feat: integrate terminal service and enhance CLI authentication handling

- Added PtyTerminalService to manage PTY terminal processes, enabling terminal functionalities within the application.
- Updated CLI installer to include authentication status checks, providing feedback on user login state.
- Enhanced the dashboard with a warning banner for CLI installation status, prompting users to log in if not authenticated.
- Introduced IPC channels for terminal operations, allowing communication between main and renderer processes for terminal management.
- Improved TaskCommentsSection and other components to support new features and enhance user experience during task management.
This commit is contained in:
iliya 2026-02-26 20:17:32 +02:00
parent d177d22dba
commit 99a8bff8d2
29 changed files with 1448 additions and 213 deletions

View file

@ -10,6 +10,10 @@ import type { Plugin } from 'vite'
const pkg = JSON.parse(readFileSync(resolve(__dirname, 'package.json'), 'utf-8'))
const prodDeps = Object.keys(pkg.dependencies || {})
// node-pty is a native addon that cannot be bundled by Rollup.
// It must remain external and be loaded at runtime via require().
const bundledDeps = prodDeps.filter(d => d !== 'node-pty')
// Rollup plugin: stub out native .node addon imports with empty modules.
// ssh2 and cpu-features use optional native bindings that can't be bundled,
// but they have pure JS fallbacks when the native module isn't available.
@ -32,7 +36,7 @@ export default defineConfig({
main: {
plugins: [
externalizeDepsPlugin({
exclude: prodDeps
exclude: bundledDeps
}),
nativeModuleStub()
],

View file

@ -48,7 +48,8 @@
"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"
"prepare": "husky",
"postinstall": "electron-rebuild -f -o node-pty"
},
"lint-staged": {
"src/**/*.{ts,tsx,js,jsx}": [
@ -99,6 +100,8 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-virtual": "^3.10.8",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.0.4",
@ -111,6 +114,7 @@
"lucide-react": "^0.562.0",
"mdast-util-to-hast": "^13.2.1",
"node-diff3": "^3.2.0",
"node-pty": "^1.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
@ -125,6 +129,7 @@
"zustand": "^4.5.0"
},
"devDependencies": {
"@electron/rebuild": "^4.0.3",
"@eslint-community/eslint-plugin-eslint-comments": "^4.6.0",
"@eslint/js": "^9.39.2",
"@tailwindcss/typography": "^0.5.19",
@ -181,7 +186,8 @@
],
"asar": true,
"asarUnpack": [
"out/renderer/**"
"out/renderer/**",
"**/node_modules/node-pty/build/Release/**"
],
"extraResources": [
{
@ -240,5 +246,10 @@
}
]
},
"packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501"
"packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501",
"pnpm": {
"onlyBuiltDependencies": [
"node-pty"
]
}
}

View file

@ -125,6 +125,12 @@ importers:
'@tanstack/react-virtual':
specifier: ^3.10.8
version: 3.13.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@xterm/addon-fit':
specifier: ^0.11.0
version: 0.11.0
'@xterm/xterm':
specifier: ^6.0.0
version: 6.0.0
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@ -161,6 +167,9 @@ importers:
node-diff3:
specifier: ^3.2.0
version: 3.2.0
node-pty:
specifier: ^1.1.0
version: 1.1.0
react:
specifier: ^18.3.1
version: 18.3.1
@ -198,6 +207,9 @@ importers:
specifier: ^4.5.0
version: 4.5.7(@types/react@18.3.27)(react@18.3.1)
devDependencies:
'@electron/rebuild':
specifier: ^4.0.3
version: 4.0.3
'@eslint-community/eslint-plugin-eslint-comments':
specifier: ^4.6.0
version: 4.6.0(eslint@9.39.2(jiti@1.21.7))
@ -586,6 +598,11 @@ packages:
engines: {node: '>=12.13.0'}
hasBin: true
'@electron/rebuild@4.0.3':
resolution: {integrity: sha512-u9vpTHRMkOYCs/1FLiSVAFZ7FbjsXK+bQuzviJZa+lG7BHZl1nz52/IcGvwa3sk80/fc3llutBkbCq10Vh8WQA==}
engines: {node: '>=22.12.0'}
hasBin: true
'@electron/universal@1.5.1':
resolution: {integrity: sha512-kbgXxyEauPJiQQUNG2VgUeyfQNFk6hBF11ISN2PNI6agUgPl55pv4eQmaqHzTAzchBvqZ2tQuRVaPStGf0mxGw==}
engines: {node: '>=8.6'}
@ -1017,6 +1034,10 @@ packages:
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
'@isaacs/fs-minipass@4.0.1':
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
engines: {node: '>=18.0.0'}
'@istanbuljs/schema@0.1.3':
resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==}
engines: {node: '>=8'}
@ -1128,10 +1149,18 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
'@npmcli/agent@3.0.0':
resolution: {integrity: sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==}
engines: {node: ^18.17.0 || >=20.5.0}
'@npmcli/fs@2.1.2':
resolution: {integrity: sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
'@npmcli/fs@4.0.0':
resolution: {integrity: sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==}
engines: {node: ^18.17.0 || >=20.5.0}
'@npmcli/move-file@2.0.1':
resolution: {integrity: sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
@ -2101,9 +2130,19 @@ packages:
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
engines: {node: '>=10.0.0'}
'@xterm/addon-fit@0.11.0':
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
'@xterm/xterm@6.0.0':
resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==}
abbrev@1.1.1:
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
abbrev@3.0.1:
resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==}
engines: {node: ^18.17.0 || >=20.5.0}
abstract-logging@2.0.1:
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
@ -2442,6 +2481,10 @@ packages:
resolution: {integrity: sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
cacache@19.0.1:
resolution: {integrity: sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==}
engines: {node: ^18.17.0 || >=20.5.0}
cacheable-lookup@5.0.4:
resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==}
engines: {node: '>=10.6.0'}
@ -2508,6 +2551,10 @@ packages:
resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
engines: {node: '>=10'}
chownr@3.0.0:
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
engines: {node: '>=18'}
chromium-pickle-js@0.2.0:
resolution: {integrity: sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==}
@ -3239,6 +3286,10 @@ packages:
resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==}
engines: {node: '>= 8'}
fs-minipass@3.0.3:
resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
@ -3686,6 +3737,10 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
isexe@3.1.5:
resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==}
engines: {node: '>=18'}
istanbul-lib-coverage@3.2.2:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'}
@ -3920,6 +3975,10 @@ packages:
resolution: {integrity: sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
make-fetch-happen@14.0.3:
resolution: {integrity: sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==}
engines: {node: ^18.17.0 || >=20.5.0}
markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
@ -4128,10 +4187,18 @@ packages:
resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==}
engines: {node: '>= 8'}
minipass-collect@2.0.1:
resolution: {integrity: sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==}
engines: {node: '>=16 || 14 >=14.17'}
minipass-fetch@2.1.2:
resolution: {integrity: sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
minipass-fetch@4.0.1:
resolution: {integrity: sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==}
engines: {node: ^18.17.0 || >=20.5.0}
minipass-flush@1.0.5:
resolution: {integrity: sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==}
engines: {node: '>= 8'}
@ -4160,6 +4227,10 @@ packages:
resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==}
engines: {node: '>= 8'}
minizlib@3.1.0:
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
engines: {node: '>= 18'}
mkdirp@1.0.4:
resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
engines: {node: '>=10'}
@ -4195,6 +4266,10 @@ packages:
resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==}
engines: {node: '>= 0.6'}
negotiator@1.0.0:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
@ -4202,9 +4277,16 @@ packages:
resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==}
engines: {node: '>=10'}
node-abi@4.26.0:
resolution: {integrity: sha512-8QwIZqikRvDIkXS2S93LjzhsSPJuIbfaMETWH+Bx8oOT9Sa9UsUtBFQlc3gBNd1+QINjaTloitXr1W3dQLi9Iw==}
engines: {node: '>=22.12.0'}
node-addon-api@1.7.2:
resolution: {integrity: sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==}
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-api-version@0.2.1:
resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==}
@ -4212,11 +4294,19 @@ packages:
resolution: {integrity: sha512-vLh2xJFSyniBLYDEDbXKqD32fQ5vAxmYT4hco8t0EHQ4CQ4BDHhshi7kdvDc6Y1MwGSi1Mhl4unUukPbCayZdw==}
engines: {bun: '>=1.3.0', node: '>=18'}
node-gyp@11.5.0:
resolution: {integrity: sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==}
engines: {node: ^18.17.0 || >=20.5.0}
hasBin: true
node-gyp@9.4.1:
resolution: {integrity: sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==}
engines: {node: ^12.13 || ^14.13 || >=16}
hasBin: true
node-pty@1.1.0:
resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==}
node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
@ -4225,6 +4315,11 @@ packages:
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
hasBin: true
nopt@8.1.0:
resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==}
engines: {node: ^18.17.0 || >=20.5.0}
hasBin: true
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
@ -4320,6 +4415,10 @@ packages:
resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==}
engines: {node: '>=10'}
p-map@7.0.4:
resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==}
engines: {node: '>=18'}
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
@ -4524,6 +4623,10 @@ packages:
engines: {node: '>=14'}
hasBin: true
proc-log@5.0.0:
resolution: {integrity: sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==}
engines: {node: ^18.17.0 || >=20.5.0}
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
@ -4910,6 +5013,10 @@ packages:
resolution: {integrity: sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==}
engines: {node: '>= 10'}
socks-proxy-agent@8.0.5:
resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==}
engines: {node: '>= 14'}
socks@2.8.7:
resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==}
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
@ -4945,6 +5052,10 @@ packages:
resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==}
engines: {node: '>=10.16.0'}
ssri@12.0.0:
resolution: {integrity: sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==}
engines: {node: ^18.17.0 || >=20.5.0}
ssri@9.0.1:
resolution: {integrity: sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
@ -5094,6 +5205,10 @@ packages:
engines: {node: '>=10'}
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
tar@7.5.9:
resolution: {integrity: sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==}
engines: {node: '>=18'}
temp-file@3.4.0:
resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==}
@ -5254,10 +5369,18 @@ packages:
resolution: {integrity: sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
unique-filename@4.0.0:
resolution: {integrity: sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==}
engines: {node: ^18.17.0 || >=20.5.0}
unique-slug@3.0.0:
resolution: {integrity: sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
unique-slug@5.0.0:
resolution: {integrity: sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==}
engines: {node: ^18.17.0 || >=20.5.0}
unist-util-find-after@5.0.0:
resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==}
@ -5436,6 +5559,11 @@ packages:
engines: {node: '>= 8'}
hasBin: true
which@5.0.0:
resolution: {integrity: sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==}
engines: {node: ^18.17.0 || >=20.5.0}
hasBin: true
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
@ -5480,6 +5608,10 @@ packages:
yallist@4.0.0:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
yallist@5.0.0:
resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
engines: {node: '>=18'}
yaml@2.8.2:
resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==}
engines: {node: '>= 14.6'}
@ -6040,6 +6172,24 @@ snapshots:
- bluebird
- supports-color
'@electron/rebuild@4.0.3':
dependencies:
'@malept/cross-spawn-promise': 2.0.0
debug: 4.4.3
detect-libc: 2.1.2
got: 11.8.6
graceful-fs: 4.2.11
node-abi: 4.26.0
node-api-version: 0.2.1
node-gyp: 11.5.0
ora: 5.4.1
read-binary-file-arch: 1.0.6
semver: 7.7.4
tar: 7.5.9
yargs: 17.7.2
transitivePeerDependencies:
- supports-color
'@electron/universal@1.5.1':
dependencies:
'@electron/asar': 3.4.1
@ -6371,6 +6521,10 @@ snapshots:
wrap-ansi: 8.1.0
wrap-ansi-cjs: wrap-ansi@7.0.0
'@isaacs/fs-minipass@4.0.1':
dependencies:
minipass: 7.1.2
'@istanbuljs/schema@0.1.3': {}
'@jridgewell/gen-mapping@0.3.13':
@ -6538,10 +6692,24 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.20.1
'@npmcli/agent@3.0.0':
dependencies:
agent-base: 7.1.4
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
lru-cache: 10.4.3
socks-proxy-agent: 8.0.5
transitivePeerDependencies:
- supports-color
'@npmcli/fs@2.1.2':
dependencies:
'@gar/promisify': 1.1.3
semver: 7.7.3
semver: 7.7.4
'@npmcli/fs@4.0.0':
dependencies:
semver: 7.7.4
'@npmcli/move-file@2.0.1':
dependencies:
@ -7454,8 +7622,14 @@ snapshots:
'@xmldom/xmldom@0.8.11': {}
'@xterm/addon-fit@0.11.0': {}
'@xterm/xterm@6.0.0': {}
abbrev@1.1.1: {}
abbrev@3.0.1: {}
abstract-logging@2.0.1: {}
acorn-jsx@5.3.2(acorn@8.15.0):
@ -7941,6 +8115,21 @@ snapshots:
transitivePeerDependencies:
- bluebird
cacache@19.0.1:
dependencies:
'@npmcli/fs': 4.0.0
fs-minipass: 3.0.3
glob: 10.5.0
lru-cache: 10.4.3
minipass: 7.1.2
minipass-collect: 2.0.1
minipass-flush: 1.0.5
minipass-pipeline: 1.2.4
p-map: 7.0.4
ssri: 12.0.0
tar: 7.5.9
unique-filename: 4.0.0
cacheable-lookup@5.0.4: {}
cacheable-request@7.0.4:
@ -8015,6 +8204,8 @@ snapshots:
chownr@2.0.0: {}
chownr@3.0.0: {}
chromium-pickle-js@0.2.0: {}
ci-info@3.9.0: {}
@ -9003,6 +9194,10 @@ snapshots:
dependencies:
minipass: 3.3.6
fs-minipass@3.0.3:
dependencies:
minipass: 7.1.2
fs.realpath@1.0.0: {}
fsevents@2.3.3:
@ -9496,6 +9691,8 @@ snapshots:
isexe@2.0.0: {}
isexe@3.1.5: {}
istanbul-lib-coverage@3.2.2: {}
istanbul-lib-report@3.0.1:
@ -9764,6 +9961,22 @@ snapshots:
- bluebird
- supports-color
make-fetch-happen@14.0.3:
dependencies:
'@npmcli/agent': 3.0.0
cacache: 19.0.1
http-cache-semantics: 4.2.0
minipass: 7.1.2
minipass-fetch: 4.0.1
minipass-flush: 1.0.5
minipass-pipeline: 1.2.4
negotiator: 1.0.0
proc-log: 5.0.0
promise-retry: 2.0.1
ssri: 12.0.0
transitivePeerDependencies:
- supports-color
markdown-table@3.0.4: {}
matcher@3.0.0:
@ -10168,6 +10381,10 @@ snapshots:
dependencies:
minipass: 3.3.6
minipass-collect@2.0.1:
dependencies:
minipass: 7.1.2
minipass-fetch@2.1.2:
dependencies:
minipass: 3.3.6
@ -10176,6 +10393,14 @@ snapshots:
optionalDependencies:
encoding: 0.1.13
minipass-fetch@4.0.1:
dependencies:
minipass: 7.1.2
minipass-sized: 1.0.3
minizlib: 3.1.0
optionalDependencies:
encoding: 0.1.13
minipass-flush@1.0.5:
dependencies:
minipass: 3.3.6
@ -10201,6 +10426,10 @@ snapshots:
minipass: 3.3.6
yallist: 4.0.0
minizlib@3.1.0:
dependencies:
minipass: 7.1.2
mkdirp@1.0.4: {}
ms@2.1.3: {}
@ -10224,21 +10453,44 @@ snapshots:
negotiator@0.6.4: {}
negotiator@1.0.0: {}
neo-async@2.6.2: {}
node-abi@3.87.0:
dependencies:
semver: 7.7.3
semver: 7.7.4
node-abi@4.26.0:
dependencies:
semver: 7.7.4
node-addon-api@1.7.2:
optional: true
node-addon-api@7.1.1: {}
node-api-version@0.2.1:
dependencies:
semver: 7.7.3
semver: 7.7.4
node-diff3@3.2.0: {}
node-gyp@11.5.0:
dependencies:
env-paths: 2.2.1
exponential-backoff: 3.1.3
graceful-fs: 4.2.11
make-fetch-happen: 14.0.3
nopt: 8.1.0
proc-log: 5.0.0
semver: 7.7.4
tar: 7.5.9
tinyglobby: 0.2.15
which: 5.0.0
transitivePeerDependencies:
- supports-color
node-gyp@9.4.1:
dependencies:
env-paths: 2.2.1
@ -10249,19 +10501,27 @@ snapshots:
nopt: 6.0.0
npmlog: 6.0.2
rimraf: 3.0.2
semver: 7.7.3
semver: 7.7.4
tar: 6.2.1
which: 2.0.2
transitivePeerDependencies:
- bluebird
- supports-color
node-pty@1.1.0:
dependencies:
node-addon-api: 7.1.1
node-releases@2.0.27: {}
nopt@6.0.0:
dependencies:
abbrev: 1.1.1
nopt@8.1.0:
dependencies:
abbrev: 3.0.1
normalize-path@3.0.0: {}
normalize-url@6.1.0: {}
@ -10395,6 +10655,8 @@ snapshots:
dependencies:
aggregate-error: 3.1.0
p-map@7.0.4: {}
package-json-from-dist@1.0.1: {}
parent-module@1.0.1:
@ -10529,6 +10791,8 @@ snapshots:
prettier@3.8.1: {}
proc-log@5.0.0: {}
process-nextick-args@2.0.1: {}
process-warning@4.0.1: {}
@ -11002,6 +11266,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
socks-proxy-agent@8.0.5:
dependencies:
agent-base: 7.1.4
debug: 4.4.3
socks: 2.8.7
transitivePeerDependencies:
- supports-color
socks@2.8.7:
dependencies:
ip-address: 10.1.0
@ -11037,6 +11309,10 @@ snapshots:
cpu-features: 0.0.10
nan: 2.25.0
ssri@12.0.0:
dependencies:
minipass: 7.1.2
ssri@9.0.1:
dependencies:
minipass: 3.3.6
@ -11245,6 +11521,14 @@ snapshots:
mkdirp: 1.0.4
yallist: 4.0.0
tar@7.5.9:
dependencies:
'@isaacs/fs-minipass': 4.0.1
chownr: 3.0.0
minipass: 7.1.2
minizlib: 3.1.0
yallist: 5.0.0
temp-file@3.4.0:
dependencies:
async-exit-hook: 2.0.1
@ -11422,10 +11706,18 @@ snapshots:
dependencies:
unique-slug: 3.0.0
unique-filename@4.0.0:
dependencies:
unique-slug: 5.0.0
unique-slug@3.0.0:
dependencies:
imurmurhash: 0.1.4
unique-slug@5.0.0:
dependencies:
imurmurhash: 0.1.4
unist-util-find-after@5.0.0:
dependencies:
'@types/unist': 3.0.3
@ -11655,6 +11947,10 @@ snapshots:
dependencies:
isexe: 2.0.0
which@5.0.0:
dependencies:
isexe: 3.1.5
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
@ -11696,6 +11992,8 @@ snapshots:
yallist@4.0.0: {}
yallist@5.0.0: {}
yaml@2.8.2: {}
yargs-parser@21.1.1: {}

