hjworktree-cli 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/.context-snapshots/context-snapshot-20260106-110353.md +66 -0
  2. package/.context-snapshots/context-snapshot-20260106-110441.md +66 -0
  3. package/.context-snapshots/context-snapshot-20260106-220000.md +99 -0
  4. package/AGENTS.md +29 -0
  5. package/CLAUDE.md +88 -0
  6. package/bin/cli.js +85 -0
  7. package/dist/server/index.d.ts +6 -0
  8. package/dist/server/index.d.ts.map +1 -0
  9. package/dist/server/index.js +64 -0
  10. package/dist/server/index.js.map +1 -0
  11. package/dist/server/routes/api.d.ts +3 -0
  12. package/dist/server/routes/api.d.ts.map +1 -0
  13. package/dist/server/routes/api.js +101 -0
  14. package/dist/server/routes/api.js.map +1 -0
  15. package/dist/server/services/gitService.d.ts +13 -0
  16. package/dist/server/services/gitService.d.ts.map +1 -0
  17. package/dist/server/services/gitService.js +84 -0
  18. package/dist/server/services/gitService.js.map +1 -0
  19. package/dist/server/services/worktreeService.d.ts +17 -0
  20. package/dist/server/services/worktreeService.d.ts.map +1 -0
  21. package/dist/server/services/worktreeService.js +161 -0
  22. package/dist/server/services/worktreeService.js.map +1 -0
  23. package/dist/server/socketHandlers.d.ts +4 -0
  24. package/dist/server/socketHandlers.d.ts.map +1 -0
  25. package/dist/server/socketHandlers.js +118 -0
  26. package/dist/server/socketHandlers.js.map +1 -0
  27. package/dist/shared/constants.d.ts +10 -0
  28. package/dist/shared/constants.d.ts.map +1 -0
  29. package/dist/shared/constants.js +31 -0
  30. package/dist/shared/constants.js.map +1 -0
  31. package/dist/shared/types/index.d.ts +67 -0
  32. package/dist/shared/types/index.d.ts.map +1 -0
  33. package/dist/shared/types/index.js +3 -0
  34. package/dist/shared/types/index.js.map +1 -0
  35. package/dist/web/assets/index-C61yAbey.css +32 -0
  36. package/dist/web/assets/index-WEdVUKxb.js +53 -0
  37. package/dist/web/assets/index-WEdVUKxb.js.map +1 -0
  38. package/dist/web/index.html +16 -0
  39. package/package.json +63 -0
  40. package/server/index.ts +75 -0
  41. package/server/routes/api.ts +108 -0
  42. package/server/services/gitService.ts +91 -0
  43. package/server/services/worktreeService.ts +181 -0
  44. package/server/socketHandlers.ts +157 -0
  45. package/shared/constants.ts +35 -0
  46. package/shared/types/index.ts +92 -0
  47. package/tsconfig.json +20 -0
  48. package/web/index.html +15 -0
  49. package/web/src/App.tsx +65 -0
  50. package/web/src/components/Layout/Header.tsx +29 -0
  51. package/web/src/components/Layout/LeftNavBar.tsx +67 -0
  52. package/web/src/components/Layout/MainLayout.tsx +23 -0
  53. package/web/src/components/Layout/StepContainer.tsx +71 -0
  54. package/web/src/components/Setup/AgentSelector.tsx +27 -0
  55. package/web/src/components/Setup/BranchSelector.tsx +28 -0
  56. package/web/src/components/Setup/SetupPanel.tsx +32 -0
  57. package/web/src/components/Setup/WorktreeCountSelector.tsx +30 -0
  58. package/web/src/components/Steps/AgentStep.tsx +20 -0
  59. package/web/src/components/Steps/BranchStep.tsx +20 -0
  60. package/web/src/components/Steps/WorktreeStep.tsx +41 -0
  61. package/web/src/components/Terminal/TerminalPanel.tsx +113 -0
  62. package/web/src/components/Terminal/XTerminal.tsx +203 -0
  63. package/web/src/hooks/useSocket.ts +80 -0
  64. package/web/src/main.tsx +10 -0
  65. package/web/src/stores/useAppStore.ts +348 -0
  66. package/web/src/styles/global.css +695 -0
  67. package/web/tsconfig.json +23 -0
  68. package/web/vite.config.ts +32 -0
@@ -0,0 +1,16 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>hjWorktree CLI</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
10
+ <script type="module" crossorigin src="/assets/index-WEdVUKxb.js"></script>
11
+ <link rel="stylesheet" crossorigin href="/assets/index-C61yAbey.css">
12
+ </head>
13
+ <body>
14
+ <div id="root"></div>
15
+ </body>
16
+ </html>
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "hjworktree-cli",
3
+ "version": "2.0.0",
4
+ "description": "Web-based git worktree parallel AI coding agent runner",
5
+ "type": "module",
6
+ "main": "dist/server/index.js",
7
+ "bin": {
8
+ "hjWorktree": "./bin/cli.js"
9
+ },
10
+ "scripts": {
11
+ "dev": "concurrently \"npm run dev:server\" \"npm run dev:web\"",
12
+ "dev:server": "tsx watch server/index.ts",
13
+ "dev:web": "vite --config web/vite.config.ts",
14
+ "build": "npm run build:server && npm run build:web",
15
+ "build:server": "tsc",
16
+ "build:web": "vite build --config web/vite.config.ts",
17
+ "start": "node dist/server/index.js",
18
+ "prepublishOnly": "npm run build"
19
+ },
20
+ "keywords": [
21
+ "cli",
22
+ "git",
23
+ "worktree",
24
+ "ai",
25
+ "codex",
26
+ "claude",
27
+ "gemini",
28
+ "parallel",
29
+ "terminal",
30
+ "web"
31
+ ],
32
+ "author": "hyungju-lee <beegizee1220@gmail.com>",
33
+ "license": "MIT",
34
+ "engines": {
35
+ "node": ">=20.0.0"
36
+ },
37
+ "dependencies": {
38
+ "cors": "^2.8.5",
39
+ "express": "^4.21.0",
40
+ "node-pty": "^1.0.0",
41
+ "open": "^10.1.0",
42
+ "simple-git": "^3.27.0",
43
+ "socket.io": "^4.7.5"
44
+ },
45
+ "devDependencies": {
46
+ "@types/cors": "^2.8.17",
47
+ "@types/express": "^4.17.21",
48
+ "@types/node": "^22.10.5",
49
+ "@types/react": "^18.3.18",
50
+ "@types/react-dom": "^18.3.5",
51
+ "@vitejs/plugin-react": "^4.3.4",
52
+ "@xterm/addon-fit": "^0.10.0",
53
+ "@xterm/xterm": "^5.5.0",
54
+ "concurrently": "^9.1.0",
55
+ "react": "^18.3.1",
56
+ "react-dom": "^18.3.1",
57
+ "socket.io-client": "^4.7.5",
58
+ "tsx": "^4.19.2",
59
+ "typescript": "^5.7.3",
60
+ "vite": "^6.0.7",
61
+ "zustand": "^5.0.2"
62
+ }
63
+ }
@@ -0,0 +1,75 @@
1
+ import express from 'express';
2
+ import { createServer } from 'http';
3
+ import { Server } from 'socket.io';
4
+ import path from 'path';
5
+ import { fileURLToPath } from 'url';
6
+ import cors from 'cors';
7
+ import { setupSocketHandlers, killAllSessions } from './socketHandlers.js';
8
+ import { apiRouter } from './routes/api.js';
9
+ import { DEFAULT_PORT, APP_NAME, APP_VERSION } from '../shared/constants.js';
10
+
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = path.dirname(__filename);
13
+
14
+ const app = express();
15
+ const httpServer = createServer(app);
16
+ const io = new Server(httpServer, {
17
+ cors: {
18
+ origin: '*',
19
+ methods: ['GET', 'POST']
20
+ }
21
+ });
22
+
23
+ const PORT = parseInt(process.env.PORT || String(DEFAULT_PORT), 10);
24
+ const CWD = process.env.CWD || process.cwd();
25
+
26
+ // Middleware
27
+ app.use(cors());
28
+ app.use(express.json());
29
+
30
+ // Serve static files from the web build directory
31
+ const webDir = path.join(__dirname, '../web');
32
+ app.use(express.static(webDir));
33
+
34
+ // API routes
35
+ app.use('/api', apiRouter(CWD));
36
+
37
+ // Socket.IO handlers
38
+ setupSocketHandlers(io, CWD);
39
+
40
+ // Fallback to index.html for SPA routing
41
+ app.get('*', (req, res) => {
42
+ res.sendFile(path.join(webDir, 'index.html'));
43
+ });
44
+
45
+ // Graceful shutdown
46
+ process.on('SIGINT', () => {
47
+ console.log('\nShutting down...');
48
+ killAllSessions();
49
+ process.exit(0);
50
+ });
51
+
52
+ process.on('SIGTERM', () => {
53
+ console.log('\nShutting down...');
54
+ killAllSessions();
55
+ process.exit(0);
56
+ });
57
+
58
+ // Start server
59
+ httpServer.listen(PORT, () => {
60
+ console.log(`
61
+ ╔════════════════════════════════════════════╗
62
+ ║ ║
63
+ ║ ${APP_NAME} v${APP_VERSION} ║
64
+ ║ ║
65
+ ║ Server running at: ║
66
+ ║ http://localhost:${PORT} ║
67
+ ║ ║
68
+ ║ Working directory: ║
69
+ ║ ${CWD.substring(0, 38)}${CWD.length > 38 ? '...' : ''}
70
+ ║ ║
71
+ ╚════════════════════════════════════════════╝
72
+ `);
73
+ });
74
+
75
+ export { app, httpServer, io };
@@ -0,0 +1,108 @@
1
+ import { Router, Request, Response } from 'express';
2
+ import { GitService } from '../services/gitService.js';
3
+ import { WorktreeService } from '../services/worktreeService.js';
4
+ import type { CreateWorktreesRequest } from '../../shared/types/index.js';
5
+
6
+ export function apiRouter(cwd: string): Router {
7
+ const router = Router();
8
+ const gitService = new GitService(cwd);
9
+ const worktreeService = new WorktreeService(cwd);
10
+
11
+ // Get project info
12
+ router.get('/info', async (req: Request, res: Response) => {
13
+ try {
14
+ const isGitRepo = await gitService.isGitRepository();
15
+ const currentBranch = isGitRepo ? await gitService.getCurrentBranch() : null;
16
+
17
+ res.json({
18
+ cwd,
19
+ isGitRepository: isGitRepo,
20
+ currentBranch,
21
+ });
22
+ } catch (error) {
23
+ res.status(500).json({
24
+ error: error instanceof Error ? error.message : 'Unknown error'
25
+ });
26
+ }
27
+ });
28
+
29
+ // Get branches
30
+ router.get('/branches', async (req: Request, res: Response) => {
31
+ try {
32
+ // Fetch latest from remote first
33
+ await gitService.fetch();
34
+ const branches = await gitService.getBranches();
35
+ res.json(branches);
36
+ } catch (error) {
37
+ res.status(500).json({
38
+ error: error instanceof Error ? error.message : 'Unknown error'
39
+ });
40
+ }
41
+ });
42
+
43
+ // Get worktrees
44
+ router.get('/worktrees', async (req: Request, res: Response) => {
45
+ try {
46
+ const worktrees = await worktreeService.listWorktrees();
47
+ res.json(worktrees);
48
+ } catch (error) {
49
+ res.status(500).json({
50
+ error: error instanceof Error ? error.message : 'Unknown error'
51
+ });
52
+ }
53
+ });
54
+
55
+ // Create worktrees
56
+ router.post('/worktrees', async (req: Request, res: Response) => {
57
+ try {
58
+ const { branch, count } = req.body as CreateWorktreesRequest;
59
+
60
+ if (!branch || !count || count < 1) {
61
+ res.status(400).json({ error: 'Invalid request: branch and count are required' });
62
+ return;
63
+ }
64
+
65
+ const worktrees = await worktreeService.createMultipleWorktrees(branch, count);
66
+ res.json({ worktrees });
67
+ } catch (error) {
68
+ res.status(500).json({
69
+ error: error instanceof Error ? error.message : 'Unknown error'
70
+ });
71
+ }
72
+ });
73
+
74
+ // Delete a worktree
75
+ router.delete('/worktrees/:name', async (req: Request, res: Response) => {
76
+ try {
77
+ const { name } = req.params;
78
+ const worktrees = await worktreeService.listWorktrees();
79
+ const worktree = worktrees.find(wt => wt.name === name);
80
+
81
+ if (!worktree) {
82
+ res.status(404).json({ error: 'Worktree not found' });
83
+ return;
84
+ }
85
+
86
+ await worktreeService.removeWorktree(worktree.path);
87
+ res.json({ success: true });
88
+ } catch (error) {
89
+ res.status(500).json({
90
+ error: error instanceof Error ? error.message : 'Unknown error'
91
+ });
92
+ }
93
+ });
94
+
95
+ // Delete all worktrees
96
+ router.delete('/worktrees', async (req: Request, res: Response) => {
97
+ try {
98
+ await worktreeService.removeAllWorktrees();
99
+ res.json({ success: true });
100
+ } catch (error) {
101
+ res.status(500).json({
102
+ error: error instanceof Error ? error.message : 'Unknown error'
103
+ });
104
+ }
105
+ });
106
+
107
+ return router;
108
+ }
@@ -0,0 +1,91 @@
1
+ import { simpleGit, SimpleGit } from 'simple-git';
2
+ import type { Branch } from '../../shared/types/index.js';
3
+ import { BRANCH_POLL_INTERVAL } from '../../shared/constants.js';
4
+
5
+ export class GitService {
6
+ private git: SimpleGit;
7
+ private cwd: string;
8
+
9
+ constructor(cwd: string = process.cwd()) {
10
+ this.cwd = cwd;
11
+ this.git = simpleGit(cwd);
12
+ }
13
+
14
+ async isGitRepository(): Promise<boolean> {
15
+ try {
16
+ await this.git.status();
17
+ return true;
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+
23
+ async getBranches(): Promise<Branch[]> {
24
+ const branchSummary = await this.git.branch(['-a']);
25
+ const branches: Branch[] = [];
26
+ const localBranchNames = new Set<string>();
27
+
28
+ // First pass: collect local branches
29
+ for (const [name, data] of Object.entries(branchSummary.branches)) {
30
+ if (!name.startsWith('remotes/')) {
31
+ localBranchNames.add(name);
32
+ branches.push({
33
+ name,
34
+ isCurrent: data.current,
35
+ isRemote: false,
36
+ });
37
+ }
38
+ }
39
+
40
+ // Second pass: add remote branches that don't have local counterparts
41
+ for (const [name] of Object.entries(branchSummary.branches)) {
42
+ if (name.startsWith('remotes/origin/')) {
43
+ const localName = name.replace('remotes/origin/', '');
44
+ // Skip HEAD reference and branches that exist locally
45
+ if (localName !== 'HEAD' && !localBranchNames.has(localName)) {
46
+ branches.push({
47
+ name: localName,
48
+ isCurrent: false,
49
+ isRemote: true,
50
+ });
51
+ }
52
+ }
53
+ }
54
+
55
+ // Sort: current first, then local, then remote, alphabetically within each group
56
+ return branches.sort((a, b) => {
57
+ if (a.isCurrent) return -1;
58
+ if (b.isCurrent) return 1;
59
+ if (!a.isRemote && b.isRemote) return -1;
60
+ if (a.isRemote && !b.isRemote) return 1;
61
+ return a.name.localeCompare(b.name);
62
+ });
63
+ }
64
+
65
+ async getCurrentBranch(): Promise<string> {
66
+ const status = await this.git.status();
67
+ return status.current || 'HEAD';
68
+ }
69
+
70
+ async fetch(): Promise<void> {
71
+ try {
72
+ await this.git.fetch(['--prune']);
73
+ } catch {
74
+ // Silently fail fetch - might not have remote configured
75
+ }
76
+ }
77
+
78
+ getCwd(): string {
79
+ return this.cwd;
80
+ }
81
+ }
82
+
83
+ // Singleton instance
84
+ let gitServiceInstance: GitService | null = null;
85
+
86
+ export function getGitService(cwd?: string): GitService {
87
+ if (!gitServiceInstance || (cwd && gitServiceInstance.getCwd() !== cwd)) {
88
+ gitServiceInstance = new GitService(cwd);
89
+ }
90
+ return gitServiceInstance;
91
+ }
@@ -0,0 +1,181 @@
1
+ import { simpleGit, SimpleGit } from 'simple-git';
2
+ import path from 'path';
3
+ import fs from 'fs/promises';
4
+ import type { Worktree } from '../../shared/types/index.js';
5
+
6
+ export class WorktreeService {
7
+ private git: SimpleGit;
8
+ private rootDir: string;
9
+ private createdWorktrees: Set<string> = new Set();
10
+
11
+ constructor(cwd: string = process.cwd()) {
12
+ this.rootDir = cwd;
13
+ this.git = simpleGit(cwd);
14
+ }
15
+
16
+ async listWorktrees(): Promise<Worktree[]> {
17
+ const result = await this.git.raw(['worktree', 'list', '--porcelain']);
18
+ const worktrees: Worktree[] = [];
19
+
20
+ const entries = result.trim().split('\n\n').filter(Boolean);
21
+
22
+ for (const entry of entries) {
23
+ const lines = entry.split('\n');
24
+ const worktreePath = lines.find((l) => l.startsWith('worktree '))?.replace('worktree ', '');
25
+ const branchLine = lines.find((l) => l.startsWith('branch '));
26
+ const branch = branchLine?.replace('branch refs/heads/', '');
27
+
28
+ if (worktreePath) {
29
+ worktrees.push({
30
+ path: worktreePath,
31
+ branch: branch || 'detached',
32
+ name: path.basename(worktreePath),
33
+ });
34
+ }
35
+ }
36
+
37
+ return worktrees;
38
+ }
39
+
40
+ async createWorktree(baseBranch: string, index: number): Promise<Worktree> {
41
+ const worktreeName = `${baseBranch}-project-${index}`;
42
+ // Create worktree in parent directory of the main repo
43
+ const worktreePath = path.join(path.dirname(this.rootDir), worktreeName);
44
+ const newBranchName = worktreeName;
45
+
46
+ // Check if directory already exists
47
+ try {
48
+ await fs.access(worktreePath);
49
+ // Directory exists, try to remove it first
50
+ await this.removeWorktree(worktreePath);
51
+ } catch {
52
+ // Directory doesn't exist, which is good
53
+ }
54
+
55
+ // Check if branch already exists
56
+ try {
57
+ const branches = await this.git.branch();
58
+ if (branches.all.includes(newBranchName)) {
59
+ // Branch exists, delete it first
60
+ await this.git.branch(['-D', newBranchName]);
61
+ }
62
+ } catch {
63
+ // Branch doesn't exist, which is good
64
+ }
65
+
66
+ // Create worktree with new branch based on the selected branch
67
+ await this.git.raw(['worktree', 'add', '-b', newBranchName, worktreePath, baseBranch]);
68
+
69
+ this.createdWorktrees.add(worktreePath);
70
+
71
+ return {
72
+ path: worktreePath,
73
+ branch: newBranchName,
74
+ name: worktreeName,
75
+ };
76
+ }
77
+
78
+ async createMultipleWorktrees(baseBranch: string, count: number): Promise<Worktree[]> {
79
+ const worktrees: Worktree[] = [];
80
+ const createdPaths: string[] = [];
81
+
82
+ try {
83
+ for (let i = 1; i <= count; i++) {
84
+ const worktree = await this.createWorktree(baseBranch, i);
85
+ worktrees.push(worktree);
86
+ createdPaths.push(worktree.path);
87
+ }
88
+ return worktrees;
89
+ } catch (error) {
90
+ // Rollback: remove already created worktrees
91
+ console.error(`Failed to create worktree ${createdPaths.length + 1}, rolling back...`);
92
+
93
+ for (const worktreePath of createdPaths) {
94
+ try {
95
+ await this.removeWorktree(worktreePath);
96
+ } catch (rollbackError) {
97
+ console.error(`Failed to rollback worktree: ${worktreePath}`, rollbackError);
98
+ }
99
+ }
100
+
101
+ throw error;
102
+ }
103
+ }
104
+
105
+ async removeWorktree(worktreePath: string): Promise<void> {
106
+ const branchName = path.basename(worktreePath);
107
+
108
+ try {
109
+ // 1. Force remove worktree
110
+ await this.git.raw(['worktree', 'remove', worktreePath, '--force']);
111
+ } catch {
112
+ // 2. If worktree remove fails, try to remove directory manually
113
+ try {
114
+ await fs.rm(worktreePath, { recursive: true, force: true });
115
+ } catch {
116
+ // Ignore errors
117
+ }
118
+ }
119
+
120
+ // 3. CRITICAL: Prune orphan worktree references
121
+ try {
122
+ await this.git.raw(['worktree', 'prune']);
123
+ } catch {
124
+ // Ignore prune errors
125
+ }
126
+
127
+ // 4. Now we can safely delete the branch
128
+ try {
129
+ await this.git.branch(['-D', branchName]);
130
+ } catch {
131
+ // Branch deletion failure is acceptable
132
+ }
133
+
134
+ this.createdWorktrees.delete(worktreePath);
135
+ }
136
+
137
+ async removeAllWorktrees(): Promise<void> {
138
+ const worktrees = await this.listWorktrees();
139
+
140
+ // Filter out the main worktree (usually the first one / the main repo)
141
+ const additionalWorktrees = worktrees.filter(wt => wt.path !== this.rootDir);
142
+
143
+ for (const worktree of additionalWorktrees) {
144
+ try {
145
+ await this.removeWorktree(worktree.path);
146
+ } catch (error) {
147
+ console.error(`Failed to remove worktree: ${worktree.path}`, error);
148
+ }
149
+ }
150
+ }
151
+
152
+ async cleanup(): Promise<void> {
153
+ // Remove all worktrees created in this session
154
+ for (const worktreePath of this.createdWorktrees) {
155
+ try {
156
+ await this.removeWorktree(worktreePath);
157
+ } catch (error) {
158
+ console.error(`Failed to cleanup worktree: ${worktreePath}`, error);
159
+ }
160
+ }
161
+ this.createdWorktrees.clear();
162
+ }
163
+
164
+ getCreatedWorktrees(): string[] {
165
+ return Array.from(this.createdWorktrees);
166
+ }
167
+
168
+ getRootDir(): string {
169
+ return this.rootDir;
170
+ }
171
+ }
172
+
173
+ // Singleton instance
174
+ let worktreeServiceInstance: WorktreeService | null = null;
175
+
176
+ export function getWorktreeService(cwd?: string): WorktreeService {
177
+ if (!worktreeServiceInstance || (cwd && worktreeServiceInstance.getRootDir() !== cwd)) {
178
+ worktreeServiceInstance = new WorktreeService(cwd);
179
+ }
180
+ return worktreeServiceInstance;
181
+ }
@@ -0,0 +1,157 @@
1
+ import { Server, Socket } from 'socket.io';
2
+ import * as pty from 'node-pty';
3
+ import type { IPty } from 'node-pty';
4
+ import type {
5
+ TerminalCreateData,
6
+ TerminalInputData,
7
+ TerminalResizeData,
8
+ TerminalKillData,
9
+ AgentId
10
+ } from '../shared/types/index.js';
11
+ import { AI_AGENTS } from '../shared/constants.js';
12
+
13
+ interface TerminalSession {
14
+ pty: IPty;
15
+ worktreePath: string;
16
+ agentType: AgentId;
17
+ socketId: string;
18
+ }
19
+
20
+ const sessions = new Map<string, TerminalSession>();
21
+
22
+ function getAgentCommand(agentType: AgentId): string {
23
+ const agent = AI_AGENTS.find(a => a.id === agentType);
24
+ return agent?.command || 'bash';
25
+ }
26
+
27
+ function getShell(): string {
28
+ return process.platform === 'win32' ? 'powershell.exe' : process.env.SHELL || '/bin/bash';
29
+ }
30
+
31
+ export function setupSocketHandlers(io: Server, cwd: string) {
32
+ io.on('connection', (socket: Socket) => {
33
+ console.log(`Client connected: ${socket.id}`);
34
+
35
+ // Terminal create
36
+ socket.on('terminal:create', async (data: TerminalCreateData) => {
37
+ const { sessionId, worktreePath, agentType } = data;
38
+
39
+ console.log(`Creating terminal session: ${sessionId} at ${worktreePath} with ${agentType}`);
40
+
41
+ try {
42
+ const shell = getShell();
43
+ const agentCommand = getAgentCommand(agentType);
44
+
45
+ const ptyProcess = pty.spawn(shell, [], {
46
+ name: 'xterm-256color',
47
+ cols: 120,
48
+ rows: 30,
49
+ cwd: worktreePath,
50
+ env: {
51
+ ...process.env,
52
+ TERM: 'xterm-256color',
53
+ FORCE_COLOR: '1',
54
+ COLORTERM: 'truecolor',
55
+ } as Record<string, string>,
56
+ });
57
+
58
+ sessions.set(sessionId, {
59
+ pty: ptyProcess,
60
+ worktreePath,
61
+ agentType,
62
+ socketId: socket.id,
63
+ });
64
+
65
+ // Send output to client
66
+ ptyProcess.onData((output: string) => {
67
+ socket.emit('terminal:output', { sessionId, data: output });
68
+ });
69
+
70
+ // Handle PTY exit
71
+ ptyProcess.onExit(({ exitCode }) => {
72
+ console.log(`Terminal session ${sessionId} exited with code ${exitCode}`);
73
+ socket.emit('terminal:exit', { sessionId, exitCode });
74
+ sessions.delete(sessionId);
75
+ });
76
+
77
+ // Start the AI agent after a short delay
78
+ setTimeout(() => {
79
+ ptyProcess.write(`${agentCommand}\r`);
80
+ }, 500);
81
+
82
+ socket.emit('terminal:created', { sessionId });
83
+ } catch (error) {
84
+ console.error(`Failed to create terminal session: ${sessionId}`, error);
85
+ socket.emit('terminal:error', {
86
+ sessionId,
87
+ error: error instanceof Error ? error.message : 'Unknown error'
88
+ });
89
+ }
90
+ });
91
+
92
+ // Terminal input
93
+ socket.on('terminal:input', (data: TerminalInputData) => {
94
+ const { sessionId, data: input } = data;
95
+ const session = sessions.get(sessionId);
96
+
97
+ if (session) {
98
+ session.pty.write(input);
99
+ }
100
+ });
101
+
102
+ // Terminal resize
103
+ socket.on('terminal:resize', (data: TerminalResizeData) => {
104
+ const { sessionId, cols, rows } = data;
105
+ const session = sessions.get(sessionId);
106
+
107
+ if (session) {
108
+ session.pty.resize(cols, rows);
109
+ }
110
+ });
111
+
112
+ // Terminal kill
113
+ socket.on('terminal:kill', (data: TerminalKillData) => {
114
+ const { sessionId } = data;
115
+ const session = sessions.get(sessionId);
116
+
117
+ if (session) {
118
+ console.log(`Killing terminal session: ${sessionId}`);
119
+ session.pty.kill();
120
+ sessions.delete(sessionId);
121
+ }
122
+ });
123
+
124
+ // Broadcast input to all terminals
125
+ socket.on('terminal:broadcast', (data: { data: string }) => {
126
+ const socketSessions = Array.from(sessions.entries())
127
+ .filter(([, session]) => session.socketId === socket.id);
128
+
129
+ for (const [sessionId, session] of socketSessions) {
130
+ session.pty.write(data.data);
131
+ }
132
+ });
133
+
134
+ // Disconnect handling
135
+ socket.on('disconnect', () => {
136
+ console.log(`Client disconnected: ${socket.id}`);
137
+
138
+ // Kill all sessions owned by this socket
139
+ const socketSessions = Array.from(sessions.entries())
140
+ .filter(([, session]) => session.socketId === socket.id);
141
+
142
+ for (const [sessionId, session] of socketSessions) {
143
+ console.log(`Killing orphaned terminal session: ${sessionId}`);
144
+ session.pty.kill();
145
+ sessions.delete(sessionId);
146
+ }
147
+ });
148
+ });
149
+ }
150
+
151
+ export function killAllSessions(): void {
152
+ for (const [sessionId, session] of sessions) {
153
+ console.log(`Killing terminal session: ${sessionId}`);
154
+ session.pty.kill();
155
+ }
156
+ sessions.clear();
157
+ }