Merge pull request #4 from 777genius/improvements

improvements
This commit is contained in:
Илия 2026-02-23 17:38:53 +02:00 committed by GitHub
commit e97fa7635f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1698 additions and 363 deletions

1
.husky/pre-commit Normal file
View file

@ -0,0 +1 @@
pnpm exec lint-staged

View file

@ -5,9 +5,7 @@
<h1 align="center">Claude Agent Teams UI</h1>
<p align="center">
<strong><code>Terminal tells you nothing. This shows you everything.</code></strong>
<br />
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.
<strong><code>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.</code></strong>
</p>
<p align="center">

20
bin/kill-dev.js Normal file
View file

@ -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');

View file

@ -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"
}
"nsis": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true
},
"publish": [
{
"provider": "github",
"releaseType": "draft"
}
]
},
"packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501"
}

View file

@ -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: {}

View file

@ -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,

View file

@ -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<string>();
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<string>();
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<IpcResult<st
return wrapTeamHandler('aliveList', async () => getTeamProvisioningService().getAliveTeams());
}
async function handleStopTeam(
_event: IpcMainInvokeEvent,
teamName: unknown
): Promise<IpcResult<void>> {
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,

View file

@ -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 };

View file

@ -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;

View file

@ -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<string, Promise<void>>();
async function withInboxLock<T>(inboxPath: string, fn: () => Promise<T>): Promise<T> {
const prev = writeLocks.get(inboxPath) ?? Promise.resolve();
let release!: () => void;
const mine = new Promise<void>((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<SendMessageResult> {
const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${request.member}.json`);

View file

@ -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 <taskId>
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task start <taskId>
2. Use this command to mark task completed BEFORE sending your final reply:
node \\"$HOME/.claude/tools/teamctl.js\\" --team \\"${teamName}\\" task complete <taskId>
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task complete <taskId>
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 <taskId>
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" review approve <taskId>
4. If review fails and changes are needed:
node \\"$HOME/.claude/tools/teamctl.js\\" --team \\"${teamName}\\" review request-changes <taskId> --comment \\"<what to fix>\\"
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" review request-changes <taskId> --comment "<what to fix>"
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 <taskId> --text \\"<your reply>\\" --from \\"<your-name>\\"
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task comment <taskId> --text "<your reply>" --from "<your-name>"
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 <taskId> --text \\"<summary of your finding or decision>\\" --from \\"<your-name>\\"
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task comment <taskId> --text "<summary of your finding or decision>" --from "<your-name>"
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 #<taskId> 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 <teammate-message> 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 <teammate-message teammate_id="alice">...</teammate-message>, 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 "<actual-member-name>" --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 <teammate-message> 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 <teammate-message teammate_id="alice">...</teammate-message>, 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 "<actual-member-name>" --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<string, ProvisioningRun>();
private readonly activeByTeam = new Map<string, string>();
private readonly leadInboxRelayInFlight = new Map<string, Promise<number>>();
private readonly relayedLeadInboxMessageIds = new Map<string, Set<string>>();
private readonly relayedLeadInboxFallbackKeys = new Map<string, Set<string>>();
private readonly liveLeadProcessMessages = new Map<string, InboxMessage[]>();
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<void> {
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/<lead>.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<number> {
const existing = this.leadInboxRelayInFlight.get(teamName);
if (existing) {
return existing;
}
const work = (async (): Promise<number> => {
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<string>();
const relayedFallback = this.relayedLeadInboxFallbackKeys.get(teamName) ?? new Set<string>();
let config: Awaited<ReturnType<TeamConfigReader['getConfig']>> | 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<ReturnType<TeamInboxReader['getMessagesFor']>> = [];
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<string>((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<void> {
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<string, unknown>;
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<string>): Set<string> {
const MAX_IDS = 2000;
if (set.size <= MAX_IDS) return set;
const next = new Set<string>();
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);
}
/**

View file

@ -0,0 +1,19 @@
const WRITE_LOCKS = new Map<string, Promise<void>>();
export async function withInboxLock<T>(inboxPath: string, fn: () => Promise<T>): Promise<T> {
const prev = WRITE_LOCKS.get(inboxPath) ?? Promise.resolve();
let release!: () => void;
const mine = new Promise<void>((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);
}
}
}

View file

@ -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

View file

@ -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';

View file

@ -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<string[]>(TEAM_ALIVE_LIST);
},
stop: async (teamName: string) => {
return invokeIpcWithResult<void>(TEAM_STOP, teamName);
},
createConfig: async (request: TeamCreateConfigRequest) => {
return invokeIpcWithResult<void>(TEAM_CREATE_CONFIG, request);
},

View file

@ -685,6 +685,9 @@ export class HttpAPIClient implements ElectronAPI {
aliveList: async (): Promise<string[]> => {
return [];
},
stop: async (): Promise<void> => {
throw new Error('Team stop is not available in browser mode');
},
createConfig: async (): Promise<void> => {
throw new Error('Team config creation is not available in browser mode');
},

View file

@ -214,6 +214,40 @@ const RepositoryCard = ({
<span className="text-text-muted">·</span>
<span className="text-[10px] text-text-muted">{lastActivity}</span>
</div>
{/* 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 (
<div className="mt-2 w-full space-y-1">
<div className="flex items-center gap-2">
<div
className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--color-surface-raised)]"
role="progressbar"
aria-valuenow={completed}
aria-valuemin={0}
aria-valuemax={totalTasks}
aria-label={`Tasks ${completed}/${totalTasks} completed`}
>
<div
className="h-full rounded-full bg-emerald-500 transition-all duration-200"
style={{ width: `${progressPercent}%` }}
/>
</div>
<span className="shrink-0 text-[10px] font-medium tracking-tight text-[var(--color-text-muted)]">
{completed}/{totalTasks}
</span>
</div>
</div>
);
})()}
</button>
);
};
@ -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);
}

View file

@ -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<string | null>(null);
const [selectedTask, setSelectedTask] = useState<TeamTask | null>(null);
const [selectedMember, setSelectedMember] = useState<ResolvedTeamMember | null>(null);
const [pendingRepliesByMember, setPendingRepliesByMember] = useState<Record<string, number>>({});
const [createTaskDialog, setCreateTaskDialog] = useState<CreateTaskDialogState>({
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
<MemberList
members={data.members}
memberTaskCounts={memberTaskCounts}
taskMap={taskMap}
pendingRepliesByMember={pendingRepliesByMember}
isTeamAlive={data.isAlive}
onMemberClick={setSelectedMember}
onSendMessage={(member) => {
@ -514,6 +536,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
onAssignTask={(member) => {
openCreateTaskDialog('', '', member.name);
}}
onOpenTask={(task) => setSelectedTask(task)}
/>
</CollapsibleTeamSection>
@ -677,6 +700,11 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
</div>
}
>
<PendingRepliesBlock
members={data.members}
pendingRepliesByMember={pendingRepliesByMember}
onMemberClick={setSelectedMember}
/>
<ActiveTasksBlock
members={data.members}
tasks={data.tasks}
@ -703,6 +731,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
open={requestChangesTaskId !== null}
teamName={teamName}
taskId={requestChangesTaskId}
members={data?.members ?? []}
onCancel={() => 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);

View file

@ -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<string | null>(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 (
<div className="flex size-full items-center justify-center p-6">
@ -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 (
<div
key={team.teamName}
role="button"
tabIndex={0}
className="group relative cursor-pointer overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)]"
className={`group relative cursor-pointer overflow-hidden rounded-lg border bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)] ${
matchesCurrentProject
? 'border-emerald-500/70 ring-1 ring-emerald-500/30'
: 'border-[var(--color-border)]'
}`}
style={
teamColorSet
? { borderLeftWidth: '3px', borderLeftColor: teamColorSet.border }
@ -430,6 +465,24 @@ export const TeamListView = (): React.JSX.Element => {
<StatusBadge status={status} />
</div>
<div className="flex shrink-0 gap-1">
{status === 'running' && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-amber-500/10 hover:text-amber-300 disabled:opacity-50 group-hover:opacity-100"
onClick={(e) => handleStopTeam(team.teamName, e)}
disabled={stoppingTeamName === team.teamName}
aria-label="Stop team"
>
<Square size={14} fill="currentColor" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{stoppingTeamName === team.teamName ? 'Stopping…' : 'Stop team'}
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<button

View file

@ -25,7 +25,7 @@ export const ActiveTasksBlock = ({
return (
<div className="mb-3 space-y-1.5">
<p className="text-[10px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
Сейчас в работе
In progress
</p>
{working.map((member) => {
const taskId = member.currentTaskId!;
@ -88,7 +88,7 @@ export const ActiveTasksBlock = ({
className="min-w-0 flex-1 truncate text-[10px]"
style={{ color: CARD_ICON_MUTED }}
>
выполняет
working on
</span>
{task &&
(onTaskClick ? (

View file

@ -175,6 +175,7 @@ export const ActivityItem = ({
};
const summaryText = message.summary || autoSummary || '';
const HeaderTag = systemLabel ? 'button' : 'div';
return (
<article
@ -186,13 +187,12 @@ export const ActivityItem = ({
}}
>
{/* Header — clickable when system message to toggle expand */}
<div
<HeaderTag
type={systemLabel ? 'button' : undefined}
className={[
'flex items-center gap-2 px-3 py-2',
systemLabel ? 'cursor-pointer select-none' : '',
systemLabel ? 'w-full cursor-pointer select-none border-0 bg-transparent text-left' : '',
].join(' ')}
role={systemLabel ? 'button' : undefined}
tabIndex={systemLabel ? 0 : undefined}
onClick={systemLabel ? () => setIsExpanded((v) => !v) : undefined}
onKeyDown={
systemLabel
@ -216,7 +216,7 @@ export const ActivityItem = ({
/>
) : null}
{message.source === 'lead_session' ? (
{message.source === 'lead_session' || message.source === 'lead_process' ? (
<Bot className="size-3.5 shrink-0" style={{ color: colors.border }} />
) : (
<MessageSquare className="size-3.5 shrink-0" style={{ color: colors.border }} />
@ -275,6 +275,10 @@ export const ActivityItem = ({
<span className="text-[10px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
session
</span>
) : message.source === 'lead_process' ? (
<span className="text-[10px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
live
</span>
) : null}
{/* Recipient — badge like sender, clickable to open member popup */}
@ -370,7 +374,7 @@ export const ActivityItem = ({
{timestamp}
</span>
</div>
</div>
</HeaderTag>
{/* Content — collapsed for system messages, expanded for others */}
{isExpanded ? (

View file

@ -44,11 +44,8 @@ const MessageRowWithObserver = ({
useEffect(() => {
messageRef.current = message;
}, [message]);
useEffect(() => {
onVisibleRef.current = onVisible;
}, [onVisible]);
}, [message, onVisible]);
useEffect(() => {
if (!onVisible) return;

View file

@ -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<string, number>;
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 (
<div className="mb-3 space-y-1.5">
<p className="text-[10px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
Awaiting replies
</p>
{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 (
<article
key={`pending-reply:${member.name}:${sentAtMs}`}
className="overflow-hidden rounded-md"
style={{
backgroundColor: CARD_BG,
border: CARD_BORDER_STYLE,
borderLeft: `3px solid ${colors.border}`,
}}
>
<div className="flex items-center gap-2 px-3 py-2">
<Loader2
className="size-3.5 shrink-0 animate-spin"
style={{ color: colors.border }}
/>
{onMemberClick ? (
<button
type="button"
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
style={{
backgroundColor: colors.badge,
color: colors.text,
border: `1px solid ${colors.border}40`,
}}
onClick={() => onMemberClick(member)}
title="Open member"
>
{member.name}
</button>
) : (
<span
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
style={{
backgroundColor: colors.badge,
color: colors.text,
border: `1px solid ${colors.border}40`,
}}
>
{member.name}
</span>
)}
{roleLabel ? (
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{roleLabel}
</span>
) : null}
<span
className="min-w-0 flex-1 truncate text-[10px]"
style={{ color: CARD_ICON_MUTED }}
title="Message sent, awaiting reply"
>
awaiting reply
</span>
<span className="shrink-0 text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{since}
</span>
</div>
</article>
);
})}
</div>
);
};

View file

@ -798,7 +798,7 @@ export const CreateTeamDialog = ({
{launchTeam ? (
<div className="space-y-1.5 md:col-span-2">
<Label className="text-xs text-[var(--color-text-muted)]">cwd</Label>
<Label className="text-xs text-[var(--color-text-muted)]">Project</Label>
<div className="space-y-2">
<div className="flex items-center gap-1">
<Button

View file

@ -269,7 +269,7 @@ export const LaunchTeamDialog = ({
<div className="space-y-4">
<div className="space-y-1.5">
<Label className="text-xs text-[var(--color-text-muted)]">cwd</Label>
<Label className="text-xs text-[var(--color-text-muted)]">Project</Label>
<div className="space-y-2">
<div className="flex items-center gap-1">
<Button

View file

@ -1,3 +1,5 @@
import { useMemo } from 'react';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
@ -8,13 +10,18 @@ import {
DialogTitle,
} from '@renderer/components/ui/dialog';
import { Label } from '@renderer/components/ui/label';
import { Textarea } from '@renderer/components/ui/textarea';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { ResolvedTeamMember } from '@shared/types';
interface ReviewDialogProps {
open: boolean;
teamName: string;
taskId: string | null;
members: ResolvedTeamMember[];
onCancel: () => void;
onSubmit: (comment?: string) => void;
}
@ -23,6 +30,7 @@ export const ReviewDialog = ({
open,
teamName,
taskId,
members,
onCancel,
onSubmit,
}: ReviewDialogProps): React.JSX.Element => {
@ -31,6 +39,17 @@ export const ReviewDialog = ({
enabled: Boolean(teamName && taskId),
});
const mentionSuggestions = useMemo<MentionSuggestion[]>(
() =>
members.map((m) => ({
id: m.name,
name: m.name,
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
color: m.color,
})),
[members]
);
const handleCancel = (): void => {
onCancel();
};
@ -58,16 +77,20 @@ export const ReviewDialog = ({
<div className="grid gap-2 py-2">
<Label htmlFor="review-comment">Comment (optional)</Label>
<Textarea
<MentionableTextarea
id="review-comment"
className="min-h-[110px] text-xs"
value={draft.value}
onValueChange={draft.setValue}
placeholder="Describe what needs to change..."
onChange={(event) => draft.setValue(event.target.value)}
suggestions={mentionSuggestions}
hintText="Use @ to mention team members"
footerRight={
draft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : undefined
}
/>
{draft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null}
</div>
<DialogFooter>

View file

@ -132,27 +132,35 @@ export const TaskCommentsSection = ({
</div>
{(() => {
const reply = parseMessageReply(comment.text);
const displayText = reply ? reply.replyText : comment.text;
const needsExpandCollapse = displayText.includes('\n');
const expanded = expandedCommentIds.has(comment.id);
const collapsedHeight = 'max-h-[120px]';
const showCollapsed = needsExpandCollapse && !expanded;
const showExpandedButton = needsExpandCollapse && expanded;
return (
<div className="relative text-xs">
<div
className={
expanded ? undefined : `relative ${collapsedHeight} overflow-hidden`
showCollapsed ? `relative ${collapsedHeight} overflow-hidden` : undefined
}
>
{reply ? (
<ReplyQuoteBlock
reply={reply}
bodyMaxHeight={expanded ? undefined : 'max-h-56'}
bodyMaxHeight={
needsExpandCollapse && !expanded ? 'max-h-56' : 'max-h-none'
}
/>
) : (
<MarkdownViewer
content={comment.text}
maxHeight={expanded ? undefined : collapsedHeight}
maxHeight={
needsExpandCollapse && !expanded ? collapsedHeight : 'max-h-none'
}
/>
)}
{!expanded && (
{showCollapsed && (
<>
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-14"
@ -167,25 +175,25 @@ export const TaskCommentsSection = ({
type="button"
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)] shadow-sm transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
onClick={() => toggleCommentExpanded(comment.id)}
title="Развернуть"
title="Expand"
>
<ChevronDown size={12} />
Развернуть
Expand
</button>
</div>
</>
)}
</div>
{expanded && (
{showExpandedButton && (
<div className="flex justify-center pt-2">
<button
type="button"
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1 text-[11px] text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
onClick={() => toggleCommentExpanded(comment.id)}
title="Свернуть"
title="Collapse"
>
<ChevronUp size={12} />
Свернуть
Collapse
</button>
</div>
)}

View file

@ -2,16 +2,19 @@ import { Badge } from '@renderer/components/ui/badge';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
import { ListPlus, MessageSquare } from 'lucide-react';
import { ListPlus, Loader2, MessageSquare } from 'lucide-react';
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
import type { ResolvedTeamMember } from '@shared/types';
import type { ResolvedTeamMember, TeamTask } from '@shared/types';
interface MemberCardProps {
member: ResolvedTeamMember;
memberColor: string;
taskCounts?: TaskStatusCounts | null;
isTeamAlive?: boolean;
currentTask?: TeamTask | null;
isAwaitingReply?: boolean;
onOpenTask?: () => void;
onClick?: () => void;
onSendMessage?: () => void;
onAssignTask?: () => void;
@ -22,6 +25,9 @@ export const MemberCard = ({
memberColor,
taskCounts,
isTeamAlive,
currentTask,
isAwaitingReply,
onOpenTask,
onClick,
onSendMessage,
onAssignTask,
@ -40,7 +46,7 @@ export const MemberCard = ({
return (
<div className="rounded">
<div
className="group relative flex cursor-pointer items-center gap-2.5 rounded-t px-2 py-1.5"
className="group relative cursor-pointer rounded-t px-2 py-1.5"
style={{
borderLeft: `3px solid ${colors.border}`,
backgroundColor: colors.badge,
@ -57,66 +63,101 @@ export const MemberCard = ({
}}
>
<div className="pointer-events-none absolute inset-0 rounded-t transition-colors group-hover:bg-white/5" />
<div className="relative shrink-0">
<img
src={agentAvatarUrl(member.name)}
alt={member.name}
className="size-7 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"
/>
<span
className={`absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-[var(--color-surface)] ${dotClass}`}
aria-label={member.status}
/>
</div>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-[var(--color-text)]">
{member.name}
</span>
{(() => {
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
return roleLabel ? (
<span className="hidden shrink-0 text-xs text-[var(--color-text-muted)] sm:inline">
{roleLabel}
</span>
) : null;
})()}
<Badge
variant="secondary"
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
title={member.currentTaskId ? `Current task: ${member.currentTaskId}` : undefined}
>
{presenceLabel}
</Badge>
<Badge
variant="secondary"
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none"
>
{member.taskCount} {member.taskCount === 1 ? 'task' : 'tasks'}
</Badge>
<div className="flex shrink-0 items-center gap-0.5">
<button
type="button"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
title="Send Message"
onClick={(e) => {
e.stopPropagation();
onSendMessage?.();
}}
<div className="flex items-center gap-2.5">
<div className="relative shrink-0">
<img
src={agentAvatarUrl(member.name)}
alt={member.name}
className="size-7 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"
/>
<span
className={`absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-[var(--color-surface)] ${dotClass}`}
aria-label={member.status}
/>
</div>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-[var(--color-text)]">
{member.name}
</span>
{(() => {
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
return roleLabel ? (
<span className="hidden shrink-0 text-xs text-[var(--color-text-muted)] sm:inline">
{roleLabel}
</span>
) : null;
})()}
<Badge
variant="secondary"
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
title={member.currentTaskId ? `Current task: ${member.currentTaskId}` : undefined}
>
<MessageSquare size={13} />
</button>
<button
type="button"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
title="Assign Task"
onClick={(e) => {
e.stopPropagation();
onAssignTask?.();
}}
{presenceLabel}
</Badge>
<Badge
variant="secondary"
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none"
>
<ListPlus size={13} />
</button>
{member.taskCount} {member.taskCount === 1 ? 'task' : 'tasks'}
</Badge>
<div className="flex shrink-0 items-center gap-0.5">
<button
type="button"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
title="Send message"
onClick={(e) => {
e.stopPropagation();
onSendMessage?.();
}}
>
<MessageSquare size={13} />
</button>
<button
type="button"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
title="Assign task"
onClick={(e) => {
e.stopPropagation();
onAssignTask?.();
}}
>
<ListPlus size={13} />
</button>
</div>
</div>
{currentTask ? (
<div className="mt-1 flex items-center gap-2 pl-9 text-[10px] text-[var(--color-text-muted)]">
<Loader2 className="size-3 animate-spin" style={{ color: colors.border }} />
<span className="truncate">working on</span>
<button
type="button"
className="truncate rounded px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text)] transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
style={{ border: `1px solid ${colors.border}40` }}
title="Open task"
onClick={(e) => {
e.stopPropagation();
onOpenTask?.();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
e.stopPropagation();
e.preventDefault();
}
}}
>
#{currentTask.id} {currentTask.subject.slice(0, 36)}
{currentTask.subject.length > 36 ? '…' : ''}
</button>
</div>
) : null}
{!currentTask && isAwaitingReply ? (
<div className="mt-1 flex items-center gap-2 pl-9 text-[10px] text-[var(--color-text-muted)]">
<Loader2 className="size-3 animate-spin" style={{ color: colors.border }} />
<span className="truncate">awaiting reply</span>
</div>
) : null}
</div>
<div
className="h-0.5 rounded-b bg-[var(--color-border)]"

View file

@ -3,24 +3,30 @@ import { getMemberColor } from '@shared/constants/memberColors';
import { MemberCard } from './MemberCard';
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
import type { ResolvedTeamMember } from '@shared/types';
import type { ResolvedTeamMember, TeamTask } from '@shared/types';
interface MemberListProps {
members: ResolvedTeamMember[];
memberTaskCounts?: Map<string, TaskStatusCounts>;
taskMap?: Map<string, TeamTask>;
pendingRepliesByMember?: Record<string, number>;
isTeamAlive?: boolean;
onMemberClick?: (member: ResolvedTeamMember) => void;
onSendMessage?: (member: ResolvedTeamMember) => void;
onAssignTask?: (member: ResolvedTeamMember) => void;
onOpenTask?: (task: TeamTask) => void;
}
export const MemberList = ({
members,
memberTaskCounts,
taskMap,
pendingRepliesByMember,
isTeamAlive,
onMemberClick,
onSendMessage,
onAssignTask,
onOpenTask,
}: MemberListProps): React.JSX.Element => {
if (members.length === 0) {
return (
@ -32,18 +38,26 @@ export const MemberList = ({
return (
<div className="flex flex-col gap-0.5">
{members.map((member, index) => (
<MemberCard
key={member.name}
member={member}
memberColor={member.color ?? getMemberColor(index)}
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())}
isTeamAlive={isTeamAlive}
onClick={() => onMemberClick?.(member)}
onSendMessage={() => onSendMessage?.(member)}
onAssignTask={() => onAssignTask?.(member)}
/>
))}
{members.map((member, index) => {
const currentTask =
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
const awaitingReply = Boolean(pendingRepliesByMember?.[member.name]);
return (
<MemberCard
key={member.name}
member={member}
memberColor={member.color ?? getMemberColor(index)}
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())}
isTeamAlive={isTeamAlive}
currentTask={currentTask}
isAwaitingReply={awaitingReply}
onOpenTask={currentTask ? () => onOpenTask?.(currentTask) : undefined}
onClick={() => onMemberClick?.(member)}
onSendMessage={() => onSendMessage?.(member)}
onAssignTask={() => onAssignTask?.(member)}
/>
);
})}
</div>
);
};

View file

@ -10,13 +10,10 @@ export function useTeamMessagesRead(teamName: string): {
markRead: (messageKey: string) => void;
} {
const [version, setVersion] = useState(0);
const readSet = useMemo(
() => {
if (version < 0) return new Set<string>();
return teamName ? getReadSetStorage(teamName) : new Set<string>();
},
[teamName, version]
);
const readSet = useMemo(() => {
if (version < 0) return new Set<string>();
return teamName ? getReadSetStorage(teamName) : new Set<string>();
}, [teamName, version]);
const markRead = useCallback(
(messageKey: string) => {
@ -30,5 +27,6 @@ export function useTeamMessagesRead(teamName: string): {
[teamName]
);
return { readSet, markRead };
const effectiveReadSet = !teamName ? new Set<string>() : readSet;
return { readSet: effectiveReadSet, markRead };
}

View file

@ -359,6 +359,7 @@ export interface TeamsAPI {
processSend: (teamName: string, message: string) => Promise<void>;
processAlive: (teamName: string) => Promise<boolean>;
aliveList: () => Promise<string[]>;
stop: (teamName: string) => Promise<void>;
createConfig: (request: TeamCreateConfigRequest) => Promise<void>;
getMemberLogs: (teamName: string, memberName: string) => Promise<MemberLogSummary[]>;
getLogsForTask: (
@ -461,7 +462,8 @@ export interface ElectronAPI {
// Shell operations
openPath: (
targetPath: string,
projectRoot?: string
projectRoot?: string,
userSelectedFromDialog?: boolean
) => Promise<{ success: boolean; error?: string }>;
openExternal: (url: string) => Promise<{ success: boolean; error?: string }>;

View file

@ -78,7 +78,7 @@ export interface InboxMessage {
summary?: string;
color?: string;
messageId?: string;
source?: 'inbox' | 'lead_session';
source?: 'inbox' | 'lead_session' | 'lead_process';
}
export interface SendMessageRequest {

View file

@ -20,6 +20,7 @@ vi.mock('@preload/constants/ipcChannels', () => ({
TEAM_PROCESS_SEND: 'team:processSend',
TEAM_PROCESS_ALIVE: 'team:processAlive',
TEAM_ALIVE_LIST: 'team:aliveList',
TEAM_STOP: 'team:stop',
TEAM_GET_MEMBER_LOGS: 'team:getMemberLogs',
TEAM_GET_LOGS_FOR_TASK: 'team:getLogsForTask',
TEAM_GET_MEMBER_STATS: 'team:getMemberStats',
@ -31,6 +32,7 @@ vi.mock('@preload/constants/ipcChannels', () => ({
import {
TEAM_ALIVE_LIST,
TEAM_STOP,
TEAM_CANCEL_PROVISIONING,
TEAM_CREATE,
TEAM_CREATE_CONFIG,
@ -107,7 +109,10 @@ describe('ipc teams handlers', () => {
launchTeam: vi.fn(async () => ({ runId: 'run-2' })),
sendMessageToTeam: vi.fn(async () => undefined),
isTeamAlive: vi.fn(() => true),
relayLeadInboxMessages: vi.fn(async () => 0),
getLiveLeadProcessMessages: vi.fn(() => []),
getAliveTeams: vi.fn(() => ['my-team']),
stopTeam: vi.fn(() => undefined),
};
beforeEach(() => {
@ -135,6 +140,7 @@ describe('ipc teams handlers', () => {
expect(handlers.has(TEAM_PROCESS_SEND)).toBe(true);
expect(handlers.has(TEAM_PROCESS_ALIVE)).toBe(true);
expect(handlers.has(TEAM_ALIVE_LIST)).toBe(true);
expect(handlers.has(TEAM_STOP)).toBe(true);
expect(handlers.has(TEAM_CREATE_CONFIG)).toBe(true);
expect(handlers.has(TEAM_GET_MEMBER_LOGS)).toBe(true);
expect(handlers.has(TEAM_GET_LOGS_FOR_TASK)).toBe(true);
@ -203,6 +209,43 @@ describe('ipc teams handlers', () => {
});
});
it('dedups live lead replies when lead_session already has same text', async () => {
service.getTeamData.mockResolvedValueOnce({
teamName: 'my-team',
messages: [
{
from: 'team-lead',
to: 'user',
text: 'Hello there',
timestamp: '2026-02-23T10:00:00.000Z',
read: true,
source: 'lead_session',
},
],
});
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
{
from: 'team-lead',
to: 'user',
text: 'Hello there',
timestamp: '2026-02-23T10:00:01.000Z',
read: true,
source: 'lead_process',
messageId: 'live-1',
},
]);
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
const result = (await getDataHandler({} as never, 'my-team')) as {
success: boolean;
data: { messages: { source?: string }[] };
};
expect(result.success).toBe(true);
const sources = result.data.messages.map((m) => m.source);
expect(sources.filter((s) => s === 'lead_process')).toHaveLength(0);
expect(sources.filter((s) => s === 'lead_session')).toHaveLength(1);
});
describe('createTask prompt validation', () => {
it('accepts valid prompt string', async () => {
const handler = handlers.get(TEAM_CREATE_TASK)!;
@ -304,6 +347,7 @@ describe('ipc teams handlers', () => {
expect(handlers.has(TEAM_PROCESS_SEND)).toBe(false);
expect(handlers.has(TEAM_PROCESS_ALIVE)).toBe(false);
expect(handlers.has(TEAM_ALIVE_LIST)).toBe(false);
expect(handlers.has(TEAM_STOP)).toBe(false);
expect(handlers.has(TEAM_CREATE_CONFIG)).toBe(false);
expect(handlers.has(TEAM_GET_MEMBER_LOGS)).toBe(false);
expect(handlers.has(TEAM_GET_LOGS_FOR_TASK)).toBe(false);

View file

@ -0,0 +1,233 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => {
const files = new Map<string, string>();
let atomicWriteShouldFail = false;
// Normalize path separators so tests pass on Windows (backslash → forward slash)
const norm = (p: string): string => p.replace(/\\/g, '/');
const readFile = vi.fn(async (filePath: string) => {
const data = files.get(norm(filePath));
if (data === undefined) {
const error = new Error('ENOENT') as NodeJS.ErrnoException;
error.code = 'ENOENT';
throw error;
}
return data;
});
const atomicWrite = vi.fn(async (filePath: string, data: string) => {
if (atomicWriteShouldFail) {
throw new Error('atomic write failed');
}
files.set(norm(filePath), data);
});
return {
files,
readFile,
atomicWrite,
setAtomicWriteShouldFail: (next: boolean) => {
atomicWriteShouldFail = next;
},
};
});
vi.mock('fs', () => ({
promises: {
readFile: hoisted.readFile,
},
}));
vi.mock('../../../../src/main/services/team/atomicWrite', () => ({
atomicWriteAsync: hoisted.atomicWrite,
}));
vi.mock('../../../../src/main/utils/pathDecoder', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../../src/main/utils/pathDecoder')>();
return {
...actual,
getTeamsBasePath: () => '/mock/teams',
};
});
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
function seedConfig(teamName: string): void {
hoisted.files.set(
`/mock/teams/${teamName}/config.json`,
JSON.stringify({
name: 'My Team',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
})
);
}
function seedLeadInbox(teamName: string, messages: unknown[]): void {
hoisted.files.set(`/mock/teams/${teamName}/inboxes/team-lead.json`, JSON.stringify(messages));
}
function attachAliveRun(
service: TeamProvisioningService,
teamName: string,
opts?: { writable?: boolean }
): { writeSpy: ReturnType<typeof vi.fn> } {
const runId = 'run-1';
const writeSpy = vi.fn();
const writable = opts?.writable ?? true;
(service as unknown as { activeByTeam: Map<string, string> }).activeByTeam.set(teamName, runId);
(service as unknown as { runs: Map<string, unknown> }).runs.set(runId, {
runId,
teamName,
child: {
stdin: {
writable,
write: writeSpy,
},
},
processKilled: false,
cancelRequested: false,
provisioningComplete: true,
leadRelayCapture: null,
});
return { writeSpy };
}
async function waitForCapture(service: TeamProvisioningService): Promise<any> {
const runs = (service as unknown as { runs: Map<string, unknown> }).runs;
const run = runs.get('run-1') as any;
for (let i = 0; i < 50; i++) {
if (run?.leadRelayCapture) return run;
// Progress async awaits in relayLeadInboxMessages
await Promise.resolve();
}
for (let i = 0; i < 50; i++) {
if (run?.leadRelayCapture) return run;
await new Promise((r) => setTimeout(r, 0));
}
return run;
}
describe('TeamProvisioningService relayLeadInboxMessages', () => {
beforeEach(() => {
hoisted.files.clear();
hoisted.readFile.mockClear();
hoisted.atomicWrite.mockClear();
hoisted.setAtomicWriteShouldFail(false);
});
it('relays unread lead inbox messages into stdin', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
seedConfig(teamName);
seedLeadInbox(teamName, [
{
from: 'bob',
text: 'Please assign this to Alice.',
timestamp: '2026-02-23T10:00:00.000Z',
read: false,
summary: 'Need delegation',
messageId: 'm-1',
},
]);
const { writeSpy } = attachAliveRun(service, teamName);
const relayPromise = service.relayLeadInboxMessages(teamName);
const run = await waitForCapture(service);
expect(run?.leadRelayCapture).toBeTruthy();
(service as any).handleStreamJsonMessage(run, {
type: 'assistant',
content: [{ type: 'text', text: 'OK, will do.' }],
});
(service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' });
const relayed = await relayPromise;
expect(relayed).toBe(1);
expect(writeSpy).toHaveBeenCalledTimes(1);
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
expect(payload).toContain('"type":"user"');
expect(payload).toContain('Please assign this to Alice.');
expect(service.getLiveLeadProcessMessages(teamName)).toHaveLength(1);
});
it('dedups by messageId even if markRead fails', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
seedConfig(teamName);
seedLeadInbox(teamName, [
{
from: 'bob',
text: 'Ping leader',
timestamp: '2026-02-23T10:00:00.000Z',
read: false,
summary: 'Ping',
messageId: 'm-1',
},
]);
hoisted.setAtomicWriteShouldFail(true);
const { writeSpy } = attachAliveRun(service, teamName);
const firstPromise = service.relayLeadInboxMessages(teamName);
const run = await waitForCapture(service);
expect(run?.leadRelayCapture).toBeTruthy();
(service as any).handleStreamJsonMessage(run, {
type: 'assistant',
content: [{ type: 'text', text: 'Acknowledged.' }],
});
(service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' });
const first = await firstPromise;
const second = await service.relayLeadInboxMessages(teamName);
expect(first).toBe(1);
expect(second).toBe(0);
expect(writeSpy).toHaveBeenCalledTimes(1);
});
it('does not mark as relayed when stdin is not writable', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
seedConfig(teamName);
seedLeadInbox(teamName, [
{
from: 'bob',
text: 'Hello',
timestamp: '2026-02-23T10:00:00.000Z',
read: false,
messageId: 'm-1',
},
]);
const { writeSpy } = attachAliveRun(service, teamName, { writable: false });
const first = await service.relayLeadInboxMessages(teamName);
expect(first).toBe(0);
expect(writeSpy).toHaveBeenCalledTimes(0);
(service as unknown as { runs: Map<string, unknown> }).runs.set('run-1', {
runId: 'run-1',
teamName,
child: { stdin: { writable: true, write: writeSpy } },
processKilled: false,
cancelRequested: false,
provisioningComplete: true,
leadRelayCapture: null,
});
const secondPromise = service.relayLeadInboxMessages(teamName);
const run = await waitForCapture(service);
expect(run?.leadRelayCapture).toBeTruthy();
(service as any).handleStreamJsonMessage(run, {
type: 'assistant',
content: [{ type: 'text', text: 'Hi.' }],
});
(service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' });
const second = await secondPromise;
expect(second).toBe(1);
expect(writeSpy).toHaveBeenCalledTimes(1);
});
});

View file

@ -13,6 +13,7 @@ import {
isPathWithinAllowedDirectories,
validateFilePath,
validateOpenPath,
validateOpenPathUserSelected,
} from '../../../src/main/utils/pathValidation';
describe('pathValidation', () => {
@ -299,4 +300,25 @@ describe('pathValidation', () => {
fs.rmSync(tempRoot, { recursive: true, force: true });
});
});
describe('validateOpenPathUserSelected', () => {
it('should allow path outside project when chosen by user', () => {
const outsidePath = path.join(homeDir, 'some-other-project');
const result = validateOpenPathUserSelected(outsidePath);
expect(result.valid).toBe(true);
expect(result.normalizedPath).toBe(path.resolve(outsidePath));
});
it('should reject sensitive paths', () => {
const result = validateOpenPathUserSelected(path.join(homeDir, '.ssh', 'id_rsa'));
expect(result.valid).toBe(false);
expect(result.error).toBe('Cannot open sensitive files');
});
it('should reject empty path', () => {
const result = validateOpenPathUserSelected('');
expect(result.valid).toBe(false);
expect(result.error).toBe('Invalid path');
});
});
});