commit
e97fa7635f
35 changed files with 1698 additions and 363 deletions
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
|
|
@ -0,0 +1 @@
|
|||
pnpm exec lint-staged
|
||||
|
|
@ -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
20
bin/kill-dev.js
Normal 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');
|
||||
387
package.json
387
package.json
|
|
@ -1,195 +1,208 @@
|
|||
{
|
||||
"name": "claude-agent-teams-ui",
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"description": "Desktop app that visualizes Claude Code session execution — explore conversations, track context usage, and analyze tool calls",
|
||||
"license": "AGPL-3.0",
|
||||
"author": {
|
||||
"name": "Илия (777genius)",
|
||||
"email": "quantjumppro@gmail.com"
|
||||
"name": "claude-agent-teams-ui",
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"description": "Desktop app that visualizes Claude Code session execution — explore conversations, track context usage, and analyze tool calls",
|
||||
"license": "AGPL-3.0",
|
||||
"author": {
|
||||
"name": "Илия (777genius)",
|
||||
"email": "quantjumppro@gmail.com"
|
||||
},
|
||||
"homepage": "https://github.com/777genius/claude_agent_teams_ui",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/777genius/claude_agent_teams_ui.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/777genius/claude_agent_teams_ui/issues"
|
||||
},
|
||||
"main": "dist-electron/main/index.cjs",
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"dev:kill": "node bin/kill-dev.js",
|
||||
"build": "electron-vite build",
|
||||
"dist": "electron-builder --mac --win --linux",
|
||||
"dist:mac": "electron-builder --mac --publish always",
|
||||
"dist:mac:arm64": "electron-builder --mac --arm64 --publish always",
|
||||
"dist:mac:x64": "electron-builder --mac --x64 --publish always",
|
||||
"dist:win": "electron-builder --win --publish always",
|
||||
"dist:linux": "electron-builder --linux --publish always",
|
||||
"preview": "electron-vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src/",
|
||||
"lint:fix": "eslint src/ --fix",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css}\"",
|
||||
"check": "pnpm typecheck && pnpm lint && pnpm test && pnpm build",
|
||||
"fix": "pnpm lint:fix && pnpm format",
|
||||
"quality": "pnpm check && pnpm format:check && npx knip",
|
||||
"test:chunks": "tsx test/test-chunk-building.ts",
|
||||
"test:semantic": "tsx test/test-semantic-steps.ts",
|
||||
"test:noise": "tsx test/test-noise-filtering.ts",
|
||||
"test:task-filtering": "tsx test/test-task-filtering.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:coverage:critical": "vitest run --coverage --config vitest.critical.config.ts",
|
||||
"standalone": "tsx src/main/standalone.ts",
|
||||
"standalone:build": "electron-vite build && vite build --config vite.standalone.config.ts",
|
||||
"standalone:start": "node dist-standalone/index.cjs",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"lint-staged": {
|
||||
"src/**/*.{ts,tsx,js,jsx}": [
|
||||
"eslint --fix"
|
||||
],
|
||||
"src/**/*.{ts,tsx,js,jsx,json,css}": [
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-virtual": "^3.10.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.0.4",
|
||||
"date-fns": "^3.6.0",
|
||||
"electron-updater": "^6.7.3",
|
||||
"fastify": "^5.7.4",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"lucide-react": "^0.562.0",
|
||||
"mdast-util-to-hast": "^13.2.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-parse": "^11.0.0",
|
||||
"ssh-config": "^5.0.4",
|
||||
"ssh2": "^1.17.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"unified": "^11.0.5",
|
||||
"zustand": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint-community/eslint-plugin-eslint-comments": "^4.6.0",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"@types/node": "^25.0.7",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"@vitest/coverage-v8": "^3.1.4",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"electron": "^40.3.0",
|
||||
"electron-builder": "^25.1.8",
|
||||
"electron-vite": "^2.3.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-boundaries": "^5.3.1",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"eslint-plugin-security": "^3.0.1",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"eslint-plugin-sonarjs": "^3.0.6",
|
||||
"eslint-plugin-tailwindcss": "^3.18.2",
|
||||
"globals": "^17.2.0",
|
||||
"happy-dom": "^20.0.2",
|
||||
"husky": "^9.1.7",
|
||||
"knip": "^5.82.1",
|
||||
"lint-staged": "^16.2.7",
|
||||
"postcss": "^8.4.35",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.54.0",
|
||||
"vite": "^5.4.2",
|
||||
"vitest": "^3.1.4"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.claudecode.context",
|
||||
"productName": "Claude Agent Teams UI",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"homepage": "https://github.com/777genius/claude_agent_teams_ui",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/777genius/claude_agent_teams_ui.git"
|
||||
"files": [
|
||||
"out/renderer/**",
|
||||
"dist-electron/**",
|
||||
"package.json"
|
||||
],
|
||||
"asar": true,
|
||||
"asarUnpack": [
|
||||
"out/renderer/**"
|
||||
],
|
||||
"npmRebuild": false,
|
||||
"extraMetadata": {
|
||||
"main": "dist-electron/main/index.cjs"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/777genius/claude_agent_teams_ui/issues"
|
||||
"mac": {
|
||||
"category": "public.app-category.developer-tools",
|
||||
"target": [
|
||||
"dmg",
|
||||
"zip"
|
||||
],
|
||||
"hardenedRuntime": true,
|
||||
"gatekeeperAssess": false,
|
||||
"notarize": true,
|
||||
"entitlements": "resources/entitlements.mac.plist",
|
||||
"entitlementsInherit": "resources/entitlements.mac.inherit.plist",
|
||||
"icon": "resources/icons/mac/icon.icns"
|
||||
},
|
||||
"main": "dist-electron/main/index.cjs",
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"dev:kill": "pkill -f 'electron-vite|electron \\.' 2>/dev/null; echo 'Done'",
|
||||
"build": "electron-vite build",
|
||||
"dist": "electron-builder --mac --win --linux",
|
||||
"dist:mac": "electron-builder --mac --publish always",
|
||||
"dist:mac:arm64": "electron-builder --mac --arm64 --publish always",
|
||||
"dist:mac:x64": "electron-builder --mac --x64 --publish always",
|
||||
"dist:win": "electron-builder --win --publish always",
|
||||
"dist:linux": "electron-builder --linux --publish always",
|
||||
"preview": "electron-vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src/",
|
||||
"lint:fix": "eslint src/ --fix",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css}\"",
|
||||
"check": "pnpm typecheck && pnpm lint && pnpm test && pnpm build",
|
||||
"fix": "pnpm lint:fix && pnpm format",
|
||||
"quality": "pnpm check && pnpm format:check && npx knip",
|
||||
"test:chunks": "tsx test/test-chunk-building.ts",
|
||||
"test:semantic": "tsx test/test-semantic-steps.ts",
|
||||
"test:noise": "tsx test/test-noise-filtering.ts",
|
||||
"test:task-filtering": "tsx test/test-task-filtering.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:coverage:critical": "vitest run --coverage --config vitest.critical.config.ts",
|
||||
"standalone": "tsx src/main/standalone.ts",
|
||||
"standalone:build": "electron-vite build && vite build --config vite.standalone.config.ts",
|
||||
"standalone:start": "node dist-standalone/index.cjs"
|
||||
"dmg": {
|
||||
"sign": false
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-virtual": "^3.10.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.0.4",
|
||||
"date-fns": "^3.6.0",
|
||||
"electron-updater": "^6.7.3",
|
||||
"fastify": "^5.7.4",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"lucide-react": "^0.562.0",
|
||||
"mdast-util-to-hast": "^13.2.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-parse": "^11.0.0",
|
||||
"ssh-config": "^5.0.4",
|
||||
"ssh2": "^1.17.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"unified": "^11.0.5",
|
||||
"zustand": "^4.5.0"
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis"
|
||||
],
|
||||
"icon": "resources/icons/win/icon.ico"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint-community/eslint-plugin-eslint-comments": "^4.6.0",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"@types/node": "^25.0.7",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"@vitest/coverage-v8": "^3.1.4",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"electron": "^40.3.0",
|
||||
"electron-builder": "^25.1.8",
|
||||
"electron-vite": "^2.3.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-boundaries": "^5.3.1",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"eslint-plugin-security": "^3.0.1",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"eslint-plugin-sonarjs": "^3.0.6",
|
||||
"eslint-plugin-tailwindcss": "^3.18.2",
|
||||
"globals": "^17.2.0",
|
||||
"happy-dom": "^20.0.2",
|
||||
"knip": "^5.82.1",
|
||||
"postcss": "^8.4.35",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.54.0",
|
||||
"vite": "^5.4.2",
|
||||
"vitest": "^3.1.4"
|
||||
"linux": {
|
||||
"target": [
|
||||
"AppImage",
|
||||
"deb",
|
||||
"rpm",
|
||||
"pacman"
|
||||
],
|
||||
"icon": "resources/icons/png",
|
||||
"category": "Development"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.claudecode.context",
|
||||
"productName": "Claude Agent Teams UI",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"files": [
|
||||
"out/renderer/**",
|
||||
"dist-electron/**",
|
||||
"package.json"
|
||||
],
|
||||
"asar": true,
|
||||
"asarUnpack": [
|
||||
"out/renderer/**"
|
||||
],
|
||||
"npmRebuild": false,
|
||||
"extraMetadata": {
|
||||
"main": "dist-electron/main/index.cjs"
|
||||
},
|
||||
"mac": {
|
||||
"category": "public.app-category.developer-tools",
|
||||
"target": [
|
||||
"dmg",
|
||||
"zip"
|
||||
],
|
||||
"hardenedRuntime": true,
|
||||
"gatekeeperAssess": false,
|
||||
"notarize": true,
|
||||
"entitlements": "resources/entitlements.mac.plist",
|
||||
"entitlementsInherit": "resources/entitlements.mac.inherit.plist",
|
||||
"icon": "resources/icons/mac/icon.icns"
|
||||
},
|
||||
"dmg": {
|
||||
"sign": false
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis"
|
||||
],
|
||||
"icon": "resources/icons/win/icon.ico"
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
"AppImage",
|
||||
"deb",
|
||||
"rpm",
|
||||
"pacman"
|
||||
],
|
||||
"icon": "resources/icons/png",
|
||||
"category": "Development"
|
||||
},
|
||||
"deb": {
|
||||
"afterInstall": "resources/afterInstall.sh"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true
|
||||
},
|
||||
"publish": [{
|
||||
"provider": "github",
|
||||
"releaseType": "draft"
|
||||
}]
|
||||
"deb": {
|
||||
"afterInstall": "resources/afterInstall.sh"
|
||||
},
|
||||
"packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501"
|
||||
}
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true
|
||||
},
|
||||
"publish": [
|
||||
{
|
||||
"provider": "github",
|
||||
"releaseType": "draft"
|
||||
}
|
||||
]
|
||||
},
|
||||
"packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501"
|
||||
}
|
||||
|
|
|
|||
202
pnpm-lock.yaml
202
pnpm-lock.yaml
|
|
@ -204,9 +204,15 @@ importers:
|
|||
happy-dom:
|
||||
specifier: ^20.0.2
|
||||
version: 20.0.2
|
||||
husky:
|
||||
specifier: ^9.1.7
|
||||
version: 9.1.7
|
||||
knip:
|
||||
specifier: ^5.82.1
|
||||
version: 5.82.1(@types/node@25.0.7)(typescript@5.9.3)
|
||||
lint-staged:
|
||||
specifier: ^16.2.7
|
||||
version: 16.2.7
|
||||
postcss:
|
||||
specifier: ^8.4.35
|
||||
version: 8.5.6
|
||||
|
|
@ -1920,6 +1926,10 @@ packages:
|
|||
ajv@8.18.0:
|
||||
resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==}
|
||||
|
||||
ansi-escapes@7.3.0:
|
||||
resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
ansi-regex@5.0.1:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
|
@ -2285,6 +2295,10 @@ packages:
|
|||
resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
cli-cursor@5.0.0:
|
||||
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
cli-spinners@2.9.2:
|
||||
resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==}
|
||||
engines: {node: '>=6'}
|
||||
|
|
@ -2293,6 +2307,10 @@ packages:
|
|||
resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
cli-truncate@5.1.1:
|
||||
resolution: {integrity: sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
cliui@8.0.1:
|
||||
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
|
@ -2325,6 +2343,9 @@ packages:
|
|||
resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==}
|
||||
hasBin: true
|
||||
|
||||
colorette@2.0.20:
|
||||
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
|
@ -2332,6 +2353,10 @@ packages:
|
|||
comma-separated-tokens@2.0.3:
|
||||
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
|
||||
|
||||
commander@14.0.3:
|
||||
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
commander@2.20.3:
|
||||
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
|
||||
|
||||
|
|
@ -2588,6 +2613,9 @@ packages:
|
|||
engines: {node: '>= 12.20.55'}
|
||||
hasBin: true
|
||||
|
||||
emoji-regex@10.6.0:
|
||||
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
|
||||
|
||||
emoji-regex@8.0.0:
|
||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||
|
||||
|
|
@ -2604,6 +2632,10 @@ packages:
|
|||
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
environment@1.1.0:
|
||||
resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
err-code@2.0.3:
|
||||
resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==}
|
||||
|
||||
|
|
@ -2829,6 +2861,9 @@ packages:
|
|||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
eventemitter3@5.0.4:
|
||||
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
||||
|
||||
expect-type@1.3.0:
|
||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
|
@ -3004,6 +3039,10 @@ packages:
|
|||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||
engines: {node: 6.* || 8.* || >= 10.*}
|
||||
|
||||
get-east-asian-width@1.5.0:
|
||||
resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -3171,6 +3210,11 @@ packages:
|
|||
humanize-ms@1.2.1:
|
||||
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
|
||||
|
||||
husky@9.1.7:
|
||||
resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
iconv-corefoundation@1.1.7:
|
||||
resolution: {integrity: sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==}
|
||||
engines: {node: ^8.11.2 || >=10}
|
||||
|
|
@ -3295,6 +3339,10 @@ packages:
|
|||
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-fullwidth-code-point@5.1.0:
|
||||
resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
is-generator-function@1.1.2:
|
||||
resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -3521,6 +3569,15 @@ packages:
|
|||
lines-and-columns@1.2.4:
|
||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||
|
||||
lint-staged@16.2.7:
|
||||
resolution: {integrity: sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==}
|
||||
engines: {node: '>=20.17'}
|
||||
hasBin: true
|
||||
|
||||
listr2@9.0.5:
|
||||
resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
locate-path@6.0.0:
|
||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -3557,6 +3614,10 @@ packages:
|
|||
resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
log-update@6.1.0:
|
||||
resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
longest-streak@3.1.0:
|
||||
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
||||
|
||||
|
|
@ -3778,6 +3839,10 @@ packages:
|
|||
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
mimic-function@5.0.1:
|
||||
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
mimic-response@1.0.1:
|
||||
resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==}
|
||||
engines: {node: '>=4'}
|
||||
|
|
@ -3858,6 +3923,10 @@ packages:
|
|||
nan@2.25.0:
|
||||
resolution: {integrity: sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==}
|
||||
|
||||
nano-spawn@2.0.0:
|
||||
resolution: {integrity: sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==}
|
||||
engines: {node: '>=20.17'}
|
||||
|
||||
nanoid@3.3.11:
|
||||
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
|
|
@ -3961,6 +4030,10 @@ packages:
|
|||
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
onetime@7.0.0:
|
||||
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
|
@ -4050,6 +4123,11 @@ packages:
|
|||
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
pidtree@0.6.0:
|
||||
resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==}
|
||||
engines: {node: '>=0.10'}
|
||||
hasBin: true
|
||||
|
||||
pify@2.3.0:
|
||||
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -4390,6 +4468,10 @@ packages:
|
|||
resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
restore-cursor@5.1.0:
|
||||
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
ret@0.5.0:
|
||||
resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -4554,6 +4636,10 @@ packages:
|
|||
resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
slice-ansi@7.1.2:
|
||||
resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
smart-buffer@4.2.0:
|
||||
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
|
||||
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
|
||||
|
|
@ -4627,6 +4713,10 @@ packages:
|
|||
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
string-argv@0.3.2:
|
||||
resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==}
|
||||
engines: {node: '>=0.6.19'}
|
||||
|
||||
string-width@4.2.3:
|
||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||
engines: {node: '>=8'}
|
||||
|
|
@ -4635,6 +4725,14 @@ packages:
|
|||
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
string-width@7.2.0:
|
||||
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
string-width@8.2.0:
|
||||
resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
string.prototype.includes@2.0.1:
|
||||
resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -5094,6 +5192,10 @@ packages:
|
|||
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
wrap-ansi@9.0.2:
|
||||
resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
|
|
@ -6785,6 +6887,10 @@ snapshots:
|
|||
json-schema-traverse: 1.0.0
|
||||
require-from-string: 2.0.2
|
||||
|
||||
ansi-escapes@7.3.0:
|
||||
dependencies:
|
||||
environment: 1.1.0
|
||||
|
||||
ansi-regex@5.0.1: {}
|
||||
|
||||
ansi-regex@6.2.2: {}
|
||||
|
|
@ -7304,6 +7410,10 @@ snapshots:
|
|||
dependencies:
|
||||
restore-cursor: 3.1.0
|
||||
|
||||
cli-cursor@5.0.0:
|
||||
dependencies:
|
||||
restore-cursor: 5.1.0
|
||||
|
||||
cli-spinners@2.9.2: {}
|
||||
|
||||
cli-truncate@2.1.0:
|
||||
|
|
@ -7312,6 +7422,11 @@ snapshots:
|
|||
string-width: 4.2.3
|
||||
optional: true
|
||||
|
||||
cli-truncate@5.1.1:
|
||||
dependencies:
|
||||
slice-ansi: 7.1.2
|
||||
string-width: 8.2.0
|
||||
|
||||
cliui@8.0.1:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
|
|
@ -7346,12 +7461,16 @@ snapshots:
|
|||
|
||||
color-support@1.1.3: {}
|
||||
|
||||
colorette@2.0.20: {}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
|
||||
comma-separated-tokens@2.0.3: {}
|
||||
|
||||
commander@14.0.3: {}
|
||||
|
||||
commander@2.20.3:
|
||||
optional: true
|
||||
|
||||
|
|
@ -7653,6 +7772,8 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
emoji-regex@10.6.0: {}
|
||||
|
||||
emoji-regex@8.0.0: {}
|
||||
|
||||
emoji-regex@9.2.2: {}
|
||||
|
|
@ -7668,6 +7789,8 @@ snapshots:
|
|||
|
||||
env-paths@2.2.1: {}
|
||||
|
||||
environment@1.1.0: {}
|
||||
|
||||
err-code@2.0.3: {}
|
||||
|
||||
es-abstract@1.24.1:
|
||||
|
|
@ -8085,6 +8208,8 @@ snapshots:
|
|||
|
||||
esutils@2.0.3: {}
|
||||
|
||||
eventemitter3@5.0.4: {}
|
||||
|
||||
expect-type@1.3.0: {}
|
||||
|
||||
exponential-backoff@3.1.3: {}
|
||||
|
|
@ -8292,6 +8417,8 @@ snapshots:
|
|||
|
||||
get-caller-file@2.0.5: {}
|
||||
|
||||
get-east-asian-width@1.5.0: {}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
|
|
@ -8528,6 +8655,8 @@ snapshots:
|
|||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
husky@9.1.7: {}
|
||||
|
||||
iconv-corefoundation@1.1.7:
|
||||
dependencies:
|
||||
cli-truncate: 2.1.0
|
||||
|
|
@ -8645,6 +8774,10 @@ snapshots:
|
|||
|
||||
is-fullwidth-code-point@3.0.0: {}
|
||||
|
||||
is-fullwidth-code-point@5.1.0:
|
||||
dependencies:
|
||||
get-east-asian-width: 1.5.0
|
||||
|
||||
is-generator-function@1.1.2:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
|
|
@ -8871,6 +9004,25 @@ snapshots:
|
|||
|
||||
lines-and-columns@1.2.4: {}
|
||||
|
||||
lint-staged@16.2.7:
|
||||
dependencies:
|
||||
commander: 14.0.3
|
||||
listr2: 9.0.5
|
||||
micromatch: 4.0.8
|
||||
nano-spawn: 2.0.0
|
||||
pidtree: 0.6.0
|
||||
string-argv: 0.3.2
|
||||
yaml: 2.8.2
|
||||
|
||||
listr2@9.0.5:
|
||||
dependencies:
|
||||
cli-truncate: 5.1.1
|
||||
colorette: 2.0.20
|
||||
eventemitter3: 5.0.4
|
||||
log-update: 6.1.0
|
||||
rfdc: 1.4.1
|
||||
wrap-ansi: 9.0.2
|
||||
|
||||
locate-path@6.0.0:
|
||||
dependencies:
|
||||
p-locate: 5.0.0
|
||||
|
|
@ -8898,6 +9050,14 @@ snapshots:
|
|||
chalk: 4.1.2
|
||||
is-unicode-supported: 0.1.0
|
||||
|
||||
log-update@6.1.0:
|
||||
dependencies:
|
||||
ansi-escapes: 7.3.0
|
||||
cli-cursor: 5.0.0
|
||||
slice-ansi: 7.1.2
|
||||
strip-ansi: 7.1.2
|
||||
wrap-ansi: 9.0.2
|
||||
|
||||
longest-streak@3.1.0: {}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
|
|
@ -9334,6 +9494,8 @@ snapshots:
|
|||
|
||||
mimic-fn@2.1.0: {}
|
||||
|
||||
mimic-function@5.0.1: {}
|
||||
|
||||
mimic-response@1.0.1: {}
|
||||
|
||||
mimic-response@3.1.0: {}
|
||||
|
|
@ -9410,6 +9572,8 @@ snapshots:
|
|||
nan@2.25.0:
|
||||
optional: true
|
||||
|
||||
nano-spawn@2.0.0: {}
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
napi-postinstall@0.3.4: {}
|
||||
|
|
@ -9519,6 +9683,10 @@ snapshots:
|
|||
dependencies:
|
||||
mimic-fn: 2.1.0
|
||||
|
||||
onetime@7.0.0:
|
||||
dependencies:
|
||||
mimic-function: 5.0.1
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
deep-is: 0.1.4
|
||||
|
|
@ -9631,6 +9799,8 @@ snapshots:
|
|||
|
||||
picomatch@4.0.3: {}
|
||||
|
||||
pidtree@0.6.0: {}
|
||||
|
||||
pify@2.3.0: {}
|
||||
|
||||
pino-abstract-transport@3.0.0:
|
||||
|
|
@ -9955,6 +10125,11 @@ snapshots:
|
|||
onetime: 5.1.2
|
||||
signal-exit: 3.0.7
|
||||
|
||||
restore-cursor@5.1.0:
|
||||
dependencies:
|
||||
onetime: 7.0.0
|
||||
signal-exit: 4.1.0
|
||||
|
||||
ret@0.5.0: {}
|
||||
|
||||
retry@0.12.0: {}
|
||||
|
|
@ -10158,6 +10333,11 @@ snapshots:
|
|||
is-fullwidth-code-point: 3.0.0
|
||||
optional: true
|
||||
|
||||
slice-ansi@7.1.2:
|
||||
dependencies:
|
||||
ansi-styles: 6.2.3
|
||||
is-fullwidth-code-point: 5.1.0
|
||||
|
||||
smart-buffer@4.2.0: {}
|
||||
|
||||
smol-toml@1.6.0: {}
|
||||
|
|
@ -10224,6 +10404,8 @@ snapshots:
|
|||
es-errors: 1.3.0
|
||||
internal-slot: 1.1.0
|
||||
|
||||
string-argv@0.3.2: {}
|
||||
|
||||
string-width@4.2.3:
|
||||
dependencies:
|
||||
emoji-regex: 8.0.0
|
||||
|
|
@ -10236,6 +10418,17 @@ snapshots:
|
|||
emoji-regex: 9.2.2
|
||||
strip-ansi: 7.1.2
|
||||
|
||||
string-width@7.2.0:
|
||||
dependencies:
|
||||
emoji-regex: 10.6.0
|
||||
get-east-asian-width: 1.5.0
|
||||
strip-ansi: 7.1.2
|
||||
|
||||
string-width@8.2.0:
|
||||
dependencies:
|
||||
get-east-asian-width: 1.5.0
|
||||
strip-ansi: 7.1.2
|
||||
|
||||
string.prototype.includes@2.0.1:
|
||||
dependencies:
|
||||
call-bind: 1.0.8
|
||||
|
|
@ -10826,6 +11019,12 @@ snapshots:
|
|||
string-width: 5.1.2
|
||||
strip-ansi: 7.1.2
|
||||
|
||||
wrap-ansi@9.0.2:
|
||||
dependencies:
|
||||
ansi-styles: 6.2.3
|
||||
string-width: 7.2.0
|
||||
strip-ansi: 7.1.2
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
xmlbuilder@15.1.1: {}
|
||||
|
|
@ -10836,8 +11035,7 @@ snapshots:
|
|||
|
||||
yallist@4.0.0: {}
|
||||
|
||||
yaml@2.8.2:
|
||||
optional: true
|
||||
yaml@2.8.2: {}
|
||||
|
||||
yargs-parser@21.1.1: {}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
19
src/main/services/team/inboxLock.ts
Normal file
19
src/main/services/team/inboxLock.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -44,11 +44,8 @@ const MessageRowWithObserver = ({
|
|||
|
||||
useEffect(() => {
|
||||
messageRef.current = message;
|
||||
}, [message]);
|
||||
|
||||
useEffect(() => {
|
||||
onVisibleRef.current = onVisible;
|
||||
}, [onVisible]);
|
||||
}, [message, onVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onVisible) return;
|
||||
|
|
|
|||
105
src/renderer/components/team/activity/PendingRepliesBlock.tsx
Normal file
105
src/renderer/components/team/activity/PendingRepliesBlock.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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)]"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
233
test/main/services/team/TeamProvisioningServiceRelay.test.ts
Normal file
233
test/main/services/team/TeamProvisioningServiceRelay.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue