11import { app , BrowserWindow , ipcMain , dialog } from "electron" ;
22import * as pty from "node-pty" ;
33import * as os from "os" ;
4+ import * as path from "path" ;
5+ import * as fs from "fs" ;
46import { simpleGit } from "simple-git" ;
57import Store from "electron-store" ;
68
@@ -10,10 +12,79 @@ interface SessionConfig {
1012 codingAgent : string ;
1113}
1214
15+ interface PersistedSession {
16+ id : string ;
17+ number : number ;
18+ name : string ;
19+ config : SessionConfig ;
20+ worktreePath : string ;
21+ createdAt : number ;
22+ }
23+
1324let mainWindow : BrowserWindow ;
14- const sessions = new Map < string , pty . IPty > ( ) ;
25+ const activePtyProcesses = new Map < string , pty . IPty > ( ) ;
1526const store = new Store ( ) ;
1627
28+ // Helper functions for session management
29+ function getPersistedSessions ( ) : PersistedSession [ ] {
30+ return ( store as any ) . get ( "sessions" , [ ] ) ;
31+ }
32+
33+ function savePersistedSessions ( sessions : PersistedSession [ ] ) {
34+ ( store as any ) . set ( "sessions" , sessions ) ;
35+ }
36+
37+ function getNextSessionNumber ( ) : number {
38+ const sessions = getPersistedSessions ( ) ;
39+ if ( sessions . length === 0 ) return 1 ;
40+ return Math . max ( ...sessions . map ( s => s . number ) ) + 1 ;
41+ }
42+
43+ // Git worktree helper functions
44+ async function createWorktree ( projectDir : string , parentBranch : string , sessionNumber : number ) : Promise < string > {
45+ const git = simpleGit ( projectDir ) ;
46+ const fleetcodeDir = path . join ( projectDir , ".fleetcode" ) ;
47+ const worktreeName = `session${ sessionNumber } ` ;
48+ const worktreePath = path . join ( fleetcodeDir , worktreeName ) ;
49+ const branchName = `fleetcode/session${ sessionNumber } ` ;
50+
51+ // Create .fleetcode directory if it doesn't exist
52+ if ( ! fs . existsSync ( fleetcodeDir ) ) {
53+ fs . mkdirSync ( fleetcodeDir , { recursive : true } ) ;
54+ }
55+
56+ // Check if worktree already exists and remove it
57+ if ( fs . existsSync ( worktreePath ) ) {
58+ try {
59+ await git . raw ( [ "worktree" , "remove" , worktreePath , "--force" ] ) ;
60+ } catch ( error ) {
61+ console . error ( "Error removing existing worktree:" , error ) ;
62+ }
63+ }
64+
65+ // Delete the branch if it exists
66+ try {
67+ await git . raw ( [ "branch" , "-D" , branchName ] ) ;
68+ } catch ( error ) {
69+ // Branch doesn't exist, that's fine
70+ }
71+
72+ // Create new worktree with a new branch from parent branch
73+ // This creates a new branch named "fleetcode/session<N>" starting from the parent branch
74+ await git . raw ( [ "worktree" , "add" , "-b" , branchName , worktreePath , parentBranch ] ) ;
75+
76+ return worktreePath ;
77+ }
78+
79+ async function removeWorktree ( projectDir : string , worktreePath : string ) {
80+ const git = simpleGit ( projectDir ) ;
81+ try {
82+ await git . raw ( [ "worktree" , "remove" , worktreePath , "--force" ] ) ;
83+ } catch ( error ) {
84+ console . error ( "Error removing worktree:" , error ) ;
85+ }
86+ }
87+
1788// Open directory picker
1889ipcMain . handle ( "select-directory" , async ( ) => {
1990 const result = await dialog . showOpenDialog ( mainWindow , {
@@ -54,90 +125,207 @@ ipcMain.on("save-settings", (_event, config: SessionConfig) => {
54125} ) ;
55126
56127// Create new session
57- ipcMain . on ( "create-session" , ( event , config : SessionConfig ) => {
58- const sessionId = `session-${ Date . now ( ) } ` ;
59- const shell = os . platform ( ) === "darwin" ? "zsh" : "bash" ;
128+ ipcMain . on ( "create-session" , async ( event , config : SessionConfig ) => {
129+ try {
130+ const sessionNumber = getNextSessionNumber ( ) ;
131+ const sessionId = `session-${ Date . now ( ) } ` ;
132+ const sessionName = `Session ${ sessionNumber } ` ;
133+
134+ // Create git worktree
135+ const worktreePath = await createWorktree ( config . projectDir , config . parentBranch , sessionNumber ) ;
136+
137+ // Create persisted session metadata
138+ const persistedSession : PersistedSession = {
139+ id : sessionId ,
140+ number : sessionNumber ,
141+ name : sessionName ,
142+ config,
143+ worktreePath,
144+ createdAt : Date . now ( ) ,
145+ } ;
146+
147+ // Save to store
148+ const sessions = getPersistedSessions ( ) ;
149+ sessions . push ( persistedSession ) ;
150+ savePersistedSessions ( sessions ) ;
151+
152+ // Spawn PTY in worktree directory
153+ const shell = os . platform ( ) === "darwin" ? "zsh" : "bash" ;
154+ const ptyProcess = pty . spawn ( shell , [ ] , {
155+ name : "xterm-color" ,
156+ cols : 80 ,
157+ rows : 30 ,
158+ cwd : worktreePath ,
159+ env : process . env ,
160+ } ) ;
161+
162+ activePtyProcesses . set ( sessionId , ptyProcess ) ;
163+
164+ let terminalReady = false ;
165+ let dataBuffer = "" ;
166+
167+ ptyProcess . onData ( ( data ) => {
168+ mainWindow . webContents . send ( "session-output" , sessionId , data ) ;
169+
170+ // Detect when terminal is ready
171+ if ( ! terminalReady ) {
172+ dataBuffer += data ;
173+
174+ // Method 1: Look for bracketed paste mode enable sequence
175+ if ( dataBuffer . includes ( "\x1b[?2004h" ) ) {
176+ terminalReady = true ;
177+
178+ // Auto-run the selected coding agent
179+ if ( config . codingAgent === "claude" ) {
180+ ptyProcess . write ( "claude\r" ) ;
181+ } else if ( config . codingAgent === "codex" ) {
182+ ptyProcess . write ( "codex\r" ) ;
183+ }
184+ }
185+
186+ // Method 2: Fallback - look for common prompt indicators
187+ else if ( dataBuffer . includes ( "$ " ) || dataBuffer . includes ( "% " ) ||
188+ dataBuffer . includes ( "> " ) || dataBuffer . includes ( "➜ " ) ||
189+ dataBuffer . includes ( "➜ " ) ||
190+ dataBuffer . includes ( "✗ " ) || dataBuffer . includes ( "✓ " ) ||
191+ dataBuffer . endsWith ( "$" ) || dataBuffer . endsWith ( "%" ) ||
192+ dataBuffer . endsWith ( ">" ) || dataBuffer . endsWith ( "➜" ) ||
193+ dataBuffer . endsWith ( "✗" ) || dataBuffer . endsWith ( "✓" ) ) {
194+ terminalReady = true ;
195+
196+ // Auto-run the selected coding agent
197+ if ( config . codingAgent === "claude" ) {
198+ ptyProcess . write ( "claude\r" ) ;
199+ } else if ( config . codingAgent === "codex" ) {
200+ ptyProcess . write ( "codex\r" ) ;
201+ }
202+ }
203+ }
204+ } ) ;
60205
206+ event . reply ( "session-created" , sessionId , persistedSession ) ;
207+ } catch ( error ) {
208+ console . error ( "Error creating session:" , error ) ;
209+ const errorMessage = error instanceof Error ? error . message : String ( error ) ;
210+ event . reply ( "session-error" , errorMessage ) ;
211+ }
212+ } ) ;
213+
214+ // Handle session input
215+ ipcMain . on ( "session-input" , ( _event , sessionId : string , data : string ) => {
216+ const ptyProcess = activePtyProcesses . get ( sessionId ) ;
217+ if ( ptyProcess ) {
218+ ptyProcess . write ( data ) ;
219+ }
220+ } ) ;
221+
222+ // Handle session resize
223+ ipcMain . on ( "session-resize" , ( _event , sessionId : string , cols : number , rows : number ) => {
224+ const ptyProcess = activePtyProcesses . get ( sessionId ) ;
225+ if ( ptyProcess ) {
226+ ptyProcess . resize ( cols , rows ) ;
227+ }
228+ } ) ;
229+
230+ // Reopen session (spawn new PTY for existing session)
231+ ipcMain . on ( "reopen-session" , ( event , sessionId : string ) => {
232+ // Check if PTY already active
233+ if ( activePtyProcesses . has ( sessionId ) ) {
234+ event . reply ( "session-reopened" , sessionId ) ;
235+ return ;
236+ }
237+
238+ // Find persisted session
239+ const sessions = getPersistedSessions ( ) ;
240+ const session = sessions . find ( s => s . id === sessionId ) ;
241+
242+ if ( ! session ) {
243+ console . error ( "Session not found:" , sessionId ) ;
244+ return ;
245+ }
246+
247+ // Spawn new PTY in worktree directory
248+ const shell = os . platform ( ) === "darwin" ? "zsh" : "bash" ;
61249 const ptyProcess = pty . spawn ( shell , [ ] , {
62250 name : "xterm-color" ,
63251 cols : 80 ,
64252 rows : 30 ,
65- cwd : config . projectDir || process . env . HOME ,
253+ cwd : session . worktreePath ,
66254 env : process . env ,
67255 } ) ;
68256
69- sessions . set ( sessionId , ptyProcess ) ;
257+ activePtyProcesses . set ( sessionId , ptyProcess ) ;
70258
71259 let terminalReady = false ;
72260 let dataBuffer = "" ;
73261
74262 ptyProcess . onData ( ( data ) => {
75263 mainWindow . webContents . send ( "session-output" , sessionId , data ) ;
76264
77- // Detect when terminal is ready
265+ // Detect when terminal is ready and auto-run agent
78266 if ( ! terminalReady ) {
79267 dataBuffer += data ;
80268
81- // Method 1: Look for bracketed paste mode enable sequence
82- // This is sent by modern shells (zsh, bash) when ready for input
83- if ( dataBuffer . includes ( "\x1b[?2004h" ) ) {
269+ if ( dataBuffer . includes ( "\x1b[?2004h" ) ||
270+ dataBuffer . includes ( "$ " ) || dataBuffer . includes ( "% " ) ||
271+ dataBuffer . includes ( "➜ " ) || dataBuffer . includes ( "✗ " ) ||
272+ dataBuffer . includes ( "✓ " ) ) {
84273 terminalReady = true ;
85274
86- // Auto-run the selected coding agent
87- if ( config . codingAgent === "claude" ) {
275+ if ( session . config . codingAgent === "claude" ) {
88276 ptyProcess . write ( "claude\r" ) ;
89- } else if ( config . codingAgent === "codex" ) {
90- ptyProcess . write ( "codex\r" ) ;
91- }
92- }
93-
94- // Method 2: Fallback - look for common prompt indicators
95- // In case bracketed paste mode is disabled
96- else if ( dataBuffer . includes ( "$ " ) || dataBuffer . includes ( "% " ) ||
97- dataBuffer . includes ( "> " ) || dataBuffer . includes ( "➜ " ) ||
98- dataBuffer . includes ( "➜ " ) ||
99- dataBuffer . includes ( "✗ " ) || dataBuffer . includes ( "✓ " ) ||
100- dataBuffer . endsWith ( "$" ) || dataBuffer . endsWith ( "%" ) ||
101- dataBuffer . endsWith ( ">" ) || dataBuffer . endsWith ( "➜" ) ||
102- dataBuffer . endsWith ( "✗" ) || dataBuffer . endsWith ( "✓" ) ) {
103- terminalReady = true ;
104-
105- // Auto-run the selected coding agent
106- if ( config . codingAgent === "claude" ) {
107- ptyProcess . write ( "claude\r" ) ;
108- } else if ( config . codingAgent === "codex" ) {
277+ } else if ( session . config . codingAgent === "codex" ) {
109278 ptyProcess . write ( "codex\r" ) ;
110279 }
111280 }
112281 }
113282 } ) ;
114283
115- event . reply ( "session-created " , sessionId , config ) ;
284+ event . reply ( "session-reopened " , sessionId ) ;
116285} ) ;
117286
118- // Handle session input
119- ipcMain . on ( "session-input" , ( _event , sessionId : string , data : string ) => {
120- const session = sessions . get ( sessionId ) ;
121- if ( session ) {
122- session . write ( data ) ;
287+ // Close session (kill PTY but keep session)
288+ ipcMain . on ( "close-session" , ( _event , sessionId : string ) => {
289+ const ptyProcess = activePtyProcesses . get ( sessionId ) ;
290+ if ( ptyProcess ) {
291+ ptyProcess . kill ( ) ;
292+ activePtyProcesses . delete ( sessionId ) ;
123293 }
124294} ) ;
125295
126- // Handle session resize
127- ipcMain . on ( "session-resize" , ( _event , sessionId : string , cols : number , rows : number ) => {
128- const session = sessions . get ( sessionId ) ;
129- if ( session ) {
130- session . resize ( cols , rows ) ;
296+ // Delete session (kill PTY, remove worktree, delete from store)
297+ ipcMain . on ( "delete-session" , async ( _event , sessionId : string ) => {
298+ // Kill PTY if active
299+ const ptyProcess = activePtyProcesses . get ( sessionId ) ;
300+ if ( ptyProcess ) {
301+ ptyProcess . kill ( ) ;
302+ activePtyProcesses . delete ( sessionId ) ;
131303 }
132- } ) ;
133304
134- // Handle session close
135- ipcMain . on ( "close-session" , ( _event , sessionId : string ) => {
136- const session = sessions . get ( sessionId ) ;
137- if ( session ) {
138- session . kill ( ) ;
139- sessions . delete ( sessionId ) ;
305+ // Find and remove from persisted sessions
306+ const sessions = getPersistedSessions ( ) ;
307+ const sessionIndex = sessions . findIndex ( s => s . id === sessionId ) ;
308+
309+ if ( sessionIndex === - 1 ) {
310+ console . error ( "Session not found:" , sessionId ) ;
311+ return ;
140312 }
313+
314+ const session = sessions [ sessionIndex ] ;
315+
316+ // Remove git worktree
317+ await removeWorktree ( session . config . projectDir , session . worktreePath ) ;
318+
319+ // Remove from store
320+ sessions . splice ( sessionIndex , 1 ) ;
321+ savePersistedSessions ( sessions ) ;
322+
323+ mainWindow . webContents . send ( "session-deleted" , sessionId ) ;
324+ } ) ;
325+
326+ // Get all persisted sessions
327+ ipcMain . handle ( "get-all-sessions" , ( ) => {
328+ return getPersistedSessions ( ) ;
141329} ) ;
142330
143331const createWindow = ( ) => {
@@ -151,6 +339,12 @@ const createWindow = () => {
151339 } ) ;
152340
153341 mainWindow . loadFile ( "index.html" ) ;
342+
343+ // Load persisted sessions once window is ready
344+ mainWindow . webContents . on ( "did-finish-load" , ( ) => {
345+ const sessions = getPersistedSessions ( ) ;
346+ mainWindow . webContents . send ( "load-persisted-sessions" , sessions ) ;
347+ } ) ;
154348} ;
155349
156350app . whenReady ( ) . then ( ( ) => {
0 commit comments