- 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 (
+