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