View file

@ -43,6 +43,7 @@ import {
LocalFileSystemProvider,
MemberStatsComputer,
NotificationManager,
PtyTerminalService,
ServiceContext,
ServiceContextRegistry,
SshConnectionManager,
@ -170,6 +171,7 @@ let sshConnectionManager: SshConnectionManager;
let teamDataService: TeamDataService;
let teamProvisioningService: TeamProvisioningService;
let cliInstallerService: CliInstallerService;
let ptyTerminalService: PtyTerminalService;
let httpServer: HttpServer;
// File watcher event cleanup functions
@ -422,6 +424,7 @@ function initializeServices(): void {
// Initialize updater and CLI installer services
updaterService = new UpdaterService();
cliInstallerService = new CliInstallerService();
ptyTerminalService = new PtyTerminalService();
teamDataService = new TeamDataService();
teamProvisioningService = new TeamProvisioningService();
const teamMemberLogsFinder = new TeamMemberLogsFinder();
@ -474,7 +477,8 @@ function initializeServices(): void {
fileContentResolver,
reviewApplier,
gitDiffFallback,
cliInstallerService
cliInstallerService,
ptyTerminalService
);
// Forward SSH state changes to renderer and HTTP SSE clients
@ -568,6 +572,11 @@ function shutdownServices(): void {
sshConnectionManager.dispose();
}
// Kill all PTY processes
if (ptyTerminalService) {
ptyTerminalService.killAll();
}
// Remove IPC handlers
removeIpcHandlers();
@ -719,6 +728,9 @@ function createWindow(): void {
if (cliInstallerService) {
cliInstallerService.setMainWindow(null);
}
if (ptyTerminalService) {
ptyTerminalService.setMainWindow(null);
}
});
// Handle renderer process crashes (render-process-gone replaces deprecated 'crashed' event)
@ -737,6 +749,9 @@ function createWindow(): void {
if (cliInstallerService) {
cliInstallerService.setMainWindow(mainWindow);
}
if (ptyTerminalService) {
ptyTerminalService.setMainWindow(mainWindow);
}
logger.info('Main window created');
}

View file

@ -55,6 +55,11 @@ import {
removeSubagentHandlers,
} from './subagents';
import { initializeTeamHandlers, registerTeamHandlers, removeTeamHandlers } from './teams';
import {
initializeTerminalHandlers,
registerTerminalHandlers,
removeTerminalHandlers,
} from './terminal';
import {
initializeUpdaterHandlers,
registerUpdaterHandlers,
@ -70,6 +75,7 @@ import type {
FileContentResolver,
GitDiffFallback,
MemberStatsComputer,
PtyTerminalService,
ReviewApplierService,
ServiceContext,
ServiceContextRegistry,
@ -105,7 +111,8 @@ export function initializeIpcHandlers(
fileContentResolver?: FileContentResolver,
reviewApplier?: ReviewApplierService,
gitDiffFallback?: GitDiffFallback,
cliInstaller?: CliInstallerService
cliInstaller?: CliInstallerService,
ptyTerminal?: PtyTerminalService
): void {
// Initialize domain handlers with registry
initializeProjectHandlers(registry);
@ -133,6 +140,9 @@ export function initializeIpcHandlers(
if (cliInstaller) {
initializeCliInstallerHandlers(cliInstaller);
}
if (ptyTerminal) {
initializeTerminalHandlers(ptyTerminal);
}
if (changeExtractor) {
initializeReviewHandlers({
extractor: changeExtractor,
@ -160,6 +170,9 @@ export function initializeIpcHandlers(
if (cliInstaller) {
registerCliInstallerHandlers(ipcMain);
}
if (ptyTerminal) {
registerTerminalHandlers(ipcMain);
}
if (httpServerDeps) {
registerHttpServerHandlers(ipcMain);
}
@ -187,6 +200,7 @@ export function removeIpcHandlers(): void {
removeReviewHandlers(ipcMain);
removeWindowHandlers(ipcMain);
removeCliInstallerHandlers(ipcMain);
removeTerminalHandlers(ipcMain);
removeHttpServerHandlers(ipcMain);
logger.info('All handlers removed');

84
src/main/ipc/terminal.ts Normal file
View file

@ -0,0 +1,84 @@
/**
* IPC Handlers for Embedded Terminal Operations.
*
* Handlers:
* - terminal:spawn: Spawn a new PTY process (returns pty ID)
* - terminal:write: Write data to PTY stdin (fire-and-forget)
* - terminal:resize: Resize PTY terminal (fire-and-forget)
* - terminal:kill: Kill PTY process (fire-and-forget)
* - terminal:data: PTY output events (main renderer, not a handler)
* - terminal:exit: PTY exit events (main renderer, not a handler)
*/
import {
TERMINAL_KILL,
TERMINAL_RESIZE,
TERMINAL_SPAWN,
TERMINAL_WRITE,
// eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload
} from '@preload/constants/ipcChannels';
import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
import type { PtyTerminalService } from '../services';
import type { IpcResult } from '@shared/types';
import type { PtySpawnOptions } from '@shared/types/terminal';
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
const logger = createLogger('IPC:terminal');
let service: PtyTerminalService;
/**
* Initializes terminal handlers with the service instance.
*/
export function initializeTerminalHandlers(terminalService: PtyTerminalService): void {
service = terminalService;
}
/**
* Registers all terminal IPC handlers.
*/
export function registerTerminalHandlers(ipcMain: IpcMain): void {
// spawn uses handle (needs response with pty ID)
ipcMain.handle(TERMINAL_SPAWN, handleSpawn);
// write, resize, kill are fire-and-forget (hot path, latency-sensitive)
ipcMain.on(TERMINAL_WRITE, (_event, ptyId: string, data: string) => service.write(ptyId, data));
ipcMain.on(TERMINAL_RESIZE, (_event, ptyId: string, cols: number, rows: number) =>
service.resize(ptyId, cols, rows)
);
ipcMain.on(TERMINAL_KILL, (_event, ptyId: string) => service.kill(ptyId));
logger.info('Terminal handlers registered');
}
/**
* Removes all terminal IPC handlers.
*/
export function removeTerminalHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TERMINAL_SPAWN);
ipcMain.removeAllListeners(TERMINAL_WRITE);
ipcMain.removeAllListeners(TERMINAL_RESIZE);
ipcMain.removeAllListeners(TERMINAL_KILL);
logger.info('Terminal handlers removed');
}
// =============================================================================
// Handler Implementations
// =============================================================================
async function handleSpawn(
_event: IpcMainInvokeEvent,
options?: PtySpawnOptions
): Promise<IpcResult<string>> {
try {
const id = service.spawn(options);
return { success: true, data: id };
} catch (error) {
const msg = getErrorMessage(error);
logger.error('Error in terminal:spawn:', msg);
return { success: false, error: msg };
}
}

View file

@ -191,6 +191,8 @@ export class CliInstallerService {
binaryPath: null,
latestVersion: null,
updateAvailable: false,
authLoggedIn: false,
authMethod: null,
};
const binaryPath = await ClaudeBinaryResolver.resolve();
@ -209,6 +211,22 @@ export class CliInstallerService {
} catch (err) {
logger.warn('Failed to get CLI version:', getErrorMessage(err));
}
// Check auth status
try {
const { stdout: authStdout } = await execFileAsync(binaryPath, ['auth', 'status'], {
timeout: VERSION_TIMEOUT_MS,
});
const auth = JSON.parse(authStdout.trim()) as { loggedIn?: boolean; authMethod?: string };
result.authLoggedIn = auth.loggedIn === true;
result.authMethod = auth.authMethod ?? null;
logger.info(
`Auth status: loggedIn=${result.authLoggedIn}, method=${result.authMethod ?? 'null'}`
);
} catch (err) {
logger.warn('Failed to check auth status:', getErrorMessage(err));
result.authLoggedIn = false;
}
}
try {

View file

@ -0,0 +1,112 @@
/**
* PtyTerminalService manages node-pty terminal instances.
*
* Provides PTY spawning, IO, and lifecycle management for the embedded terminal.
* Events (data, exit) are forwarded to the renderer via mainWindow.webContents.send().
*/
import crypto from 'node:crypto';
import os from 'node:os';
// eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload
import { TERMINAL_DATA, TERMINAL_EXIT } from '@preload/constants/ipcChannels';
import { createLogger } from '@shared/utils/logger';
import type { PtySpawnOptions } from '@shared/types/terminal';
import type { BrowserWindow } from 'electron';
const logger = createLogger('PtyTerminalService');
// Graceful import: node-pty is a native addon that may not be available
// if electron-rebuild was not run or native build tools are missing.
import type { IPty } from 'node-pty';
import type * as NodePty from 'node-pty';
type NodePtyModule = typeof NodePty;
let nodePty: NodePtyModule | null = null;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports -- node-pty is optional native addon
nodePty = require('node-pty') as NodePtyModule;
} catch {
logger.warn('node-pty not available — terminal features disabled');
}
export class PtyTerminalService {
private ptys = new Map<string, IPty>();
private mainWindow: BrowserWindow | null = null;
setMainWindow(window: BrowserWindow | null): void {
this.mainWindow = window;
}
/**
* Spawn a new PTY process.
* @returns Unique PTY ID for subsequent write/resize/kill calls.
* @throws If node-pty native module is not available.
*/
spawn(options?: PtySpawnOptions): string {
if (!nodePty) {
throw new Error(
'Terminal not available: node-pty native module not found. Run: pnpm install'
);
}
const id = crypto.randomUUID();
const shell =
options?.command ??
(process.platform === 'win32'
? (process.env.COMSPEC ?? 'powershell.exe')
: (process.env.SHELL ?? '/bin/bash'));
const pty = nodePty.spawn(shell, options?.args ?? [], {
name: 'xterm-256color',
cols: options?.cols ?? 80,
rows: options?.rows ?? 24,
cwd: options?.cwd ?? os.homedir(),
env: { ...process.env, ...options?.env } as Record<string, string>,
});
pty.onData((data) => this.send(TERMINAL_DATA, id, data));
pty.onExit(({ exitCode }) => {
this.send(TERMINAL_EXIT, id, exitCode);
this.ptys.delete(id);
});
this.ptys.set(id, pty);
logger.info(`PTY spawned: ${id} (${shell})`);
return id;
}
write(id: string, data: string): void {
this.ptys.get(id)?.write(data);
}
resize(id: string, cols: number, rows: number): void {
this.ptys.get(id)?.resize(cols, rows);
}
kill(id: string): void {
const pty = this.ptys.get(id);
if (pty) {
pty.kill();
this.ptys.delete(id);
logger.info(`PTY killed: ${id}`);
}
}
/** Kill all PTY processes. Called on app shutdown. */
killAll(): void {
const count = this.ptys.size;
if (count > 0) {
logger.info(`Killing ${count} PTY processes on shutdown`);
}
this.ptys.forEach((pty) => pty.kill());
this.ptys.clear();
}
private send(channel: string, ...args: unknown[]): void {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send(channel, ...args);
}
}
}

View file

@ -24,6 +24,7 @@ export * from './FileWatcher';
export * from './HttpServer';
export * from './LocalFileSystemProvider';
export * from './NotificationManager';
export * from './PtyTerminalService';
export * from './ServiceContext';
export * from './ServiceContextRegistry';
export * from './SshConfigParser';

View file

@ -1,10 +1,10 @@
import { createLogger } from '@shared/utils/logger';
import { diffLines } from 'diff';
import { createReadStream } from 'fs';
import { stat } from 'fs/promises';
import * as readline from 'readline';
import { TeamConfigReader } from './TeamConfigReader';
import { countLineChanges } from './UnifiedLineCounter';
import type { TaskBoundaryParser } from './TaskBoundaryParser';
import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
@ -37,7 +37,7 @@ interface LogFileRef {
export class ChangeExtractorService {
private cache = new Map<string, CacheEntry>();
private readonly CACHE_TTL = 30 * 1000; // 30 сек — shorter TTL to reduce stale data risk
private readonly cacheTtl = 30 * 1000; // 30 сек — shorter TTL to reduce stale data risk
constructor(
private readonly logsFinder: TeamMemberLogsFinder,
@ -96,7 +96,7 @@ export class ChangeExtractorService {
this.cache.set(cacheKey, {
data: result,
mtime: latestMtime,
expiresAt: Date.now() + this.CACHE_TTL,
expiresAt: Date.now() + this.cacheTtl,
});
return result;
@ -173,6 +173,26 @@ export class ChangeExtractorService {
}
}
/**
* Compute a context hash from old/newString for reliable hunksnippet matching.
* Uses first+last 3 lines of both strings as a fingerprint.
*/
private computeContextHash(oldString: string, newString: string): string {
const take3 = (s: string): string => {
const lines = s.split('\n');
const head = lines.slice(0, 3).join('\n');
const tail = lines.length > 3 ? lines.slice(-3).join('\n') : '';
return `${head}|${tail}`;
};
const raw = `${take3(oldString)}::${take3(newString)}`;
// Simple hash: DJB2 variant (fast, no crypto needed)
let hash = 5381;
for (let i = 0; i < raw.length; i++) {
hash = ((hash << 5) + hash + raw.charCodeAt(i)) | 0;
}
return (hash >>> 0).toString(36);
}
/** Парсить один JSONL файл и извлечь все snippets (двухпроходный подход) */
private async parseJSONLFile(filePath: string): Promise<SnippetDiff[]> {
// Сначала считываем все записи в память для двух проходов
@ -237,16 +257,16 @@ export class ChangeExtractorService {
const isError = erroredIds.has(toolUseId);
if (toolName === 'Edit') {
const filePath_ = typeof input.file_path === 'string' ? input.file_path : '';
const path = typeof input.file_path === 'string' ? input.file_path : '';
const oldString = typeof input.old_string === 'string' ? input.old_string : '';
const newString = typeof input.new_string === 'string' ? input.new_string : '';
const replaceAll = input.replace_all === true;
if (filePath_) {
seenFiles.add(filePath_);
if (path) {
seenFiles.add(path);
snippets.push({
toolUseId,
filePath: filePath_,
filePath: path,
toolName: 'Edit',
type: 'edit',
oldString,
@ -254,18 +274,19 @@ export class ChangeExtractorService {
replaceAll,
timestamp,
isError,
contextHash: this.computeContextHash(oldString, newString),
});
}
} else if (toolName === 'Write') {
const filePath_ = typeof input.file_path === 'string' ? input.file_path : '';
const path = typeof input.file_path === 'string' ? input.file_path : '';
const writeContent = typeof input.content === 'string' ? input.content : '';
if (filePath_) {
const isNew = !seenFiles.has(filePath_);
seenFiles.add(filePath_);
if (path) {
const isNew = !seenFiles.has(path);
seenFiles.add(path);
snippets.push({
toolUseId,
filePath: filePath_,
filePath: path,
toolName: 'Write',
type: isNew ? 'write-new' : 'write-update',
oldString: '',
@ -273,14 +294,15 @@ export class ChangeExtractorService {
replaceAll: false,
timestamp,
isError,
contextHash: this.computeContextHash('', writeContent),
});
}
} else if (toolName === 'MultiEdit') {
const filePath_ = typeof input.file_path === 'string' ? input.file_path : '';
const path = typeof input.file_path === 'string' ? input.file_path : '';
const edits = Array.isArray(input.edits) ? input.edits : [];
if (filePath_) {
seenFiles.add(filePath_);
if (path) {
seenFiles.add(path);
for (const edit of edits) {
if (!edit || typeof edit !== 'object') continue;
const editObj = edit as Record<string, unknown>;
@ -288,7 +310,7 @@ export class ChangeExtractorService {
const newString = typeof editObj.new_string === 'string' ? editObj.new_string : '';
snippets.push({
toolUseId,
filePath: filePath_,
filePath: path,
toolName: 'MultiEdit',
type: 'multi-edit',
oldString,
@ -296,6 +318,7 @@ export class ChangeExtractorService {
replaceAll: false,
timestamp,
isError,
contextHash: this.computeContextHash(oldString, newString),
});
}
}
@ -388,7 +411,7 @@ export class ChangeExtractorService {
let totalRemoved = 0;
for (const s of data.snippets) {
if (s.isError) continue;
const { added, removed } = this.countLines(s.oldString, s.newString);
const { added, removed } = countLineChanges(s.oldString, s.newString);
totalAdded += added;
totalRemoved += removed;
}
@ -418,15 +441,18 @@ export class ChangeExtractorService {
private buildTimeline(filePath: string, snippets: SnippetDiff[]): FileEditTimeline {
const events: FileEditEvent[] = snippets
.filter((s) => !s.isError)
.map((s, idx) => ({
toolUseId: s.toolUseId,
toolName: s.toolName as FileEditEvent['toolName'],
timestamp: s.timestamp,
summary: this.generateEditSummary(s),
linesAdded: Math.max(0, s.newString.split('\n').length - s.oldString.split('\n').length),
linesRemoved: Math.max(0, s.oldString.split('\n').length - s.newString.split('\n').length),
snippetIndex: idx,
}));
.map((s, idx) => {
const { added, removed } = countLineChanges(s.oldString, s.newString);
return {
toolUseId: s.toolUseId,
toolName: s.toolName as FileEditEvent['toolName'],
timestamp: s.timestamp,
summary: this.generateEditSummary(s),
linesAdded: added,
linesRemoved: removed,
snippetIndex: idx,
};
});
const timestamps = events.map((e) => new Date(e.timestamp).getTime()).filter((t) => !isNaN(t));
const durationMs =
@ -442,16 +468,14 @@ export class ChangeExtractorService {
case 'write-update':
return 'Wrote full file content';
case 'multi-edit': {
const lines = snippet.oldString.split('\n').length;
return `Multi-edit (${lines} line${lines !== 1 ? 's' : ''})`;
const { added, removed } = countLineChanges(snippet.oldString, snippet.newString);
const total = added + removed;
return `Multi-edit (${total} line${total !== 1 ? 's' : ''})`;
}
case 'edit': {
const added = snippet.newString.split('\n').length;
const removed = snippet.oldString.split('\n').length;
if (removed === 0 || snippet.oldString === '')
return `Added ${added} line${added !== 1 ? 's' : ''}`;
if (added === 0 || snippet.newString === '')
return `Removed ${removed} line${removed !== 1 ? 's' : ''}`;
const { added, removed } = countLineChanges(snippet.oldString, snippet.newString);
if (snippet.oldString === '') return `Added ${added} line${added !== 1 ? 's' : ''}`;
if (snippet.newString === '') return `Removed ${removed} line${removed !== 1 ? 's' : ''}`;
return `Changed ${removed}${added} lines`;
}
default:
@ -459,19 +483,6 @@ export class ChangeExtractorService {
}
}
/** Подсчёт добавленных/удалённых строк через diff */
private countLines(oldStr: string, newStr: string): { added: number; removed: number } {
if (!oldStr && !newStr) return { added: 0, removed: 0 };
const changes = diffLines(oldStr, newStr);
let added = 0;
let removed = 0;
for (const c of changes) {
if (c.added) added += c.count ?? 0;
if (c.removed) removed += c.count ?? 0;
}
return { added, removed };
}
/** Проверить, содержит ли путь к файлу один из sessionId */
private pathMatchesAnySession(filePath: string, sessionIds: Set<string>): boolean {
for (const sessionId of sessionIds) {

View file

@ -0,0 +1,155 @@
import { structuredPatch } from 'diff';
import type { SnippetDiff } from '@shared/types';
/**
* Reliable hunksnippet matcher using content overlap analysis.
*
* Uses bidirectional substring matching between hunk added/removed lines
* and snippet newString/oldString to determine which snippets correspond
* to which diff hunks.
*
* Replaces the previous 1:1 hunkIndexsnippetIndex assumption.
*/
export class HunkSnippetMatcher {
/**
* Match hunk indices to their corresponding snippets.
* Returns a Map where each hunk index maps to the set of matching snippet indices.
*
* @param snippets MUST be pre-filtered (no isError entries).
* Returned indices are relative to this array.
*/
matchHunksToSnippets(
original: string,
modified: string,
hunkIndices: number[],
snippets: SnippetDiff[]
): Map<number, Set<number>> {
if (snippets.length === 0) return new Map();
const patch = structuredPatch('file', 'file', original, modified);
if (!patch.hunks || patch.hunks.length === 0) return new Map();
const mapping = new Map<number, Set<number>>();
for (const hunkIdx of hunkIndices) {
if (hunkIdx < 0 || hunkIdx >= patch.hunks.length) continue;
const hunk = patch.hunks[hunkIdx];
const snippetSet = new Set<number>();
// Extract added/removed content from hunk
const addedLines = hunk.lines.filter((l) => l.startsWith('+')).map((l) => l.slice(1));
const removedLines = hunk.lines.filter((l) => l.startsWith('-')).map((l) => l.slice(1));
const addedContent = addedLines.join('\n');
const removedContent = removedLines.join('\n');
for (let sIdx = 0; sIdx < snippets.length; sIdx++) {
const snippet = snippets[sIdx];
// Content overlap: check if snippet's strings appear in hunk's diff content
if (this.hasContentOverlap(snippet, addedContent, removedContent)) {
snippetSet.add(sIdx);
}
}
mapping.set(hunkIdx, snippetSet);
}
return mapping;
}
/**
* Find the correct position of a snippet's newString in the content,
* disambiguating when multiple occurrences exist.
*/
findSnippetPosition(snippet: SnippetDiff, content: string): number {
const { newString, oldString } = snippet;
if (!newString) return -1; // Deletion — can't find empty string reliably
const firstPos = content.indexOf(newString);
if (firstPos === -1) return -1;
// Fast path: only one occurrence — no ambiguity
const lastPos = content.lastIndexOf(newString);
if (firstPos === lastPos) return firstPos;
// Multiple occurrences — collect all positions
const positions: number[] = [];
let searchStart = 0;
while (true) {
const pos = content.indexOf(newString, searchStart);
if (pos === -1) break;
positions.push(pos);
searchStart = pos + 1;
}
// Disambiguate using oldString context
if (oldString) {
const oldTokens = oldString
.split(/\s+/)
.filter((t) => t.length > 3)
.slice(0, 20); // Limit tokens to prevent excessive scanning
if (oldTokens.length > 0) {
let bestPos = firstPos;
let bestScore = 0;
for (const pos of positions) {
const nearbyStart = Math.max(0, pos - 500);
const nearbyEnd = Math.min(content.length, pos + newString.length + 500);
const nearby = content.substring(nearbyStart, nearbyEnd);
const matchScore = oldTokens.filter((t) => nearby.includes(t)).length;
if (matchScore > bestScore) {
bestScore = matchScore;
bestPos = pos;
}
}
return bestPos;
}
}
return firstPos;
}
// ── Private helpers ──
/**
* Check if a snippet's content overlaps with hunk's added/removed content.
*/
private hasContentOverlap(
snippet: SnippetDiff,
hunkAddedContent: string,
hunkRemovedContent: string
): boolean {
// Skip empty snippets
if (!snippet.newString && !snippet.oldString) return false;
if (snippet.type === 'write-new' || snippet.type === 'write-update') {
// For Write: check if hunk's added content is a substring of snippet's newString
if (snippet.newString && hunkAddedContent) {
return snippet.newString.includes(hunkAddedContent);
}
return false;
}
// For Edit/MultiEdit: check bidirectional overlap
const matchesNew = snippet.newString
? hunkAddedContent.includes(snippet.newString) || snippet.newString.includes(hunkAddedContent)
: false;
const matchesOld = snippet.oldString
? hunkRemovedContent.includes(snippet.oldString) ||
snippet.oldString.includes(hunkRemovedContent)
: false;
// Both directions match = high confidence
if (matchesNew && matchesOld) return true;
// Single direction match = acceptable for Edit
if (matchesNew || matchesOld) return true;
return false;
}
}

View file

@ -3,6 +3,7 @@ import { createReadStream } from 'fs';
import * as readline from 'readline';
import { type TeamMemberLogsFinder } from './TeamMemberLogsFinder';
import { countLineChanges } from './UnifiedLineCounter';
import type { FileLineStats, MemberFullStats } from '@shared/types';
@ -117,6 +118,9 @@ export class MemberStatsComputer {
let firstTimestamp: string | null = null;
let lastTimestamp: string | null = null;
// Track last known content per file for accurate Write/NotebookEdit diffs
const fileLastContent = new Map<string, string>();
const trackFile = (fp: string): void => {
if (typeof fp === 'string' && isValidFilePath(fp)) filesTouchedSet.add(fp);
};
@ -186,41 +190,73 @@ export class MemberStatsComputer {
trackFile(input.path);
}
// Count lines for Edit
// Count lines for Edit (using semantic diff for accuracy)
if (toolName === 'Edit') {
const editPath = typeof input.file_path === 'string' ? input.file_path : '';
const oldStr = typeof input.old_string === 'string' ? input.old_string : '';
const newStr = typeof input.new_string === 'string' ? input.new_string : '';
const oldLines = oldStr ? oldStr.split('\n').length : 0;
const newLines = newStr ? newStr.split('\n').length : 0;
const fileAdded = newLines > oldLines ? newLines - oldLines : 0;
const fileRemoved = oldLines > newLines ? oldLines - newLines : 0;
const replaceAll = input.replace_all === true;
const { added: fileAdded, removed: fileRemoved } = countLineChanges(
oldStr,
newStr
);
linesAdded += fileAdded;
linesRemoved += fileRemoved;
if (typeof input.file_path === 'string') {
addFileLines(input.file_path, fileAdded, fileRemoved);
}
}
// Count lines for Write
if (toolName === 'Write') {
const writeContent = typeof input.content === 'string' ? input.content : '';
if (writeContent) {
const fileAdded = writeContent.split('\n').length;
linesAdded += fileAdded;
if (typeof input.file_path === 'string') {
addFileLines(input.file_path, fileAdded, 0);
if (editPath) {
addFileLines(editPath, fileAdded, fileRemoved);
// Update fileLastContent so subsequent Writes diff against correct state
const prev = fileLastContent.get(editPath);
if (prev !== undefined && oldStr) {
if (replaceAll) {
fileLastContent.set(editPath, prev.split(oldStr).join(newStr));
} else {
const idx = prev.indexOf(oldStr);
if (idx !== -1) {
fileLastContent.set(
editPath,
prev.substring(0, idx) + newStr + prev.substring(idx + oldStr.length)
);
}
}
}
}
}
// Count lines for NotebookEdit
// Count lines for Write (track previous content for accurate diff)
if (toolName === 'Write') {
const writeContent = typeof input.content === 'string' ? input.content : '';
const writePath = typeof input.file_path === 'string' ? input.file_path : '';
if (writeContent) {
const prevContent = fileLastContent.get(writePath) ?? '';
const { added: fileAdded, removed: fileRemoved } = countLineChanges(
prevContent,
writeContent
);
if (writePath) fileLastContent.set(writePath, writeContent);
linesAdded += fileAdded;
linesRemoved += fileRemoved;
if (writePath) {
addFileLines(writePath, fileAdded, fileRemoved);
}
}
}
// Count lines for NotebookEdit (semantic diff)
if (toolName === 'NotebookEdit') {
const src = typeof input.new_source === 'string' ? input.new_source : '';
if (src) {
const fileAdded = src.split('\n').length;
const nbPath =
typeof input.notebook_path === 'string' ? input.notebook_path : '';
const prevContent = fileLastContent.get(nbPath) ?? '';
const { added: fileAdded, removed: fileRemoved } = countLineChanges(
prevContent,
src
);
if (nbPath) fileLastContent.set(nbPath, src);
linesAdded += fileAdded;
if (typeof input.notebook_path === 'string') {
addFileLines(input.notebook_path, fileAdded, 0);
linesRemoved += fileRemoved;
if (nbPath) {
addFileLines(nbPath, fileAdded, fileRemoved);
}
}
if (typeof input.notebook_path === 'string') {

View file

@ -3,6 +3,8 @@ import { applyPatch, structuredPatch } from 'diff';
import { readFile, writeFile } from 'fs/promises';
import { diff3Merge } from 'node-diff3';
import { HunkSnippetMatcher } from './HunkSnippetMatcher';
import type {
ApplyReviewRequest,
ApplyReviewResult,
@ -26,6 +28,8 @@ const logger = createLogger('Service:ReviewApplierService');
* - Batch review application
*/
export class ReviewApplierService {
private readonly matcher = new HunkSnippetMatcher();
/**
* Check if the file on disk has been modified since the review was computed.
* Compares current disk content against the expected modified content.
@ -68,7 +72,7 @@ export class ReviewApplierService {
snippets: SnippetDiff[]
): Promise<RejectResult> {
// Try snippet-level reverse first (most accurate)
const snippetResult = this.trySnippetLevelReject(modified, hunkIndices, snippets);
const snippetResult = this.trySnippetLevelReject(original, modified, hunkIndices, snippets);
if (snippetResult) {
try {
await writeFile(filePath, snippetResult.newContent, 'utf8');
@ -199,7 +203,7 @@ export class ReviewApplierService {
snippets: SnippetDiff[]
): Promise<{ preview: string; hasConflicts: boolean }> {
// Try snippet-level reverse
const snippetResult = this.trySnippetLevelReject(modified, hunkIndices, snippets);
const snippetResult = this.trySnippetLevelReject(original, modified, hunkIndices, snippets);
if (snippetResult) {
return { preview: snippetResult.newContent, hasConflicts: false };
}
@ -323,10 +327,11 @@ export class ReviewApplierService {
/**
* Snippet-level rejection: reverse specific snippets by position (most accurate).
*
* Maps hunk indices to snippet indices, then reverses each snippet's edit
* in reverse positional order to avoid index shift.
* Uses HunkSnippetMatcher with content overlap analysis to map
* hunk indices snippet indices, then reverses matched snippets.
*/
private trySnippetLevelReject(
original: string,
modified: string,
hunkIndices: number[],
snippets: SnippetDiff[]
@ -334,11 +339,21 @@ export class ReviewApplierService {
const validSnippets = snippets.filter((s) => !s.isError);
if (validSnippets.length === 0) return null;
// Map hunk indices to snippet indices.
// The structured patch hunks roughly correspond to groups of consecutive edit snippets,
// but a simple approach: use hunkIndices as snippet indices (Phase 2 assumption).
const snippetsToReject = hunkIndices
.filter((idx) => idx >= 0 && idx < validSnippets.length)
// Pass pre-filtered snippets — matcher returns indices relative to this array
const hunkToSnippets = this.matcher.matchHunksToSnippets(
original,
modified,
hunkIndices,
validSnippets
);
// Collect all unique snippet indices to reject
const snippetIndices = new Set<number>();
for (const indices of hunkToSnippets.values()) {
indices.forEach((idx) => snippetIndices.add(idx));
}
const snippetsToReject = Array.from(snippetIndices)
.map((idx) => validSnippets[idx])
.filter(Boolean);
@ -346,18 +361,17 @@ export class ReviewApplierService {
let content = modified;
// Sort by position in file descending to avoid index shift when replacing
// We find each snippet's newString position and sort by that
// Find positions using disambiguation and sort descending for safe replacement
const positioned = snippetsToReject
.map((snippet) => {
const pos = content.indexOf(snippet.newString);
const pos = this.matcher.findSnippetPosition(snippet, content);
return { snippet, pos };
})
.filter((item) => item.pos !== -1)
.sort((a, b) => b.pos - a.pos);
if (positioned.length !== snippetsToReject.length) {
// Some snippets' newStrings not found in current content — can't do snippet-level
// Some snippets' newStrings not found — can't do snippet-level
return null;
}

View file

@ -94,6 +94,9 @@ export class TeamTaskReader {
/* leave undefined */
}
// `satisfies Record<keyof TeamTask, unknown>` ensures compile-time
// safety: if a field is added to TeamTask but not mapped here,
// TypeScript will error. This prevents silently dropping new fields.
const task: TeamTask = {
id:
typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
@ -126,7 +129,13 @@ export class TeamTaskReader {
typeof c.createdAt === 'string'
)
: undefined,
};
needsClarification: (['lead', 'user'] as const).includes(
parsed.needsClarification as 'lead' | 'user'
)
? (parsed.needsClarification as 'lead' | 'user')
: undefined,
deletedAt: undefined, // deleted tasks are filtered out below
} satisfies Record<keyof TeamTask, unknown>;
if (task.status === 'deleted') {
continue;
}

View file

@ -0,0 +1,25 @@
import { diffLines } from 'diff';
/**
* Unified line counting utility using semantic diff.
* Ensures consistent +/- line counts across all services
* (MemberStatsComputer, ChangeExtractorService, FileContentResolver).
*
* Uses `diffLines()` from npm `diff` package the same algorithm
* already used correctly in ChangeExtractorService.countLines()
* and FileContentResolver.getFileContent().
*/
export function countLineChanges(
oldStr: string,
newStr: string
): { added: number; removed: number } {
if (!oldStr && !newStr) return { added: 0, removed: 0 };
const changes = diffLines(oldStr, newStr);
let added = 0;
let removed = 0;
for (const c of changes) {
if (c.added) added += c.count ?? 0;
if (c.removed) removed += c.count ?? 0;
}
return { added, removed };
}

View file

@ -2,6 +2,7 @@ export { ChangeExtractorService } from './ChangeExtractorService';
export { ClaudeBinaryResolver } from './ClaudeBinaryResolver';
export { FileContentResolver } from './FileContentResolver';
export { GitDiffFallback } from './GitDiffFallback';
export { HunkSnippetMatcher } from './HunkSnippetMatcher';
export { MemberStatsComputer } from './MemberStatsComputer';
export { ReviewApplierService } from './ReviewApplierService';
export { TaskBoundaryParser } from './TaskBoundaryParser';
@ -19,3 +20,4 @@ export { TeamProvisioningService } from './TeamProvisioningService';
export { TeamSentMessagesStore } from './TeamSentMessagesStore';
export { TeamTaskReader } from './TeamTaskReader';
export { TeamTaskWriter } from './TeamTaskWriter';
export { countLineChanges } from './UnifiedLineCounter';

View file

@ -325,6 +325,28 @@ export const CLI_INSTALLER_INSTALL = 'cliInstaller:install';
/** CLI installer progress events (main -> renderer) */
export const CLI_INSTALLER_PROGRESS = 'cliInstaller:progress';
// =============================================================================
// Terminal API Channels
// =============================================================================
/** Spawn a new PTY terminal process */
export const TERMINAL_SPAWN = 'terminal:spawn';
/** Write data to PTY stdin (fire-and-forget) */
export const TERMINAL_WRITE = 'terminal:write';
/** Resize PTY terminal (fire-and-forget) */
export const TERMINAL_RESIZE = 'terminal:resize';
/** Kill PTY process (fire-and-forget) */
export const TERMINAL_KILL = 'terminal:kill';
/** PTY data output (main -> renderer) */
export const TERMINAL_DATA = 'terminal:data';
/** PTY process exit (main -> renderer) */
export const TERMINAL_EXIT = 'terminal:exit';
// =============================================================================
// Review API Channels
// =============================================================================

View file

@ -79,6 +79,12 @@ import {
TEAM_UPDATE_MEMBER_ROLE,
TEAM_UPDATE_TASK_OWNER,
TEAM_UPDATE_TASK_STATUS,
TERMINAL_DATA,
TERMINAL_EXIT,
TERMINAL_KILL,
TERMINAL_RESIZE,
TERMINAL_SPAWN,
TERMINAL_WRITE,
UPDATER_CHECK,
UPDATER_DOWNLOAD,
UPDATER_INSTALL,
@ -173,6 +179,7 @@ import type {
UpdateKanbanPatch,
WslClaudeRootCandidate,
} from '@shared/types';
import type { PtySpawnOptions } from '@shared/types/terminal';
// =============================================================================
// IPC Result Types and Helpers
@ -880,6 +887,39 @@ const electronAPI: ElectronAPI = {
};
},
},
// ===== Terminal API =====
terminal: {
spawn: (options?: PtySpawnOptions) => invokeIpcWithResult<string>(TERMINAL_SPAWN, options),
write: (ptyId: string, data: string) => ipcRenderer.send(TERMINAL_WRITE, ptyId, data),
resize: (ptyId: string, cols: number, rows: number) =>
ipcRenderer.send(TERMINAL_RESIZE, ptyId, cols, rows),
kill: (ptyId: string) => ipcRenderer.send(TERMINAL_KILL, ptyId),
onData: (cb: (event: unknown, ptyId: string, data: string) => void): (() => void) => {
ipcRenderer.on(
TERMINAL_DATA,
cb as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
return (): void => {
ipcRenderer.removeListener(
TERMINAL_DATA,
cb as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
};
},
onExit: (cb: (event: unknown, ptyId: string, exitCode: number) => void): (() => void) => {
ipcRenderer.on(
TERMINAL_EXIT,
cb as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
return (): void => {
ipcRenderer.removeListener(
TERMINAL_EXIT,
cb as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
};
},
},
};
// Use contextBridge to securely expose the API to the renderer process

View file

@ -63,6 +63,7 @@ import type {
WslClaudeRootCandidate,
} from '@shared/types';
import type { AgentConfig } from '@shared/types/api';
import type { TerminalAPI } from '@shared/types/terminal';
export class HttpAPIClient implements ElectronAPI {
private baseUrl: string;
@ -877,6 +878,8 @@ export class HttpAPIClient implements ElectronAPI {
binaryPath: null,
latestVersion: null,
updateAvailable: false,
authLoggedIn: false,
authMethod: null,
}),
install: async (): Promise<void> => {
console.warn('[HttpAPIClient] CLI installer not available in browser mode');
@ -885,4 +888,19 @@ export class HttpAPIClient implements ElectronAPI {
return () => {};
},
};
// ---------------------------------------------------------------------------
// Terminal (not available in browser mode)
// ---------------------------------------------------------------------------
terminal: TerminalAPI = {
spawn: async (): Promise<string> => {
throw new Error('Terminal not available in browser mode');
},
write: () => {},
resize: () => {},
kill: () => {},
onData: (): (() => void) => () => {},
onExit: (): (() => void) => () => {},
};
}

View file

@ -7,24 +7,34 @@
* Only rendered in Electron mode.
*/
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api, isElectronMode } from '@renderer/api';
import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
import { formatBytes } from '@renderer/utils/formatters';
import { AlertTriangle, CheckCircle, Download, Loader2, RefreshCw, Terminal } from 'lucide-react';
import {
AlertTriangle,
CheckCircle,
Download,
Loader2,
LogIn,
RefreshCw,
Terminal,
} from 'lucide-react';
// =============================================================================
// Border color by state
// =============================================================================
type BannerVariant = 'loading' | 'error' | 'success' | 'info';
type BannerVariant = 'loading' | 'error' | 'success' | 'info' | 'warning';
const VARIANT_STYLES: Record<BannerVariant, { border: string; bg: string }> = {
loading: { border: 'var(--color-border)', bg: 'transparent' },
error: { border: '#ef4444', bg: 'rgba(239, 68, 68, 0.06)' },
success: { border: '#22c55e', bg: 'rgba(34, 197, 94, 0.04)' },
info: { border: '#3b82f6', bg: 'rgba(59, 130, 246, 0.04)' },
warning: { border: '#f59e0b', bg: 'rgba(245, 158, 11, 0.06)' },
};
// =============================================================================
@ -148,6 +158,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
isBusy,
} = useCliInstaller();
const [showLoginTerminal, setShowLoginTerminal] = useState(false);
useEffect(() => {
if (isElectron) {
void fetchCliStatus();
@ -171,6 +183,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
if (installerState !== 'idle') return 'info';
if (!cliStatus) return 'loading';
if (!cliStatus.installed) return 'error';
if (cliStatus.installed && !cliStatus.authLoggedIn) return 'warning';
if (cliStatus.updateAvailable) return 'info';
return 'success';
};
@ -368,6 +381,58 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
);
}
// Installed but not logged in — yellow warning banner
if (cliStatus.installed && !cliStatus.authLoggedIn) {
return (
<>
<div
className="mb-6 rounded-lg border-l-4 p-4"
style={{
borderColor: VARIANT_STYLES.warning.border,
backgroundColor: VARIANT_STYLES.warning.bg,
}}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 size-5 shrink-0" style={{ color: '#f59e0b' }} />
<div>
<p className="text-sm font-medium" style={{ color: '#fbbf24' }}>
Not logged in
</p>
<p className="mt-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
Claude CLI is installed but you are not authenticated. Login is required for team
provisioning and AI features.
</p>
</div>
</div>
<button
onClick={() => setShowLoginTerminal(true)}
className="flex shrink-0 items-center gap-1.5 rounded-md px-4 py-2 text-sm font-medium text-white transition-colors"
style={{ backgroundColor: '#f59e0b' }}
>
<LogIn className="size-4" />
Login
</button>
</div>
</div>
{showLoginTerminal && cliStatus.binaryPath && (
<TerminalModal
title="Claude Auth Login"
command={cliStatus.binaryPath}
args={['auth', 'login']}
onClose={() => {
setShowLoginTerminal(false);
void fetchCliStatus();
}}
onExit={() => {
void fetchCliStatus();
}}
/>
)}
</>
);
}
// Installed — show version, path, update info
return (
<div

View file

@ -105,128 +105,130 @@ export const TaskCommentsSection = ({
{comments.length > 0 ? (
<div className="mb-3 space-y-2">
{[...comments].reverse().map((comment) => (
<div key={comment.id} className="group p-2.5">
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
<span
className="font-medium"
style={{
color: (() => {
const rc = colorMap.get(comment.author);
return rc ? getTeamColorSet(rc).text : 'var(--color-text-secondary)';
})(),
}}
>
{comment.author}
</span>
<span>
{(() => {
const date = new Date(comment.createdAt);
return isNaN(date.getTime())
? 'unknown time'
: formatDistanceToNow(date, { addSuffix: true });
})()}
</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="ml-auto flex items-center gap-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text-secondary)] group-hover:opacity-100"
onClick={() => {
const replyText = stripAgentBlocks(
parseMessageReply(comment.text)?.replyText ?? comment.text
);
if (onReply) {
onReply(comment.author, replyText);
} else {
setReplyTo({ author: comment.author, text: replyText });
{[...comments]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.map((comment) => (
<div key={comment.id} className="group p-2.5">
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
<span
className="font-medium"
style={{
color: (() => {
const rc = colorMap.get(comment.author);
return rc ? getTeamColorSet(rc).text : 'var(--color-text-secondary)';
})(),
}}
>
{comment.author}
</span>
<span>
{(() => {
const date = new Date(comment.createdAt);
return isNaN(date.getTime())
? 'unknown time'
: formatDistanceToNow(date, { addSuffix: true });
})()}
</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="ml-auto flex items-center gap-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text-secondary)] group-hover:opacity-100"
onClick={() => {
const replyText = stripAgentBlocks(
parseMessageReply(comment.text)?.replyText ?? comment.text
);
if (onReply) {
onReply(comment.author, replyText);
} else {
setReplyTo({ author: comment.author, text: replyText });
}
}}
>
<Reply size={11} />
Reply
</button>
</TooltipTrigger>
<TooltipContent side="left">Reply to comment</TooltipContent>
</Tooltip>
</div>
{(() => {
const reply = parseMessageReply(comment.text);
const rawForDisplay = reply ? reply.replyText : comment.text;
const displayText = stripAgentBlocks(rawForDisplay);
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={
showCollapsed ? `relative ${collapsedHeight} overflow-hidden` : undefined
}
}}
>
<Reply size={11} />
Reply
</button>
</TooltipTrigger>
<TooltipContent side="left">Reply to comment</TooltipContent>
</Tooltip>
</div>
{(() => {
const reply = parseMessageReply(comment.text);
const rawForDisplay = reply ? reply.replyText : comment.text;
const displayText = stripAgentBlocks(rawForDisplay);
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={
showCollapsed ? `relative ${collapsedHeight} overflow-hidden` : undefined
}
>
{reply ? (
<ReplyQuoteBlock
reply={{
...reply,
originalText: stripAgentBlocks(reply.originalText),
replyText: stripAgentBlocks(reply.replyText),
}}
bodyMaxHeight={
needsExpandCollapse && !expanded ? 'max-h-56' : 'max-h-none'
}
/>
) : (
<MarkdownViewer
content={displayText}
maxHeight={
needsExpandCollapse && !expanded ? collapsedHeight : 'max-h-none'
}
/>
)}
{showCollapsed && (
<>
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-14"
style={{
background:
'linear-gradient(to top, var(--color-surface) 0%, transparent 100%)',
>
{reply ? (
<ReplyQuoteBlock
reply={{
...reply,
originalText: stripAgentBlocks(reply.originalText),
replyText: stripAgentBlocks(reply.replyText),
}}
aria-hidden
bodyMaxHeight={
needsExpandCollapse && !expanded ? 'max-h-56' : 'max-h-none'
}
/>
<div className="absolute inset-x-0 bottom-0 flex justify-center pt-1">
<button
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="Expand"
>
<ChevronDown size={12} />
Expand
</button>
</div>
</>
) : (
<MarkdownViewer
content={displayText}
maxHeight={
needsExpandCollapse && !expanded ? collapsedHeight : 'max-h-none'
}
/>
)}
{showCollapsed && (
<>
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-14"
style={{
background:
'linear-gradient(to top, var(--color-surface) 0%, transparent 100%)',
}}
aria-hidden
/>
<div className="absolute inset-x-0 bottom-0 flex justify-center pt-1">
<button
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="Expand"
>
<ChevronDown size={12} />
Expand
</button>
</div>
</>
)}
</div>
{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="Collapse"
>
<ChevronUp size={12} />
Collapse
</button>
</div>
)}
</div>
{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="Collapse"
>
<ChevronUp size={12} />
Collapse
</button>
</div>
)}
</div>
);
})()}
</div>
))}
);
})()}
</div>
))}
</div>
) : null}

View file

@ -0,0 +1,126 @@
import '@xterm/xterm/css/xterm.css';
import { useEffect, useRef } from 'react';
import { api } from '@renderer/api';
import { FitAddon } from '@xterm/addon-fit';
import { Terminal } from '@xterm/xterm';
import type { PtySpawnOptions } from '@shared/types/terminal';
interface EmbeddedTerminalProps {
/** Command to run (if not provided, opens default shell) */
command?: string;
/** Arguments for the command */
args?: string[];
/** Working directory */
cwd?: string;
/** Callback when PTY process exits */
onExit?: (exitCode: number) => void;
/** CSS class for container */
className?: string;
}
export const EmbeddedTerminal = ({
command,
args,
cwd,
onExit,
className,
}: EmbeddedTerminalProps): React.JSX.Element => {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
let ptyId: string | null = null;
const term = new Terminal({
cursorBlink: true,
fontSize: 13,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
theme: {
background: '#141416',
foreground: '#fafafa',
cursor: '#fafafa',
selectionBackground: 'rgba(255, 255, 255, 0.2)',
},
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(container);
// Fit after opening so dimensions are correct
requestAnimationFrame(() => fitAddon.fit());
// User input → PTY (returns IDisposable — must dispose in cleanup)
const inputDisposable = term.onData((data) => {
if (ptyId) api.terminal.write(ptyId, data);
});
// PTY output → xterm
const unsubData = api.terminal.onData((_, id, data) => {
if (id === ptyId) term.write(data);
});
// PTY exit
const unsubExit = api.terminal.onExit((_, id, exitCode) => {
if (id === ptyId) {
ptyId = null;
onExit?.(exitCode);
}
});
// Spawn PTY
const spawnOptions: PtySpawnOptions = {
...(command ? { command } : {}),
...(args ? { args } : {}),
...(cwd ? { cwd } : {}),
cols: term.cols,
rows: term.rows,
};
api.terminal
.spawn(spawnOptions)
.then((id) => {
ptyId = id;
// Send actual terminal size after spawn (fitAddon.fit() may have
// changed cols/rows via RAF after spawnOptions was constructed)
api.terminal.resize(id, term.cols, term.rows);
})
.catch((err: unknown) => {
term.write(
`\r\n\x1b[31mFailed to start terminal: ${err instanceof Error ? err.message : String(err)}\x1b[0m\r\n`
);
});
// ResizeObserver → fitAddon.fit() → pty.resize()
const observer = new ResizeObserver(() => {
fitAddon.fit();
if (ptyId) {
api.terminal.resize(ptyId, term.cols, term.rows);
}
});
observer.observe(container);
return () => {
inputDisposable.dispose();
unsubData();
unsubExit();
if (ptyId) api.terminal.kill(ptyId);
observer.disconnect();
term.dispose();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally run once on mount
}, []);
return (
<div
ref={containerRef}
className={`min-h-0 flex-1 ${className ?? ''}`}
style={{ overflow: 'hidden' }}
/>
);
};

View file

@ -0,0 +1,89 @@
import { useState } from 'react';
import ReactDOM from 'react-dom';
import { Terminal, X } from 'lucide-react';
import { EmbeddedTerminal } from './EmbeddedTerminal';
interface TerminalModalProps {
/** Modal title */
title?: string;
/** Command to run */
command?: string;
/** Arguments for the command */
args?: string[];
/** Working directory */
cwd?: string;
/** Called when the modal should close */
onClose: () => void;
/** Called when the PTY process exits */
onExit?: (exitCode: number) => void;
}
export function TerminalModal({
title = 'Terminal',
command,
args,
cwd,
onClose,
onExit,
}: TerminalModalProps): React.JSX.Element {
const [exited, setExited] = useState<number | null>(null);
const handleExit = (exitCode: number): void => {
setExited(exitCode);
onExit?.(exitCode);
};
const handleKeyDown = (e: React.KeyboardEvent): void => {
if (e.key === 'Escape') {
e.stopPropagation();
onClose();
}
};
return ReactDOM.createPortal(
// eslint-disable-next-line jsx-a11y/no-static-element-interactions -- modal backdrop
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
onKeyDown={handleKeyDown}
>
<div className="flex h-[60vh] w-full max-w-3xl flex-col overflow-hidden rounded-lg border border-border-emphasis bg-surface shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<div className="flex items-center gap-2 text-sm font-medium text-text">
<Terminal size={16} className="text-text-secondary" />
{title}
</div>
<button
onClick={onClose}
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
>
<X size={16} />
</button>
</div>
{/* Terminal area */}
<div className="relative flex min-h-0 flex-1 flex-col p-2">
{exited === null ? (
<EmbeddedTerminal command={command} args={args} cwd={cwd} onExit={handleExit} />
) : (
<div className="flex flex-1 flex-col items-center justify-center gap-3 text-text-secondary">
<p className="text-sm">
Process exited with code{' '}
<span className="font-mono font-medium text-text">{exited}</span>
</p>
<button
onClick={onClose}
className="rounded-md bg-surface-raised px-4 py-1.5 text-sm text-text transition-colors hover:bg-border-emphasis"
>
Close
</button>
</div>
)}
</div>
</div>
</div>,
document.body
);
}

View file

@ -55,6 +55,7 @@ import type {
TeamUpdateConfigRequest,
UpdateKanbanPatch,
} from './team';
import type { TerminalAPI } from './terminal';
import type { WaterfallData } from './visualization';
import type {
ConversationGroup,
@ -634,6 +635,9 @@ export interface ElectronAPI {
// CLI Installer API
cliInstaller: CliInstallerAPI;
// Embedded Terminal API (xterm.js + node-pty)
terminal: TerminalAPI;
}
// =============================================================================

View file

@ -39,6 +39,10 @@ export interface CliInstallationStatus {
latestVersion: string | null;
/** True when installed version < latest version */
updateAvailable: boolean;
/** Whether user is logged in (claude auth status) */
authLoggedIn: boolean;
/** Auth method if logged in (e.g. "oauth_token", "api_key"), null otherwise */
authMethod: string | null;
}
// =============================================================================

View file

@ -32,3 +32,6 @@ export type * from './review';
// Re-export CLI Installer types
export type * from './cliInstaller';
// Re-export Terminal types
export type * from './terminal';

View file

@ -9,6 +9,8 @@ export interface SnippetDiff {
replaceAll: boolean;
timestamp: string;
isError: boolean;
/** Hash of ±3 surrounding context lines for reliable hunk↔snippet matching */
contextHash?: string;
}
/** Агрегированные изменения по файлу */

View file

@ -62,6 +62,8 @@ export interface TaskComment {
createdAt: string;
}
// Fields are validated in TeamTaskReader.getTasks() using `satisfies Record<keyof TeamTask, unknown>`.
// Adding a field here without mapping it there will cause a compile error.
export interface TeamTask {
id: string;
subject: string;

View file

@ -0,0 +1,49 @@
/**
* Terminal types shared between main, preload, and renderer processes.
*
* Used for embedded PTY terminal (xterm.js + node-pty).
*/
// =============================================================================
// PTY Spawn Options
// =============================================================================
/**
* Options for spawning a new PTY process.
*/
export interface PtySpawnOptions {
/** Command to run (default: user's shell) */
command?: string;
/** Arguments for the command */
args?: string[];
/** Working directory */
cwd?: string;
/** Environment variables (merged with process.env) */
env?: Record<string, string>;
/** Initial terminal columns */
cols?: number;
/** Initial terminal rows */
rows?: number;
}
// =============================================================================
// Preload API
// =============================================================================
/**
* Terminal API exposed via preload bridge.
*/
export interface TerminalAPI {
/** Spawn a new PTY process. Returns unique pty ID. */
spawn: (options?: PtySpawnOptions) => Promise<string>;
/** Write data to PTY stdin (fire-and-forget). */
write: (ptyId: string, data: string) => void;
/** Resize PTY terminal (fire-and-forget). */
resize: (ptyId: string, cols: number, rows: number) => void;
/** Kill PTY process (fire-and-forget). */
kill: (ptyId: string) => void;
/** Subscribe to PTY data output. Returns cleanup function. */
onData: (cb: (event: unknown, ptyId: string, data: string) => void) => () => void;
/** Subscribe to PTY exit events. Returns cleanup function. */
onExit: (cb: (event: unknown, ptyId: string, exitCode: number) => void) => () => void;
}