diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 00000000..5ee7abd8 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +pnpm exec lint-staged diff --git a/README.md b/README.md index bc9eb1e9..0c7e06dd 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,7 @@

Claude Agent Teams UI

- Terminal tells you nothing. This shows you everything. -
- You're the CTO, agents are your team. They handle tasks themselves, message each other, review each other's code. You just look at the kanban board and drink coffee. + You're the CTO, agents are your team. They handle tasks themselves, message each other, review each other's code. You just look at the kanban board and drink coffee.

diff --git a/bin/kill-dev.js b/bin/kill-dev.js new file mode 100644 index 00000000..9d4812e8 --- /dev/null +++ b/bin/kill-dev.js @@ -0,0 +1,20 @@ +#!/usr/bin/env node +import { spawnSync } from 'child_process'; + +const isWindows = process.platform === 'win32'; + +if (isWindows) { + const r = spawnSync('taskkill', ['/F', '/IM', 'electron.exe'], { + stdio: 'inherit', + shell: true, + }); + if (r.status != null && r.status !== 0 && r.status !== 128 && r.signal == null) { + process.exitCode = 1; + } +} else { + const r = spawnSync('pkill', ['-f', 'electron-vite|electron \\.'], { stdio: 'inherit' }); + if (r.status != null && r.status !== 0 && r.status !== 1 && r.signal == null) { + process.exitCode = 1; + } +} +console.log('Done'); diff --git a/package.json b/package.json index 7eaa6de2..b2e39cf9 100644 --- a/package.json +++ b/package.json @@ -1,195 +1,208 @@ { - "name": "claude-agent-teams-ui", - "type": "module", - "version": "0.1.0", - "description": "Desktop app that visualizes Claude Code session execution — explore conversations, track context usage, and analyze tool calls", - "license": "AGPL-3.0", - "author": { - "name": "Илия (777genius)", - "email": "quantjumppro@gmail.com" + "name": "claude-agent-teams-ui", + "type": "module", + "version": "0.1.0", + "description": "Desktop app that visualizes Claude Code session execution — explore conversations, track context usage, and analyze tool calls", + "license": "AGPL-3.0", + "author": { + "name": "Илия (777genius)", + "email": "quantjumppro@gmail.com" + }, + "homepage": "https://github.com/777genius/claude_agent_teams_ui", + "repository": { + "type": "git", + "url": "https://github.com/777genius/claude_agent_teams_ui.git" + }, + "bugs": { + "url": "https://github.com/777genius/claude_agent_teams_ui/issues" + }, + "main": "dist-electron/main/index.cjs", + "scripts": { + "dev": "electron-vite dev", + "dev:kill": "node bin/kill-dev.js", + "build": "electron-vite build", + "dist": "electron-builder --mac --win --linux", + "dist:mac": "electron-builder --mac --publish always", + "dist:mac:arm64": "electron-builder --mac --arm64 --publish always", + "dist:mac:x64": "electron-builder --mac --x64 --publish always", + "dist:win": "electron-builder --win --publish always", + "dist:linux": "electron-builder --linux --publish always", + "preview": "electron-vite preview", + "typecheck": "tsc --noEmit", + "lint": "eslint src/", + "lint:fix": "eslint src/ --fix", + "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css}\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css}\"", + "check": "pnpm typecheck && pnpm lint && pnpm test && pnpm build", + "fix": "pnpm lint:fix && pnpm format", + "quality": "pnpm check && pnpm format:check && npx knip", + "test:chunks": "tsx test/test-chunk-building.ts", + "test:semantic": "tsx test/test-semantic-steps.ts", + "test:noise": "tsx test/test-noise-filtering.ts", + "test:task-filtering": "tsx test/test-task-filtering.ts", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "test:coverage:critical": "vitest run --coverage --config vitest.critical.config.ts", + "standalone": "tsx src/main/standalone.ts", + "standalone:build": "electron-vite build && vite build --config vite.standalone.config.ts", + "standalone:start": "node dist-standalone/index.cjs", + "prepare": "husky" + }, + "lint-staged": { + "src/**/*.{ts,tsx,js,jsx}": [ + "eslint --fix" + ], + "src/**/*.{ts,tsx,js,jsx,json,css}": [ + "prettier --write" + ] + }, + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@fastify/cors": "^11.2.0", + "@fastify/static": "^9.0.0", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", + "@tanstack/react-virtual": "^3.10.8", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "1.0.4", + "date-fns": "^3.6.0", + "electron-updater": "^6.7.3", + "fastify": "^5.7.4", + "idb-keyval": "^6.2.2", + "lucide-react": "^0.562.0", + "mdast-util-to-hast": "^13.2.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "ssh-config": "^5.0.4", + "ssh2": "^1.17.0", + "tailwind-merge": "^3.5.0", + "tailwindcss-animate": "^1.0.7", + "unified": "^11.0.5", + "zustand": "^4.5.0" + }, + "devDependencies": { + "@eslint-community/eslint-plugin-eslint-comments": "^4.6.0", + "@eslint/js": "^9.39.2", + "@tailwindcss/typography": "^0.5.19", + "@types/hast": "^3.0.4", + "@types/mdast": "^4.0.4", + "@types/node": "^25.0.7", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@types/ssh2": "^1.15.5", + "@vitejs/plugin-react": "^4.3.1", + "@vitest/coverage-v8": "^3.1.4", + "autoprefixer": "^10.4.17", + "electron": "^40.3.0", + "electron-builder": "^25.1.8", + "electron-vite": "^2.3.0", + "eslint": "^9.39.2", + "eslint-config-prettier": "^10.1.8", + "eslint-import-resolver-typescript": "^4.4.4", + "eslint-plugin-boundaries": "^5.3.1", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.26", + "eslint-plugin-security": "^3.0.1", + "eslint-plugin-simple-import-sort": "^12.1.1", + "eslint-plugin-sonarjs": "^3.0.6", + "eslint-plugin-tailwindcss": "^3.18.2", + "globals": "^17.2.0", + "happy-dom": "^20.0.2", + "husky": "^9.1.7", + "knip": "^5.82.1", + "lint-staged": "^16.2.7", + "postcss": "^8.4.35", + "prettier": "^3.8.1", + "prettier-plugin-tailwindcss": "^0.7.2", + "tailwindcss": "^3.4.1", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "typescript-eslint": "^8.54.0", + "vite": "^5.4.2", + "vitest": "^3.1.4" + }, + "build": { + "appId": "com.claudecode.context", + "productName": "Claude Agent Teams UI", + "directories": { + "output": "release" }, - "homepage": "https://github.com/777genius/claude_agent_teams_ui", - "repository": { - "type": "git", - "url": "https://github.com/777genius/claude_agent_teams_ui.git" + "files": [ + "out/renderer/**", + "dist-electron/**", + "package.json" + ], + "asar": true, + "asarUnpack": [ + "out/renderer/**" + ], + "npmRebuild": false, + "extraMetadata": { + "main": "dist-electron/main/index.cjs" }, - "bugs": { - "url": "https://github.com/777genius/claude_agent_teams_ui/issues" + "mac": { + "category": "public.app-category.developer-tools", + "target": [ + "dmg", + "zip" + ], + "hardenedRuntime": true, + "gatekeeperAssess": false, + "notarize": true, + "entitlements": "resources/entitlements.mac.plist", + "entitlementsInherit": "resources/entitlements.mac.inherit.plist", + "icon": "resources/icons/mac/icon.icns" }, - "main": "dist-electron/main/index.cjs", - "scripts": { - "dev": "electron-vite dev", - "dev:kill": "pkill -f 'electron-vite|electron \\.' 2>/dev/null; echo 'Done'", - "build": "electron-vite build", - "dist": "electron-builder --mac --win --linux", - "dist:mac": "electron-builder --mac --publish always", - "dist:mac:arm64": "electron-builder --mac --arm64 --publish always", - "dist:mac:x64": "electron-builder --mac --x64 --publish always", - "dist:win": "electron-builder --win --publish always", - "dist:linux": "electron-builder --linux --publish always", - "preview": "electron-vite preview", - "typecheck": "tsc --noEmit", - "lint": "eslint src/", - "lint:fix": "eslint src/ --fix", - "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css}\"", - "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css}\"", - "check": "pnpm typecheck && pnpm lint && pnpm test && pnpm build", - "fix": "pnpm lint:fix && pnpm format", - "quality": "pnpm check && pnpm format:check && npx knip", - "test:chunks": "tsx test/test-chunk-building.ts", - "test:semantic": "tsx test/test-semantic-steps.ts", - "test:noise": "tsx test/test-noise-filtering.ts", - "test:task-filtering": "tsx test/test-task-filtering.ts", - "test": "vitest run", - "test:watch": "vitest", - "test:coverage": "vitest run --coverage", - "test:coverage:critical": "vitest run --coverage --config vitest.critical.config.ts", - "standalone": "tsx src/main/standalone.ts", - "standalone:build": "electron-vite build && vite build --config vite.standalone.config.ts", - "standalone:start": "node dist-standalone/index.cjs" + "dmg": { + "sign": false }, - "dependencies": { - "@dnd-kit/core": "^6.3.1", - "@dnd-kit/sortable": "^10.0.0", - "@dnd-kit/utilities": "^3.2.2", - "@fastify/cors": "^11.2.0", - "@fastify/static": "^9.0.0", - "@radix-ui/react-checkbox": "^1.3.3", - "@radix-ui/react-collapsible": "^1.1.12", - "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-label": "^2.1.8", - "@radix-ui/react-popover": "^1.1.15", - "@radix-ui/react-select": "^2.2.6", - "@radix-ui/react-slot": "^1.2.4", - "@radix-ui/react-tabs": "^1.1.13", - "@radix-ui/react-tooltip": "^1.2.8", - "@tanstack/react-virtual": "^3.10.8", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "cmdk": "1.0.4", - "date-fns": "^3.6.0", - "electron-updater": "^6.7.3", - "fastify": "^5.7.4", - "idb-keyval": "^6.2.2", - "lucide-react": "^0.562.0", - "mdast-util-to-hast": "^13.2.1", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-markdown": "^10.1.0", - "remark-gfm": "^4.0.1", - "remark-parse": "^11.0.0", - "ssh-config": "^5.0.4", - "ssh2": "^1.17.0", - "tailwind-merge": "^3.5.0", - "tailwindcss-animate": "^1.0.7", - "unified": "^11.0.5", - "zustand": "^4.5.0" + "win": { + "target": [ + "nsis" + ], + "icon": "resources/icons/win/icon.ico" }, - "devDependencies": { - "@eslint-community/eslint-plugin-eslint-comments": "^4.6.0", - "@eslint/js": "^9.39.2", - "@tailwindcss/typography": "^0.5.19", - "@types/hast": "^3.0.4", - "@types/mdast": "^4.0.4", - "@types/node": "^25.0.7", - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", - "@types/ssh2": "^1.15.5", - "@vitejs/plugin-react": "^4.3.1", - "@vitest/coverage-v8": "^3.1.4", - "autoprefixer": "^10.4.17", - "electron": "^40.3.0", - "electron-builder": "^25.1.8", - "electron-vite": "^2.3.0", - "eslint": "^9.39.2", - "eslint-config-prettier": "^10.1.8", - "eslint-import-resolver-typescript": "^4.4.4", - "eslint-plugin-boundaries": "^5.3.1", - "eslint-plugin-import": "^2.32.0", - "eslint-plugin-jsx-a11y": "^6.10.2", - "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.4.26", - "eslint-plugin-security": "^3.0.1", - "eslint-plugin-simple-import-sort": "^12.1.1", - "eslint-plugin-sonarjs": "^3.0.6", - "eslint-plugin-tailwindcss": "^3.18.2", - "globals": "^17.2.0", - "happy-dom": "^20.0.2", - "knip": "^5.82.1", - "postcss": "^8.4.35", - "prettier": "^3.8.1", - "prettier-plugin-tailwindcss": "^0.7.2", - "tailwindcss": "^3.4.1", - "tsx": "^4.21.0", - "typescript": "^5.9.3", - "typescript-eslint": "^8.54.0", - "vite": "^5.4.2", - "vitest": "^3.1.4" + "linux": { + "target": [ + "AppImage", + "deb", + "rpm", + "pacman" + ], + "icon": "resources/icons/png", + "category": "Development" }, - "build": { - "appId": "com.claudecode.context", - "productName": "Claude Agent Teams UI", - "directories": { - "output": "release" - }, - "files": [ - "out/renderer/**", - "dist-electron/**", - "package.json" - ], - "asar": true, - "asarUnpack": [ - "out/renderer/**" - ], - "npmRebuild": false, - "extraMetadata": { - "main": "dist-electron/main/index.cjs" - }, - "mac": { - "category": "public.app-category.developer-tools", - "target": [ - "dmg", - "zip" - ], - "hardenedRuntime": true, - "gatekeeperAssess": false, - "notarize": true, - "entitlements": "resources/entitlements.mac.plist", - "entitlementsInherit": "resources/entitlements.mac.inherit.plist", - "icon": "resources/icons/mac/icon.icns" - }, - "dmg": { - "sign": false - }, - "win": { - "target": [ - "nsis" - ], - "icon": "resources/icons/win/icon.ico" - }, - "linux": { - "target": [ - "AppImage", - "deb", - "rpm", - "pacman" - ], - "icon": "resources/icons/png", - "category": "Development" - }, - "deb": { - "afterInstall": "resources/afterInstall.sh" - }, - "nsis": { - "oneClick": false, - "perMachine": false, - "allowToChangeInstallationDirectory": true - }, - "publish": [{ - "provider": "github", - "releaseType": "draft" - }] + "deb": { + "afterInstall": "resources/afterInstall.sh" }, - "packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501" -} \ No newline at end of file + "nsis": { + "oneClick": false, + "perMachine": false, + "allowToChangeInstallationDirectory": true + }, + "publish": [ + { + "provider": "github", + "releaseType": "draft" + } + ] + }, + "packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e40b1b9..2a438d88 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -204,9 +204,15 @@ importers: happy-dom: specifier: ^20.0.2 version: 20.0.2 + husky: + specifier: ^9.1.7 + version: 9.1.7 knip: specifier: ^5.82.1 version: 5.82.1(@types/node@25.0.7)(typescript@5.9.3) + lint-staged: + specifier: ^16.2.7 + version: 16.2.7 postcss: specifier: ^8.4.35 version: 8.5.6 @@ -1920,6 +1926,10 @@ packages: ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -2285,6 +2295,10 @@ packages: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + cli-spinners@2.9.2: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} @@ -2293,6 +2307,10 @@ packages: resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} engines: {node: '>=8'} + cli-truncate@5.1.1: + resolution: {integrity: sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==} + engines: {node: '>=20'} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -2325,6 +2343,9 @@ packages: resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} hasBin: true + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -2332,6 +2353,10 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -2588,6 +2613,9 @@ packages: engines: {node: '>= 12.20.55'} hasBin: true + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -2604,6 +2632,10 @@ packages: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + err-code@2.0.3: resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} @@ -2829,6 +2861,9 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -3004,6 +3039,10 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -3171,6 +3210,11 @@ packages: humanize-ms@1.2.1: resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + iconv-corefoundation@1.1.7: resolution: {integrity: sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==} engines: {node: ^8.11.2 || >=10} @@ -3295,6 +3339,10 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + is-generator-function@1.1.2: resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} engines: {node: '>= 0.4'} @@ -3521,6 +3569,15 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + lint-staged@16.2.7: + resolution: {integrity: sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==} + engines: {node: '>=20.17'} + hasBin: true + + listr2@9.0.5: + resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} + engines: {node: '>=20.0.0'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -3557,6 +3614,10 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -3778,6 +3839,10 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + mimic-response@1.0.1: resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} engines: {node: '>=4'} @@ -3858,6 +3923,10 @@ packages: nan@2.25.0: resolution: {integrity: sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==} + nano-spawn@2.0.0: + resolution: {integrity: sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==} + engines: {node: '>=20.17'} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -3961,6 +4030,10 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -4050,6 +4123,11 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + pidtree@0.6.0: + resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} + engines: {node: '>=0.10'} + hasBin: true + pify@2.3.0: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} @@ -4390,6 +4468,10 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + ret@0.5.0: resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} engines: {node: '>=10'} @@ -4554,6 +4636,10 @@ packages: resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} engines: {node: '>=8'} + slice-ansi@7.1.2: + resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} + engines: {node: '>=18'} + smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} @@ -4627,6 +4713,10 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -4635,6 +4725,14 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.2.0: + resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} + engines: {node: '>=20'} + string.prototype.includes@2.0.1: resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} engines: {node: '>= 0.4'} @@ -5094,6 +5192,10 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -6785,6 +6887,10 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -7304,6 +7410,10 @@ snapshots: dependencies: restore-cursor: 3.1.0 + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + cli-spinners@2.9.2: {} cli-truncate@2.1.0: @@ -7312,6 +7422,11 @@ snapshots: string-width: 4.2.3 optional: true + cli-truncate@5.1.1: + dependencies: + slice-ansi: 7.1.2 + string-width: 8.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -7346,12 +7461,16 @@ snapshots: color-support@1.1.3: {} + colorette@2.0.20: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 comma-separated-tokens@2.0.3: {} + commander@14.0.3: {} + commander@2.20.3: optional: true @@ -7653,6 +7772,8 @@ snapshots: transitivePeerDependencies: - supports-color + emoji-regex@10.6.0: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -7668,6 +7789,8 @@ snapshots: env-paths@2.2.1: {} + environment@1.1.0: {} + err-code@2.0.3: {} es-abstract@1.24.1: @@ -8085,6 +8208,8 @@ snapshots: esutils@2.0.3: {} + eventemitter3@5.0.4: {} + expect-type@1.3.0: {} exponential-backoff@3.1.3: {} @@ -8292,6 +8417,8 @@ snapshots: get-caller-file@2.0.5: {} + get-east-asian-width@1.5.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -8528,6 +8655,8 @@ snapshots: dependencies: ms: 2.1.3 + husky@9.1.7: {} + iconv-corefoundation@1.1.7: dependencies: cli-truncate: 2.1.0 @@ -8645,6 +8774,10 @@ snapshots: is-fullwidth-code-point@3.0.0: {} + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.5.0 + is-generator-function@1.1.2: dependencies: call-bound: 1.0.4 @@ -8871,6 +9004,25 @@ snapshots: lines-and-columns@1.2.4: {} + lint-staged@16.2.7: + dependencies: + commander: 14.0.3 + listr2: 9.0.5 + micromatch: 4.0.8 + nano-spawn: 2.0.0 + pidtree: 0.6.0 + string-argv: 0.3.2 + yaml: 2.8.2 + + listr2@9.0.5: + dependencies: + cli-truncate: 5.1.1 + colorette: 2.0.20 + eventemitter3: 5.0.4 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.2 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -8898,6 +9050,14 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 + log-update@6.1.0: + dependencies: + ansi-escapes: 7.3.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.2 + strip-ansi: 7.1.2 + wrap-ansi: 9.0.2 + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -9334,6 +9494,8 @@ snapshots: mimic-fn@2.1.0: {} + mimic-function@5.0.1: {} + mimic-response@1.0.1: {} mimic-response@3.1.0: {} @@ -9410,6 +9572,8 @@ snapshots: nan@2.25.0: optional: true + nano-spawn@2.0.0: {} + nanoid@3.3.11: {} napi-postinstall@0.3.4: {} @@ -9519,6 +9683,10 @@ snapshots: dependencies: mimic-fn: 2.1.0 + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -9631,6 +9799,8 @@ snapshots: picomatch@4.0.3: {} + pidtree@0.6.0: {} + pify@2.3.0: {} pino-abstract-transport@3.0.0: @@ -9955,6 +10125,11 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + ret@0.5.0: {} retry@0.12.0: {} @@ -10158,6 +10333,11 @@ snapshots: is-fullwidth-code-point: 3.0.0 optional: true + slice-ansi@7.1.2: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + smart-buffer@4.2.0: {} smol-toml@1.6.0: {} @@ -10224,6 +10404,8 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + string-argv@0.3.2: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -10236,6 +10418,17 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.1.2 + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.5.0 + strip-ansi: 7.1.2 + + string-width@8.2.0: + dependencies: + get-east-asian-width: 1.5.0 + strip-ansi: 7.1.2 + string.prototype.includes@2.0.1: dependencies: call-bind: 1.0.8 @@ -10826,6 +11019,12 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.2 + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.1.2 + wrappy@1.0.2: {} xmlbuilder@15.1.1: {} @@ -10836,8 +11035,7 @@ snapshots: yallist@4.0.0: {} - yaml@2.8.2: - optional: true + yaml@2.8.2: {} yargs-parser@21.1.1: {} diff --git a/src/main/index.ts b/src/main/index.ts index 91b8653f..9aa6e4c2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -155,6 +155,19 @@ function wireFileWatcherEvents(context: ServiceContext): void { mainWindow.webContents.send(TEAM_CHANGE, event); } httpServer?.broadcast('team-change', event); + + // Auto-relay direct messages to live team lead process (no UI dependency). + try { + if (!event || typeof event !== 'object') return; + const row = event as { type?: unknown; teamName?: unknown }; + if (row.type !== 'inbox') return; + if (typeof row.teamName !== 'string' || row.teamName.trim().length === 0) return; + const teamName = row.teamName.trim(); + if (!teamProvisioningService.isTeamAlive(teamName)) return; + void teamProvisioningService.relayLeadInboxMessages(teamName).catch(() => undefined); + } catch { + // ignore + } }; context.fileWatcher.on('team-change', teamChangeHandler); teamChangeCleanup = () => context.fileWatcher.off('team-change', teamChangeHandler); @@ -298,6 +311,14 @@ function initializeServices(): void { void new TeamAgentToolsInstaller().ensureInstalled(); httpServer = new HttpServer(); + // Allow TeamProvisioningService to trigger team refresh events (e.g. live lead replies). + teamProvisioningService.setTeamChangeEmitter((event) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(TEAM_CHANGE, event); + } + httpServer?.broadcast('team-change', event); + }); + // Initialize IPC handlers with registry initializeIpcHandlers( contextRegistry, diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index b309b77f..62218c45 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -21,6 +21,7 @@ import { TEAM_REQUEST_REVIEW, TEAM_SEND_MESSAGE, TEAM_START_TASK, + TEAM_STOP, TEAM_UPDATE_CONFIG, TEAM_UPDATE_KANBAN, TEAM_UPDATE_TASK_STATUS, @@ -100,6 +101,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_PROCESS_SEND, handleProcessSend); ipcMain.handle(TEAM_PROCESS_ALIVE, handleProcessAlive); ipcMain.handle(TEAM_ALIVE_LIST, handleAliveList); + ipcMain.handle(TEAM_STOP, handleStopTeam); ipcMain.handle(TEAM_CREATE_CONFIG, handleCreateConfig); ipcMain.handle(TEAM_GET_MEMBER_LOGS, handleGetMemberLogs); ipcMain.handle(TEAM_GET_LOGS_FOR_TASK, handleGetLogsForTask); @@ -128,6 +130,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_PROCESS_SEND); ipcMain.removeHandler(TEAM_PROCESS_ALIVE); ipcMain.removeHandler(TEAM_ALIVE_LIST); + ipcMain.removeHandler(TEAM_STOP); ipcMain.removeHandler(TEAM_CREATE_CONFIG); ipcMain.removeHandler(TEAM_GET_MEMBER_LOGS); ipcMain.removeHandler(TEAM_GET_LOGS_FOR_TASK); @@ -179,9 +182,58 @@ async function handleGetData( return { success: false, error: validated.error ?? 'Invalid teamName' }; } return wrapTeamHandler('getData', async () => { - const data = await getTeamDataService().getTeamData(validated.value!); - const isAlive = getTeamProvisioningService().isTeamAlive(validated.value!); - return { ...data, isAlive }; + const tn = validated.value!; + const data = await getTeamDataService().getTeamData(tn); + const provisioning = getTeamProvisioningService(); + const isAlive = provisioning.isTeamAlive(tn); + + if (isAlive) { + // Fire-and-forget: relay can take time (waits for lead reply). + void provisioning.relayLeadInboxMessages(tn).catch(() => undefined); + } + + const live = provisioning.getLiveLeadProcessMessages(tn); + if (live.length === 0) { + return { ...data, isAlive }; + } + + const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n'); + const leadSessionTextFingerprints = new Set(); + for (const msg of data.messages) { + if ((msg as { source?: unknown }).source !== 'lead_session') continue; + if (typeof msg.from !== 'string' || typeof msg.text !== 'string') continue; + leadSessionTextFingerprints.add(`${msg.from}\0${normalizeText(msg.text)}`); + } + + const keyFor = (m: { + messageId?: string; + timestamp: string; + from: string; + text: string; + }): string => { + if (typeof m.messageId === 'string' && m.messageId.trim().length > 0) { + return m.messageId; + } + return `${m.timestamp}\0${m.from}\0${(m.text ?? '').slice(0, 80)}`; + }; + + const merged: typeof data.messages = []; + const seen = new Set(); + for (const msg of [...data.messages, ...live]) { + if ((msg as { source?: unknown }).source === 'lead_process') { + const fp = `${msg.from}\0${normalizeText(msg.text ?? '')}`; + if (leadSessionTextFingerprints.has(fp)) { + continue; + } + } + const key = keyFor(msg); + if (seen.has(key)) continue; + seen.add(key); + merged.push(msg); + } + merged.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); + + return { ...data, isAlive, messages: merged }; }); } @@ -232,7 +284,9 @@ async function handleUpdateConfig( } function isProvisioningTeamName(teamName: string): boolean { - return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(teamName) && teamName.length <= 64; + if (teamName.length > 64) return false; + const parts = teamName.split('-'); + return parts.every((p) => /^[a-z0-9]+$/.test(p)); } async function validateProvisioningRequest( @@ -821,6 +875,19 @@ async function handleAliveList(_event: IpcMainInvokeEvent): Promise getTeamProvisioningService().getAliveTeams()); } +async function handleStopTeam( + _event: IpcMainInvokeEvent, + teamName: unknown +): Promise> { + const validated = validateTeamName(teamName); + if (!validated.valid) { + return { success: false, error: validated.error ?? 'Invalid teamName' }; + } + return wrapTeamHandler('stop', async () => { + getTeamProvisioningService().stopTeam(validated.value!); + }); +} + async function handleStartTask( _event: IpcMainInvokeEvent, teamName: unknown, diff --git a/src/main/ipc/utility.ts b/src/main/ipc/utility.ts index 83d1b6db..05f08390 100644 --- a/src/main/ipc/utility.ts +++ b/src/main/ipc/utility.ts @@ -12,12 +12,21 @@ import { createLogger } from '@shared/utils/logger'; import { app, type IpcMain, type IpcMainInvokeEvent, shell } from 'electron'; import * as fs from 'fs'; -import { type ClaudeMdFileInfo, readAgentConfigs, readAllClaudeMdFiles, readDirectoryClaudeMd } from '../services'; +import { + type ClaudeMdFileInfo, + readAgentConfigs, + readAllClaudeMdFiles, + readDirectoryClaudeMd, +} from '../services'; import type { AgentConfig } from '@shared/types/api'; const logger = createLogger('IPC:utility'); -import { validateFilePath, validateOpenPath } from '../utils/pathValidation'; +import { + validateFilePath, + validateOpenPath, + validateOpenPathUserSelected, +} from '../utils/pathValidation'; import { countTokens } from '../utils/tokenizer'; /** @@ -96,15 +105,19 @@ async function handleShellOpenExternal( * Handler for 'shell:openPath' IPC call. * Opens a folder or file in the system's default application (Finder on macOS). * Validates path security before opening. + * When userSelectedFromDialog is true, path was chosen via system folder picker — + * only sensitive-pattern checks apply, not project/claude directory restriction. */ async function handleShellOpenPath( _event: IpcMainInvokeEvent, targetPath: string, - projectRoot?: string + projectRoot?: string, + userSelectedFromDialog?: boolean ): Promise<{ success: boolean; error?: string }> { try { - // Validate path security - const validation = validateOpenPath(targetPath, projectRoot ?? null); + const validation = userSelectedFromDialog + ? validateOpenPathUserSelected(targetPath) + : validateOpenPath(targetPath, projectRoot ?? null); if (!validation.valid) { logger.error(`shell:openPath - validation failed: ${validation.error ?? 'Unknown error'}`); return { success: false, error: validation.error }; diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index adc55287..ade6030a 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -469,7 +469,7 @@ export class TeamDataService { for (const msg of messages) { if (!msg.messageId || !msg.summary || msg.from === 'user') continue; - if (msg.source === 'lead_session') continue; + if (msg.source === 'lead_session' || msg.source === 'lead_process') continue; const textKey = `${msg.from}\0${msg.text}`; if (processedTexts.has(textKey)) continue; diff --git a/src/main/services/team/TeamInboxWriter.ts b/src/main/services/team/TeamInboxWriter.ts index 7dce744a..3fa5badf 100644 --- a/src/main/services/team/TeamInboxWriter.ts +++ b/src/main/services/team/TeamInboxWriter.ts @@ -4,29 +4,10 @@ import * as fs from 'fs'; import * as path from 'path'; import { atomicWriteAsync } from './atomicWrite'; +import { withInboxLock } from './inboxLock'; import type { InboxMessage, SendMessageRequest, SendMessageResult } from '@shared/types'; -const writeLocks = new Map>(); - -async function withInboxLock(inboxPath: string, fn: () => Promise): Promise { - const prev = writeLocks.get(inboxPath) ?? Promise.resolve(); - let release!: () => void; - const mine = new Promise((resolve) => { - release = resolve; - }); - writeLocks.set(inboxPath, mine); - await prev; - try { - return await fn(); - } finally { - release(); - if (writeLocks.get(inboxPath) === mine) { - writeLocks.delete(inboxPath); - } - } -} - export class TeamInboxWriter { async sendMessage(teamName: string, request: SendMessageRequest): Promise { const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${request.member}.json`); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 15d66ea6..3f53f634 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -18,11 +18,14 @@ import { promisify } from 'util'; import { atomicWriteAsync } from './atomicWrite'; import { ClaudeBinaryResolver } from './ClaudeBinaryResolver'; +import { withInboxLock } from './inboxLock'; import { TeamConfigReader } from './TeamConfigReader'; import { TeamInboxReader } from './TeamInboxReader'; import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import type { + InboxMessage, + TeamChangeEvent, TeamCreateRequest, TeamCreateResponse, TeamLaunchRequest, @@ -106,6 +109,14 @@ interface ProvisioningRun { waitingTasksSince: number | null; provisioningComplete: boolean; isLaunch: boolean; + leadRelayCapture: { + leadName: string; + startedAt: string; + textParts: string[]; + resolve: (text: string) => void; + reject: (error: string) => void; + timeoutHandle: NodeJS.Timeout; + } | null; } type ProvisioningAuthSource = @@ -241,18 +252,18 @@ function buildMembersPrompt(members: TeamCreateRequest['members']): string { function buildTaskStatusProtocol(teamName: string): string { return `MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task: 1. Use this command to mark task started: - node \\"$HOME/.claude/tools/teamctl.js\\" --team \\"${teamName}\\" task start + node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task start 2. Use this command to mark task completed BEFORE sending your final reply: - node \\"$HOME/.claude/tools/teamctl.js\\" --team \\"${teamName}\\" task complete + node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task complete 3. If you are asked to review and task is accepted, move it to APPROVED (not DONE): - node \\"$HOME/.claude/tools/teamctl.js\\" --team \\"${teamName}\\" review approve + node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" review approve 4. If review fails and changes are needed: - node \\"$HOME/.claude/tools/teamctl.js\\" --team \\"${teamName}\\" review request-changes --comment \\"\\" + node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" review request-changes --comment "" 5. NEVER skip status updates. A task is NOT done until completed status is written. 6. To reply to a comment on a task: - node \\"$HOME/.claude/tools/teamctl.js\\" --team \\"${teamName}\\" task comment --text \\"\\" --from \\"\\" + node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task comment --text "" --from "" 7. When discussing a task with a teammate and you have important findings, decisions, blockers, or progress updates — record them as a task comment: - node \\"$HOME/.claude/tools/teamctl.js\\" --team \\"${teamName}\\" task comment --text \\"

\\" --from \\"\\" + node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task comment --text "" --from "" Do NOT comment on trivial coordination messages. Only comment when the information is valuable context for the task. 8. When sending a message about a specific task, include # in your SendMessage summary field for traceability. Failure to follow this protocol means the task board will show incorrect status.`; @@ -277,11 +288,19 @@ Goal: Provision a Claude Code agent team with live teammates. ${userPromptBlock} Constraints: - Do NOT call TeamDelete under any circumstances. -- Do NOT use TodoWrite — use TaskCreate for tasks. +- Do NOT use TodoWrite. - Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN). - Do NOT shut down, terminate, or clean up the team or its members. - Keep assistant text minimal. - NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough. +- Keep the task board high-signal: avoid creating tasks for trivial micro-items. +- Use teamctl.js (via Bash) for tasks that must appear on the team board (assigned work, substantial work, or when the user explicitly asks to create a task). +- TaskCreate is optional for private planning only; do NOT use it for team-board tasks. + +Communication protocol (CRITICAL — you are running headless, no one sees your text output): +- When you receive a from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient. +- Your plain text output is invisible to teammates — they are separate processes and can only read their inbox. +- Example: if you receive ..., respond with SendMessage(type: "message", recipient: "alice", content: "your reply"). Task board operations — use teamctl.js via Bash: - Create task: node "$HOME/.claude/tools/teamctl.js" --team "${request.teamName}" task create --subject "..." --description "..." --owner "" --notify --from "${leadName}" @@ -296,11 +315,16 @@ Steps (execute in this exact order): - team_name: "${request.teamName}" - name: the member's name - subagent_type: "general-purpose" - - prompt: "You are {name}, a {role} on team \\"${displayName}\\" (${request.teamName}). Introduce yourself briefly (name and role) and confirm you are ready — use the language that matches the project's CLAUDE.md or the user's locale. Then wait for task assignments. + - prompt: + You are {name}, a {role} on team "${displayName}" (${request.teamName}). + Introduce yourself briefly (name and role) and confirm you are ready — use the language that matches the project's CLAUDE.md or the user's locale. + Then wait for task assignments. -${taskProtocol}" + ${taskProtocol} -3) If user instructions above mention tasks or work for members — create each task via teamctl.js (see "Task board operations"). The --notify flag sends the assignment to the member automatically, so do NOT send a separate SendMessage for the same task. +3) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked — create tasks via teamctl.js (see "Task board operations"). + - Prefer fewer, broader tasks over many micro-tasks. + - The --notify flag sends the assignment to the member automatically, so do NOT send a separate SendMessage for the same task. 4) After all steps, output a short summary. @@ -328,11 +352,19 @@ Goal: Reconnect with existing team "${request.teamName}". ${userPromptBlock} Constraints: - Do NOT call TeamDelete under any circumstances. -- Do NOT use TodoWrite — use TaskCreate for tasks. +- Do NOT use TodoWrite. - Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN). - Do NOT shut down, terminate, or clean up the team or its members. - Keep assistant text minimal. - NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough. +- Keep the task board high-signal: avoid creating tasks for trivial micro-items. +- Use teamctl.js (via Bash) for tasks that must appear on the team board (assigned work, substantial work, or when the user explicitly asks to create a task). +- TaskCreate is optional for private planning only; do NOT use it for team-board tasks. + +Communication protocol (CRITICAL — you are running headless, no one sees your text output): +- When you receive a from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient. +- Your plain text output is invisible to teammates — they are separate processes and can only read their inbox. +- Example: if you receive ..., respond with SendMessage(type: "message", recipient: "alice", content: "your reply"). Task board operations — use teamctl.js via Bash: - Create task: node "$HOME/.claude/tools/teamctl.js" --team "${request.teamName}" task create --subject "..." --description "..." --owner "" --notify --from "${leadName}" @@ -342,17 +374,22 @@ Steps (execute in this exact order): 1) Read team config at ~/.claude/teams/${request.teamName}/config.json — understand current team state. -2) Read the task list via TaskList — understand pending work. +2) Read tasks from ~/.claude/tasks/${request.teamName}/ (JSON files) and kanban state from ~/.claude/teams/${request.teamName}/kanban-state.json — understand pending work. 3) Spawn each existing member as a live teammate using the Task tool: - team_name: "${request.teamName}" - name: the member's name - subagent_type: "general-purpose" - - prompt: "You are {name}, a {role} on team \\"${request.teamName}\\". The team has been reconnected. Introduce yourself briefly (name and role) and confirm you are ready — use the language that matches the project's CLAUDE.md or the user's locale. Then check TaskList for pending work and resume. + - prompt: + You are {name}, a {role} on team "${request.teamName}". + The team has been reconnected. Introduce yourself briefly (name and role) and confirm you are ready — use the language that matches the project's CLAUDE.md or the user's locale. + Then resume any pending work you own (if any) and wait for new assignments. -${taskProtocol}" + ${taskProtocol} -4) If user instructions above mention tasks or work for members — create each task via teamctl.js (see "Task board operations"). The --notify flag sends the assignment to the member automatically, so do NOT send a separate SendMessage for the same task. +4) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked — create tasks via teamctl.js (see "Task board operations"). + - Prefer fewer, broader tasks over many micro-tasks. + - The --notify flag sends the assignment to the member automatically, so do NOT send a separate SendMessage for the same task. 5) After all steps, output a short summary. @@ -445,6 +482,11 @@ let cachedProbeResult: CachedProbeResult | null = null; export class TeamProvisioningService { private readonly runs = new Map(); private readonly activeByTeam = new Map(); + private readonly leadInboxRelayInFlight = new Map>(); + private readonly relayedLeadInboxMessageIds = new Map>(); + private readonly relayedLeadInboxFallbackKeys = new Map>(); + private readonly liveLeadProcessMessages = new Map(); + private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null; constructor( private readonly configReader: TeamConfigReader = new TeamConfigReader(), @@ -452,6 +494,14 @@ export class TeamProvisioningService { private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore() ) {} + setTeamChangeEmitter(emitter: ((event: TeamChangeEvent) => void) | null): void { + this.teamChangeEmitter = emitter; + } + + getLiveLeadProcessMessages(teamName: string): InboxMessage[] { + return [...(this.liveLeadProcessMessages.get(teamName) ?? [])]; + } + async warmup(): Promise { try { const claudePath = await ClaudeBinaryResolver.resolve(); @@ -607,6 +657,7 @@ export class TeamProvisioningService { provisioningComplete: false, isLaunch: false, fsPhase: 'waiting_config', + leadRelayCapture: null, progress: { runId, teamName: request.teamName, @@ -874,6 +925,7 @@ export class TeamProvisioningService { provisioningComplete: false, isLaunch: true, fsPhase: 'waiting_members', + leadRelayCapture: null, progress: { runId, teamName: request.teamName, @@ -1095,6 +1147,168 @@ export class TeamProvisioningService { run.child.stdin.write(payload + '\n'); } + /** + * Relay unread inbox messages addressed to the team lead into the live lead process. + * + * Why: teammates (and the UI) write to `inboxes/.json`, but the live lead CLI + * process consumes new turns via stream-json stdin. Without relaying, the lead + * appears unresponsive to direct messages. + * + * Returns the number of messages relayed. + */ + async relayLeadInboxMessages(teamName: string): Promise { + const existing = this.leadInboxRelayInFlight.get(teamName); + if (existing) { + return existing; + } + + const work = (async (): Promise => { + const runId = this.activeByTeam.get(teamName); + if (!runId) return 0; + const run = this.runs.get(runId); + if (!run?.child || run.processKilled || run.cancelRequested) return 0; + if (!run.provisioningComplete) return 0; + + const relayedIds = this.relayedLeadInboxMessageIds.get(teamName) ?? new Set(); + const relayedFallback = this.relayedLeadInboxFallbackKeys.get(teamName) ?? new Set(); + + let config: Awaited> | null = null; + try { + config = await this.configReader.getConfig(teamName); + } catch { + return 0; + } + if (!config) return 0; + + const leadName = + config.members?.find((m) => m?.agentType === 'team-lead')?.name?.trim() || 'team-lead'; + + let leadInboxMessages: Awaited> = []; + try { + leadInboxMessages = await this.inboxReader.getMessagesFor(teamName, leadName); + } catch { + return 0; + } + + const unread = leadInboxMessages + .filter((m) => { + if (m.read) return false; + if (typeof m.text !== 'string' || m.text.trim().length === 0) return false; + if (typeof m.messageId === 'string' && m.messageId.trim().length > 0) { + return !relayedIds.has(m.messageId); + } + return !relayedFallback.has(`${m.timestamp}\0${m.from}\0${m.text}`); + }) + .sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); + + if (unread.length === 0) return 0; + + const MAX_RELAY = 10; + const batch = unread.slice(0, MAX_RELAY); + + const message = [ + `You have new inbox messages addressed to you (team lead "${leadName}").`, + `Process them in order (oldest first).`, + `If action is required, delegate via task creation (teamctl.js --notify) or SendMessage, and keep responses minimal.`, + ``, + `Messages:`, + ...batch.flatMap((m, idx) => { + const summaryLine = m.summary?.trim() ? `Summary: ${m.summary.trim()}` : null; + return [ + `${idx + 1}) From: ${m.from || 'unknown'}`, + ` Timestamp: ${m.timestamp}`, + ...(summaryLine ? [` ${summaryLine}`] : []), + ` Text:`, + ...m.text.split('\n').map((line) => ` ${line}`), + ``, + ]; + }), + ].join('\n'); + + const captureTimeoutMs = 60_000; + const capturePromise = new Promise((resolve, reject) => { + const timeoutHandle = setTimeout(() => { + reject(new Error('Timed out waiting for lead reply')); + }, captureTimeoutMs); + run.leadRelayCapture = { + leadName, + startedAt: nowIso(), + textParts: [], + resolve, + reject, + timeoutHandle, + }; + }); + + try { + await this.sendMessageToTeam(teamName, message); + } catch { + if (run.leadRelayCapture) { + clearTimeout(run.leadRelayCapture.timeoutHandle); + run.leadRelayCapture = null; + } + return 0; + } + + for (const m of batch) { + if (typeof m.messageId === 'string' && m.messageId.trim().length > 0) { + relayedIds.add(m.messageId); + } else { + relayedFallback.add(`${m.timestamp}\0${m.from}\0${m.text}`); + } + } + this.relayedLeadInboxMessageIds.set(teamName, this.trimRelayedSet(relayedIds)); + this.relayedLeadInboxFallbackKeys.set(teamName, this.trimRelayedSet(relayedFallback)); + + try { + await this.markInboxMessagesRead(teamName, leadName, batch); + } catch { + // Best-effort: relay succeeded; marking read failed. + } + + let replyText: string | null = null; + try { + replyText = (await capturePromise).trim() || null; + } catch { + // ignore + } finally { + if (run.leadRelayCapture) { + clearTimeout(run.leadRelayCapture.timeoutHandle); + run.leadRelayCapture = null; + } + } + + if (replyText) { + this.pushLiveLeadProcessMessage(teamName, { + from: leadName, + to: 'user', + text: replyText, + timestamp: nowIso(), + read: true, + summary: 'Lead reply', + messageId: `lead-process-${runId}-${Date.now()}`, + source: 'lead_process', + }); + this.teamChangeEmitter?.({ + type: 'inbox', + teamName, + detail: 'lead-process-reply', + }); + } + + return batch.length; + })(); + + this.leadInboxRelayInFlight.set(teamName, work); + try { + return await work; + } finally { + if (this.leadInboxRelayInFlight.get(teamName) === work) { + this.leadInboxRelayInFlight.delete(teamName); + } + } + } + /** * Check if a team has a live process. */ @@ -1112,6 +1326,108 @@ export class TeamProvisioningService { return Array.from(this.activeByTeam.keys()).filter((name) => this.isTeamAlive(name)); } + private async markInboxMessagesRead( + teamName: string, + member: string, + messages: { messageId?: string; timestamp: string; from: string; text: string }[] + ): Promise { + const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${member}.json`); + + await withInboxLock(inboxPath, async () => { + let raw: string; + try { + raw = await fs.promises.readFile(inboxPath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return; + } + throw error; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw) as unknown; + } catch { + return; + } + if (!Array.isArray(parsed)) return; + + const ids = new Set(messages.map((m) => m.messageId).filter((id): id is string => !!id)); + const fallbackKeys = new Set( + messages.filter((m) => !m.messageId).map((m) => `${m.timestamp}\0${m.from}\0${m.text}`) + ); + + let changed = false; + for (const item of parsed) { + if (!item || typeof item !== 'object') continue; + const row = item as Record; + const msgId = typeof row.messageId === 'string' ? row.messageId : null; + const timestamp = typeof row.timestamp === 'string' ? row.timestamp : null; + const from = typeof row.from === 'string' ? row.from : null; + const text = typeof row.text === 'string' ? row.text : null; + + const matchesId = msgId ? ids.has(msgId) : false; + const matchesFallback = + !msgId && timestamp && from && text + ? fallbackKeys.has(`${timestamp}\0${from}\0${text}`) + : false; + + if (!matchesId && !matchesFallback) continue; + + if (row.read !== true) { + row.read = true; + changed = true; + } + } + + if (!changed) return; + await atomicWriteAsync(inboxPath, JSON.stringify(parsed, null, 2)); + }); + } + + private trimRelayedSet(set: Set): Set { + const MAX_IDS = 2000; + if (set.size <= MAX_IDS) return set; + const next = new Set(); + const tail = Array.from(set).slice(-MAX_IDS); + for (const id of tail) next.add(id); + return next; + } + + private pushLiveLeadProcessMessage(teamName: string, message: InboxMessage): void { + const MAX = 100; + const list = this.liveLeadProcessMessages.get(teamName) ?? []; + list.push(message); + if (list.length > MAX) { + list.splice(0, list.length - MAX); + } + this.liveLeadProcessMessages.set(teamName, list); + } + + /** + * Stop the running process for a team. No-op if team is not running. + */ + stopTeam(teamName: string): void { + const runId = this.activeByTeam.get(teamName); + if (!runId) { + return; + } + const run = this.runs.get(runId); + if (!run) { + this.activeByTeam.delete(teamName); + return; + } + if (run.processKilled || run.cancelRequested) { + return; + } + run.processKilled = true; + run.cancelRequested = true; + run.child?.stdin?.end(); + run.child?.kill(); + this.cleanupRun(run); + logger.info(`[${teamName}] Process stopped by user`); + } + /** * Process a parsed stream-json message from stdout. * Extracts assistant text for progress reporting and detects turn completion. @@ -1127,6 +1443,9 @@ export class TeamProvisioningService { if (textParts.length > 0) { const text = textParts.join(''); logger.debug(`[${run.teamName}] assistant: ${text.slice(0, 200)}`); + if (run.leadRelayCapture) { + run.leadRelayCapture.textParts.push(text); + } } } @@ -1134,6 +1453,11 @@ export class TeamProvisioningService { const subtype = msg.subtype as string | undefined; if (subtype === 'success') { logger.info(`[${run.teamName}] stream-json result: success — turn complete, process alive`); + if (run.leadRelayCapture) { + const capture = run.leadRelayCapture; + const combined = capture.textParts.join('').trim(); + capture.resolve(combined); + } if (!run.provisioningComplete) { void this.handleProvisioningTurnComplete(run); } @@ -1141,6 +1465,9 @@ export class TeamProvisioningService { const errorMsg = typeof msg.error === 'string' ? msg.error : JSON.stringify(msg.error ?? 'unknown'); logger.warn(`[${run.teamName}] stream-json result: error — ${errorMsg}`); + if (run.leadRelayCapture) { + run.leadRelayCapture.reject(errorMsg); + } if (!run.provisioningComplete) { const progress = updateProgress( run, @@ -1186,6 +1513,9 @@ export class TeamProvisioningService { }); run.onProgress(progress); logger.info(`[${run.teamName}] Launch complete. Process alive for subsequent tasks.`); + + // Pick up any direct messages that arrived before/while reconnecting. + void this.relayLeadInboxMessages(run.teamName).catch(() => undefined); return; } @@ -1224,6 +1554,9 @@ export class TeamProvisioningService { run.onProgress(progress); // NOTE: do NOT remove from activeByTeam — process stays alive logger.info(`[${run.teamName}] Provisioning complete. Process alive for subsequent tasks.`); + + // Pick up any direct messages that arrived during provisioning. + void this.relayLeadInboxMessages(run.teamName).catch(() => undefined); } /** @@ -1236,6 +1569,10 @@ export class TeamProvisioningService { } this.stopFilesystemMonitor(run); this.activeByTeam.delete(run.teamName); + this.leadInboxRelayInFlight.delete(run.teamName); + this.relayedLeadInboxMessageIds.delete(run.teamName); + this.relayedLeadInboxFallbackKeys.delete(run.teamName); + this.liveLeadProcessMessages.delete(run.teamName); } /** diff --git a/src/main/services/team/inboxLock.ts b/src/main/services/team/inboxLock.ts new file mode 100644 index 00000000..a7a79e33 --- /dev/null +++ b/src/main/services/team/inboxLock.ts @@ -0,0 +1,19 @@ +const WRITE_LOCKS = new Map>(); + +export async function withInboxLock(inboxPath: string, fn: () => Promise): Promise { + const prev = WRITE_LOCKS.get(inboxPath) ?? Promise.resolve(); + let release!: () => void; + const mine = new Promise((resolve) => { + release = resolve; + }); + WRITE_LOCKS.set(inboxPath, mine); + await prev; + try { + return await fn(); + } finally { + release(); + if (WRITE_LOCKS.get(inboxPath) === mine) { + WRITE_LOCKS.delete(inboxPath); + } + } +} diff --git a/src/main/utils/pathValidation.ts b/src/main/utils/pathValidation.ts index 2a92463c..dc9c6d34 100644 --- a/src/main/utils/pathValidation.ts +++ b/src/main/utils/pathValidation.ts @@ -198,6 +198,45 @@ export function validateFilePath( return { valid: true, normalizedPath }; } +/** + * Validates a path for opening when it was explicitly chosen by the user + * via the system folder picker. Only checks sensitive patterns, not + * allowed-directories (project / ~/.claude). + * + * @param targetPath - The path to open + * @returns Validation result + */ +export function validateOpenPathUserSelected(targetPath: string): PathValidationResult { + if (!targetPath || typeof targetPath !== 'string') { + return { valid: false, error: 'Invalid path' }; + } + + const expandedPath = targetPath.startsWith('~') + ? path.join(os.homedir(), targetPath.slice(1)) + : targetPath; + + const normalizedPath = path.resolve(path.normalize(expandedPath)); + + if (!path.isAbsolute(normalizedPath)) { + return { valid: false, error: 'Path must be absolute' }; + } + + if (matchesSensitivePattern(normalizedPath)) { + return { valid: false, error: 'Cannot open sensitive files' }; + } + + const realTargetPath = resolveRealPathIfExists(normalizedPath); + if (realTargetPath) { + const isWindows = process.platform === 'win32'; + const normalizedRealTarget = normalizeForCompare(realTargetPath, isWindows); + if (matchesSensitivePattern(normalizedRealTarget)) { + return { valid: false, error: 'Cannot open sensitive files' }; + } + } + + return { valid: true, normalizedPath }; +} + /** * Validates a path for shell:openPath operation. * More permissive than file reading - allows opening project directories diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 71914682..1a71dd20 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -235,6 +235,7 @@ export const TEAM_DELETE_TEAM = 'team:deleteTeam'; /** Get list of teams with live CLI processes */ export const TEAM_ALIVE_LIST = 'team:aliveList'; +export const TEAM_STOP = 'team:stop'; /** Create team config without provisioning CLI */ export const TEAM_CREATE_CONFIG = 'team:createConfig'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 60f89524..99b3533f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -41,6 +41,7 @@ import { TEAM_REQUEST_REVIEW, TEAM_SEND_MESSAGE, TEAM_START_TASK, + TEAM_STOP, TEAM_UPDATE_CONFIG, TEAM_UPDATE_KANBAN, TEAM_UPDATE_TASK_STATUS, @@ -387,8 +388,8 @@ const electronAPI: ElectronAPI = { }, // Shell operations - openPath: (targetPath: string, projectRoot?: string) => - ipcRenderer.invoke('shell:openPath', targetPath, projectRoot), + openPath: (targetPath: string, projectRoot?: string, userSelectedFromDialog?: boolean) => + ipcRenderer.invoke('shell:openPath', targetPath, projectRoot, userSelectedFromDialog), openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url), // Window controls (when title bar is hidden, e.g. Windows / Linux) @@ -567,6 +568,9 @@ const electronAPI: ElectronAPI = { aliveList: async () => { return invokeIpcWithResult(TEAM_ALIVE_LIST); }, + stop: async (teamName: string) => { + return invokeIpcWithResult(TEAM_STOP, teamName); + }, createConfig: async (request: TeamCreateConfigRequest) => { return invokeIpcWithResult(TEAM_CREATE_CONFIG, request); }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 6115fa22..99048216 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -685,6 +685,9 @@ export class HttpAPIClient implements ElectronAPI { aliveList: async (): Promise => { return []; }, + stop: async (): Promise => { + throw new Error('Team stop is not available in browser mode'); + }, createConfig: async (): Promise => { throw new Error('Team config creation is not available in browser mode'); }, diff --git a/src/renderer/components/dashboard/DashboardView.tsx b/src/renderer/components/dashboard/DashboardView.tsx index 3189abdb..3e57503b 100644 --- a/src/renderer/components/dashboard/DashboardView.tsx +++ b/src/renderer/components/dashboard/DashboardView.tsx @@ -214,6 +214,40 @@ const RepositoryCard = ({ · {lastActivity} + + {/* Tasks progress bar */} + {taskCounts && + (() => { + const pending = taskCounts.pending ?? 0; + const inProgress = taskCounts.inProgress ?? 0; + const completed = taskCounts.completed ?? 0; + const totalTasks = pending + inProgress + completed; + if (totalTasks === 0) return null; + const completedRatio = completed / totalTasks; + const progressPercent = Math.round(completedRatio * 100); + return ( +
+
+
+
+
+ + {completed}/{totalTasks} + +
+
+ ); + })()} ); }; @@ -250,7 +284,7 @@ const NewProjectCard = (): React.JSX.Element => { } // No match found - open the folder in file manager as fallback - const result = await api.openPath(selectedPath); + const result = await api.openPath(selectedPath, undefined, true); if (!result.success) { logger.error('Failed to open folder:', result.error); } diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 35cbfd92..dfd91893 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -13,6 +13,7 @@ import { useShallow } from 'zustand/react/shallow'; import { ActiveTasksBlock } from './activity/ActiveTasksBlock'; import { ActivityTimeline } from './activity/ActivityTimeline'; +import { PendingRepliesBlock } from './activity/PendingRepliesBlock'; import { CreateTaskDialog } from './dialogs/CreateTaskDialog'; import { EditTeamDialog } from './dialogs/EditTeamDialog'; import { LaunchTeamDialog } from './dialogs/LaunchTeamDialog'; @@ -67,6 +68,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele const [requestChangesTaskId, setRequestChangesTaskId] = useState(null); const [selectedTask, setSelectedTask] = useState(null); const [selectedMember, setSelectedMember] = useState(null); + const [pendingRepliesByMember, setPendingRepliesByMember] = useState>({}); const [createTaskDialog, setCreateTaskDialog] = useState({ open: false, defaultSubject: '', @@ -309,6 +311,24 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele const memberTaskCounts = useMemo(() => buildTaskCountsByOwner(data?.tasks ?? []), [data?.tasks]); + useEffect(() => { + if (!data || Object.keys(pendingRepliesByMember).length === 0) return; + const next = { ...pendingRepliesByMember }; + let changed = false; + for (const [memberName, sentAtMs] of Object.entries(pendingRepliesByMember)) { + const hasReply = data.messages.some((m) => { + if (m.from !== memberName) return false; + const ts = Date.parse(m.timestamp); + return Number.isFinite(ts) && ts > sentAtMs; + }); + if (hasReply) { + delete next[memberName]; + changed = true; + } + } + if (changed) setPendingRepliesByMember(next); + }, [data, pendingRepliesByMember]); + const openCreateTaskDialog = (subject = '', description = '', owner = ''): void => { setCreateTaskDialog({ open: true, @@ -504,6 +524,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele { @@ -514,6 +536,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele onAssignTask={(member) => { openCreateTaskDialog('', '', member.name); }} + onOpenTask={(task) => setSelectedTask(task)} /> @@ -677,6 +700,11 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
} > + setRequestChangesTaskId(null)} onSubmit={(comment) => { if (!requestChangesTaskId) { @@ -791,7 +820,20 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele sendError={sendMessageError} lastResult={lastSendMessageResult} onSend={(member, text, summary) => { - void sendTeamMessage(teamName, { member, text, summary }); + void (async () => { + const sentAtMs = Date.now(); + setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); + try { + await sendTeamMessage(teamName, { member, text, summary }); + } catch { + setPendingRepliesByMember((prev) => { + if (prev[member] !== sentAtMs) return prev; + const next = { ...prev }; + delete next[member]; + return next; + }); + } + })(); }} onClose={() => { setSendDialogOpen(false); diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index 95495353..aa48443b 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -14,14 +14,14 @@ import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useStore } from '@renderer/store'; import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize'; import { getBaseName } from '@renderer/utils/pathUtils'; -import { CheckCircle, Clock, Copy, FolderOpen, Play, Search, Trash2 } from 'lucide-react'; +import { CheckCircle, Clock, Copy, FolderOpen, Play, Search, Square, Trash2 } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { CreateTeamDialog } from './dialogs/CreateTeamDialog'; import { TeamEmptyState } from './TeamEmptyState'; import type { TeamCopyData } from './dialogs/CreateTeamDialog'; -import type { TeamProvisioningProgress, TeamSummary } from '@shared/types'; +import type { TeamCreateRequest, TeamProvisioningProgress, TeamSummary } from '@shared/types'; function generateUniqueName(sourceName: string, existingNames: string[]): string { const base = sourceName.replace(/-\d+$/, ''); @@ -243,6 +243,20 @@ export const TeamListView = (): React.JSX.Element => { [teams] ); + const [stoppingTeamName, setStoppingTeamName] = useState(null); + const handleStopTeam = useCallback(async (teamName: string, e: React.MouseEvent) => { + e.stopPropagation(); + setStoppingTeamName(teamName); + try { + await api.teams.stop(teamName); + setAliveTeams((prev) => prev.filter((n) => n !== teamName)); + } catch (err) { + console.error('Failed to stop team:', err); + } finally { + setStoppingTeamName(null); + } + }, []); + useEffect(() => { if (!electronMode) { return; @@ -253,6 +267,18 @@ export const TeamListView = (): React.JSX.Element => { const taskCountsByTeam = useMemo(() => buildTaskCountsByTeam(globalTasks), [globalTasks]); + const handleCreateDialogClose = useCallback(() => { + setShowCreateDialog(false); + setCopyData(null); + }, []); + + const handleCreateSubmit = useCallback( + async (request: TeamCreateRequest) => { + await createTeam(request); + }, + [createTeam] + ); + if (!electronMode) { return (
@@ -276,13 +302,8 @@ export const TeamListView = (): React.JSX.Element => { existingTeamNames={teams.map((t) => t.teamName)} initialData={copyData ?? undefined} defaultProjectPath={currentProjectPath} - onClose={() => { - setShowCreateDialog(false); - setCopyData(null); - }} - onCreate={async (request) => { - await createTeam(request); - }} + onClose={handleCreateDialogClose} + onCreate={handleCreateSubmit} onOpenTeam={openTeamTab} /> ); @@ -396,12 +417,26 @@ export const TeamListView = (): React.JSX.Element => { {filteredTeams.map((team) => { const status = resolveTeamStatus(team.teamName, aliveTeams, provisioningRuns); const teamColorSet = team.color ? getTeamColorSet(team.color) : null; + const matchesCurrentProject = + !!currentProjectPath && + (() => { + if (team.projectPath && normalizePath(team.projectPath) === currentProjectPath) + return true; + return ( + team.projectPathHistory?.some((p) => normalizePath(p) === currentProjectPath) ?? + false + ); + })(); return (
{
+ {status === 'running' && ( + + + + + + {stoppingTeamName === team.teamName ? 'Stopping…' : 'Stop team'} + + + )}
+ {/* Content — collapsed for system messages, expanded for others */} {isExpanded ? ( diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index 00dde0b1..137cb808 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -44,11 +44,8 @@ const MessageRowWithObserver = ({ useEffect(() => { messageRef.current = message; - }, [message]); - - useEffect(() => { onVisibleRef.current = onVisible; - }, [onVisible]); + }, [message, onVisible]); useEffect(() => { if (!onVisible) return; diff --git a/src/renderer/components/team/activity/PendingRepliesBlock.tsx b/src/renderer/components/team/activity/PendingRepliesBlock.tsx new file mode 100644 index 00000000..320398a3 --- /dev/null +++ b/src/renderer/components/team/activity/PendingRepliesBlock.tsx @@ -0,0 +1,105 @@ +import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables'; +import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { formatAgentRole } from '@renderer/utils/formatAgentRole'; +import { formatDistanceToNowStrict } from 'date-fns'; +import { Loader2 } from 'lucide-react'; + +import type { ResolvedTeamMember } from '@shared/types'; + +interface PendingRepliesBlockProps { + members: ResolvedTeamMember[]; + pendingRepliesByMember: Record; + onMemberClick?: (member: ResolvedTeamMember) => void; +} + +export const PendingRepliesBlock = ({ + members, + pendingRepliesByMember, + onMemberClick, +}: PendingRepliesBlockProps): React.JSX.Element | null => { + const pending = Object.entries(pendingRepliesByMember) + .map(([name, sentAtMs]) => ({ + member: members.find((m) => m.name === name) ?? null, + name, + sentAtMs, + })) + .filter((p): p is { member: ResolvedTeamMember; name: string; sentAtMs: number } => !!p.member) + .sort((a, b) => b.sentAtMs - a.sentAtMs); + + if (pending.length === 0) return null; + + return ( +
+

+ Awaiting replies +

+ {pending.map(({ member, sentAtMs }) => { + const colors = getTeamColorSet(member.color ?? ''); + const roleLabel = formatAgentRole( + member.role ?? (member.agentType !== 'general-purpose' ? member.agentType : undefined) + ); + const since = formatDistanceToNowStrict(sentAtMs, { addSuffix: true }); + + return ( +
+
+ + {onMemberClick ? ( + + ) : ( + + {member.name} + + )} + {roleLabel ? ( + + {roleLabel} + + ) : null} + + awaiting reply + + + {since} + +
+
+ ); + })} +
+ ); +}; diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 7557bb41..37d02ab8 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -798,7 +798,7 @@ export const CreateTeamDialog = ({ {launchTeam ? (
- +