code-squad-cli 1.2.16 → 1.2.19

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.
@@ -1,11 +1,45 @@
1
- export interface AppState {
2
- cwd: string;
3
- resolve: ((output: string | null) => void | Promise<void>) | null;
1
+ import { SessionManager } from '../session/SessionManager.js';
2
+ /** Server identification string for ping endpoint */
3
+ export declare const SERVER_ID = "csq-flip";
4
+ export interface ServerOptions {
5
+ /** Session timeout in ms. Default: 30000 (30 seconds) */
6
+ sessionTimeoutMs?: number;
7
+ /** Idle timeout in ms. Server shuts down if no session is registered within this time. 0 = no timeout. */
8
+ idleTimeout?: number;
4
9
  }
10
+ /**
11
+ * Singleton flip server that manages multiple sessions
12
+ */
5
13
  export declare class Server {
6
- private cwd;
7
14
  private port;
8
- constructor(cwd: string, port: number);
9
- run(): Promise<string | null>;
15
+ private options;
16
+ private app;
17
+ private server;
18
+ private sessionManager;
19
+ private resolveShutdown;
20
+ constructor(port: number, options?: ServerOptions);
21
+ /**
22
+ * Start the server and return a promise that resolves when server shuts down
23
+ */
24
+ run(): Promise<void>;
25
+ /**
26
+ * Shutdown the server gracefully
27
+ */
28
+ shutdown(): Promise<void>;
29
+ /**
30
+ * Get the session manager
31
+ */
32
+ getSessionManager(): SessionManager | null;
10
33
  }
34
+ /**
35
+ * Find an available port starting from the preferred port
36
+ */
11
37
  export declare function findFreePort(preferred: number, maxPort?: number): Promise<number>;
38
+ /**
39
+ * Check if a csq-flip server is running on the given port
40
+ */
41
+ export declare function isFlipServerRunning(port: number, timeoutMs?: number): Promise<boolean>;
42
+ /**
43
+ * Find an existing csq-flip server in the port range
44
+ */
45
+ export declare function findExistingServer(startPort: number, endPort: number): Promise<number | null>;
@@ -8,67 +8,129 @@ import { gitRouter } from '../routes/git.js';
8
8
  import { submitRouter } from '../routes/submit.js';
9
9
  import { cancelRouter } from '../routes/cancel.js';
10
10
  import { createStaticRouter } from '../routes/static.js';
11
- import { createEventsRouter } from '../routes/events.js';
12
- import { FileWatcher } from '../watcher/FileWatcher.js';
13
- import { SSEManager } from '../events/SSEManager.js';
11
+ import { createPingRouter } from '../routes/ping.js';
12
+ import { createSessionRouter } from '../routes/session.js';
13
+ import { createChangesRouter } from '../routes/changes.js';
14
+ import { SessionManager } from '../session/SessionManager.js';
15
+ const log = (...args) => console.error(...args);
16
+ /** Server identification string for ping endpoint */
17
+ export const SERVER_ID = 'csq-flip';
18
+ /**
19
+ * Singleton flip server that manages multiple sessions
20
+ */
14
21
  export class Server {
15
- cwd;
16
22
  port;
17
- constructor(cwd, port) {
18
- this.cwd = cwd;
23
+ options;
24
+ app = null;
25
+ server = null;
26
+ sessionManager = null;
27
+ resolveShutdown = null;
28
+ constructor(port, options = {}) {
19
29
  this.port = port;
30
+ this.options = {
31
+ sessionTimeoutMs: options.sessionTimeoutMs ?? 30000,
32
+ idleTimeout: options.idleTimeout ?? 0,
33
+ };
20
34
  }
35
+ /**
36
+ * Start the server and return a promise that resolves when server shuts down
37
+ */
21
38
  async run() {
22
39
  return new Promise((resolve) => {
23
- const state = {
24
- cwd: this.cwd,
25
- resolve: null,
26
- };
27
- const app = express();
40
+ this.resolveShutdown = resolve;
41
+ this.app = express();
28
42
  // Middleware
29
- app.use(cors());
30
- app.use(express.json());
31
- // Store state in app locals
32
- app.locals.state = state;
33
- // Initialize SSE manager and file watcher
34
- const sseManager = new SSEManager();
35
- const fileWatcher = new FileWatcher({ cwd: this.cwd });
36
- // Set up watcher event handlers
37
- fileWatcher.onFilesChanged(() => {
38
- sseManager.broadcast({ type: 'files-changed' });
39
- });
40
- fileWatcher.onFileChanged((path) => {
41
- sseManager.broadcast({ type: 'file-changed', path });
42
- });
43
- fileWatcher.onGitChanged(() => {
44
- sseManager.broadcast({ type: 'git-changed' });
43
+ this.app.use(cors());
44
+ this.app.use(express.json());
45
+ // Initialize session manager
46
+ this.sessionManager = new SessionManager({
47
+ sessionTimeoutMs: this.options.sessionTimeoutMs,
48
+ onAllSessionsGone: () => {
49
+ log('[Server] All sessions gone, shutting down...');
50
+ this.shutdown();
51
+ },
45
52
  });
46
- // Start watching
47
- fileWatcher.start();
53
+ this.sessionManager.start();
54
+ // Store session manager in app locals
55
+ this.app.locals.sessionManager = this.sessionManager;
48
56
  // API routes
49
- app.use('/api/files', filesRouter);
50
- app.use('/api/file', fileRouter);
51
- app.use('/api/git', gitRouter);
52
- app.use('/api/submit', submitRouter);
53
- app.use('/api/cancel', cancelRouter);
54
- app.use('/api/events', createEventsRouter(sseManager));
57
+ this.app.use('/api/ping', createPingRouter());
58
+ this.app.use('/api/session', createSessionRouter(this.sessionManager));
59
+ this.app.use('/api/changes', createChangesRouter(this.sessionManager));
60
+ this.app.use('/api/files', filesRouter);
61
+ this.app.use('/api/file', fileRouter);
62
+ this.app.use('/api/git', gitRouter);
63
+ this.app.use('/api/submit', submitRouter);
64
+ this.app.use('/api/cancel', cancelRouter);
55
65
  // Static files (fallback to web-ui dist)
56
- app.use(createStaticRouter());
57
- const server = http.createServer(app);
58
- // Set up the shutdown mechanism
59
- state.resolve = async (output) => {
60
- // Cleanup watcher and SSE connections
61
- await fileWatcher.stop();
62
- sseManager.closeAll();
63
- await new Promise(res => server.close(() => res()));
64
- resolve(output);
66
+ this.app.use(createStaticRouter());
67
+ this.server = http.createServer(this.app);
68
+ // Handle SIGINT/SIGTERM for graceful shutdown
69
+ const signalHandler = () => {
70
+ log('\nShutting down...');
71
+ this.shutdown();
65
72
  };
66
- server.listen(this.port, '127.0.0.1', () => {
67
- console.error(`Server running at http://localhost:${this.port}`);
73
+ process.on('SIGINT', signalHandler);
74
+ process.on('SIGTERM', signalHandler);
75
+ // Idle timeout - shutdown if no session is registered
76
+ let idleTimer = null;
77
+ if (this.options.idleTimeout && this.options.idleTimeout > 0) {
78
+ idleTimer = setTimeout(() => {
79
+ if (!this.sessionManager?.hasSessions) {
80
+ log('\nNo session registered, shutting down...');
81
+ this.shutdown();
82
+ }
83
+ }, this.options.idleTimeout);
84
+ // Clear timeout when first session is registered
85
+ // This is handled in session registration
86
+ }
87
+ // Store idle timer for cleanup
88
+ this.app.locals.idleTimer = idleTimer;
89
+ this.app.locals.clearIdleTimer = () => {
90
+ if (idleTimer) {
91
+ clearTimeout(idleTimer);
92
+ idleTimer = null;
93
+ this.app.locals.idleTimer = null;
94
+ }
95
+ };
96
+ this.server.listen(this.port, '127.0.0.1', () => {
97
+ log(`[Server] Running at http://localhost:${this.port}`);
68
98
  });
69
99
  });
70
100
  }
101
+ /**
102
+ * Shutdown the server gracefully
103
+ */
104
+ async shutdown() {
105
+ if (!this.server)
106
+ return;
107
+ // Clear idle timer if exists
108
+ if (this.app?.locals.idleTimer) {
109
+ clearTimeout(this.app.locals.idleTimer);
110
+ }
111
+ // Stop session manager (stops all watchers)
112
+ if (this.sessionManager) {
113
+ await this.sessionManager.stop();
114
+ }
115
+ // Close HTTP server
116
+ await new Promise(res => {
117
+ this.server.close(() => res());
118
+ });
119
+ log('[Server] Shutdown complete');
120
+ if (this.resolveShutdown) {
121
+ this.resolveShutdown();
122
+ }
123
+ }
124
+ /**
125
+ * Get the session manager
126
+ */
127
+ getSessionManager() {
128
+ return this.sessionManager;
129
+ }
71
130
  }
131
+ /**
132
+ * Find an available port starting from the preferred port
133
+ */
72
134
  export async function findFreePort(preferred, maxPort = 65535) {
73
135
  return new Promise((resolve, reject) => {
74
136
  if (preferred > maxPort) {
@@ -88,3 +150,46 @@ export async function findFreePort(preferred, maxPort = 65535) {
88
150
  });
89
151
  });
90
152
  }
153
+ /**
154
+ * Check if a csq-flip server is running on the given port
155
+ */
156
+ export async function isFlipServerRunning(port, timeoutMs = 1000) {
157
+ return new Promise((resolve) => {
158
+ const req = http.request({
159
+ hostname: '127.0.0.1',
160
+ port,
161
+ path: '/api/ping',
162
+ method: 'GET',
163
+ timeout: timeoutMs,
164
+ }, (res) => {
165
+ let data = '';
166
+ res.on('data', chunk => data += chunk);
167
+ res.on('end', () => {
168
+ try {
169
+ const json = JSON.parse(data);
170
+ resolve(json.id === SERVER_ID);
171
+ }
172
+ catch {
173
+ resolve(false);
174
+ }
175
+ });
176
+ });
177
+ req.on('error', () => resolve(false));
178
+ req.on('timeout', () => {
179
+ req.destroy();
180
+ resolve(false);
181
+ });
182
+ req.end();
183
+ });
184
+ }
185
+ /**
186
+ * Find an existing csq-flip server in the port range
187
+ */
188
+ export async function findExistingServer(startPort, endPort) {
189
+ for (let port = startPort; port <= endPort; port++) {
190
+ if (await isFlipServerRunning(port)) {
191
+ return port;
192
+ }
193
+ }
194
+ return null;
195
+ }
@@ -0,0 +1,72 @@
1
+ import { FileWatcher } from '../watcher/FileWatcher.js';
2
+ export interface Session {
3
+ /** Session ID (tty path, e.g., /dev/ttys001) */
4
+ id: string;
5
+ /** Working directory for this session */
6
+ cwd: string;
7
+ /** FileWatcher instance for this session */
8
+ watcher: FileWatcher;
9
+ /** Changed files since last poll (for polling-based sync) */
10
+ pendingChanges: PendingChanges;
11
+ /** Timeout timer for session expiration */
12
+ timeoutTimer: ReturnType<typeof setTimeout> | null;
13
+ }
14
+ export interface PendingChanges {
15
+ filesChanged: boolean;
16
+ gitChanged: boolean;
17
+ changedFiles: Set<string>;
18
+ }
19
+ export interface SessionManagerOptions {
20
+ /** Session timeout in ms. Sessions are cleaned up after this period of inactivity. */
21
+ sessionTimeoutMs: number;
22
+ /** Callback when all sessions are gone */
23
+ onAllSessionsGone?: () => void;
24
+ }
25
+ export declare class SessionManager {
26
+ private sessions;
27
+ private options;
28
+ constructor(options: SessionManagerOptions);
29
+ /**
30
+ * Start the session manager (no-op, kept for API compatibility)
31
+ */
32
+ start(): void;
33
+ /**
34
+ * Stop the session manager and clean up all sessions
35
+ */
36
+ stop(): Promise<void>;
37
+ /**
38
+ * Register a new session or update existing one
39
+ */
40
+ registerSession(sessionId: string, cwd: string): Session;
41
+ /**
42
+ * Get a session by ID and reset timeout
43
+ */
44
+ getSession(sessionId: string): Session | undefined;
45
+ /**
46
+ * Touch session to reset timeout (called on every API request)
47
+ */
48
+ touchSession(sessionId: string): void;
49
+ /**
50
+ * Unregister a session (e.g., on cancel/submit/timeout)
51
+ */
52
+ unregisterSession(sessionId: string): Promise<void>;
53
+ /**
54
+ * Get pending changes for a session and clear them
55
+ */
56
+ consumePendingChanges(sessionId: string): PendingChanges | null;
57
+ /**
58
+ * Get number of active sessions
59
+ */
60
+ get sessionCount(): number;
61
+ /**
62
+ * Check if any sessions exist
63
+ */
64
+ get hasSessions(): boolean;
65
+ private createWatcher;
66
+ private createEmptyPendingChanges;
67
+ /**
68
+ * Reset the timeout timer for a session.
69
+ * Called on every activity (register, poll, etc.)
70
+ */
71
+ private resetSessionTimeout;
72
+ }
@@ -0,0 +1,169 @@
1
+ import { FileWatcher } from '../watcher/FileWatcher.js';
2
+ const log = (...args) => console.error(...args);
3
+ export class SessionManager {
4
+ sessions = new Map();
5
+ options;
6
+ constructor(options) {
7
+ this.options = options;
8
+ }
9
+ /**
10
+ * Start the session manager (no-op, kept for API compatibility)
11
+ */
12
+ start() {
13
+ // No longer uses periodic cleanup - each session has its own timeout
14
+ }
15
+ /**
16
+ * Stop the session manager and clean up all sessions
17
+ */
18
+ async stop() {
19
+ // Clear all session timeouts and stop watchers
20
+ const stopPromises = Array.from(this.sessions.values()).map(session => {
21
+ if (session.timeoutTimer) {
22
+ clearTimeout(session.timeoutTimer);
23
+ }
24
+ return session.watcher.stop();
25
+ });
26
+ await Promise.all(stopPromises);
27
+ this.sessions.clear();
28
+ }
29
+ /**
30
+ * Register a new session or update existing one
31
+ */
32
+ registerSession(sessionId, cwd) {
33
+ const existing = this.sessions.get(sessionId);
34
+ if (existing) {
35
+ // Reset timeout on activity
36
+ this.resetSessionTimeout(sessionId);
37
+ // If cwd changed, need to restart watcher
38
+ if (existing.cwd !== cwd) {
39
+ existing.watcher.stop();
40
+ existing.cwd = cwd;
41
+ existing.watcher = this.createWatcher(sessionId, cwd);
42
+ existing.pendingChanges = this.createEmptyPendingChanges();
43
+ }
44
+ return existing;
45
+ }
46
+ // Create new session
47
+ const watcher = this.createWatcher(sessionId, cwd);
48
+ const session = {
49
+ id: sessionId,
50
+ cwd,
51
+ watcher,
52
+ pendingChanges: this.createEmptyPendingChanges(),
53
+ timeoutTimer: null,
54
+ };
55
+ this.sessions.set(sessionId, session);
56
+ this.resetSessionTimeout(sessionId);
57
+ log(`[SessionManager] Session registered: ${sessionId} (cwd: ${cwd})`);
58
+ return session;
59
+ }
60
+ /**
61
+ * Get a session by ID and reset timeout
62
+ */
63
+ getSession(sessionId) {
64
+ const session = this.sessions.get(sessionId);
65
+ if (session) {
66
+ this.resetSessionTimeout(sessionId);
67
+ }
68
+ return session;
69
+ }
70
+ /**
71
+ * Touch session to reset timeout (called on every API request)
72
+ */
73
+ touchSession(sessionId) {
74
+ this.resetSessionTimeout(sessionId);
75
+ }
76
+ /**
77
+ * Unregister a session (e.g., on cancel/submit/timeout)
78
+ */
79
+ async unregisterSession(sessionId) {
80
+ const session = this.sessions.get(sessionId);
81
+ if (session) {
82
+ // Clear timeout timer
83
+ if (session.timeoutTimer) {
84
+ clearTimeout(session.timeoutTimer);
85
+ }
86
+ await session.watcher.stop();
87
+ this.sessions.delete(sessionId);
88
+ log(`[SessionManager] Session unregistered: ${sessionId}`);
89
+ if (this.sessions.size === 0 && this.options.onAllSessionsGone) {
90
+ this.options.onAllSessionsGone();
91
+ }
92
+ }
93
+ }
94
+ /**
95
+ * Get pending changes for a session and clear them
96
+ */
97
+ consumePendingChanges(sessionId) {
98
+ const session = this.sessions.get(sessionId);
99
+ if (!session)
100
+ return null;
101
+ const changes = { ...session.pendingChanges };
102
+ changes.changedFiles = new Set(session.pendingChanges.changedFiles);
103
+ // Reset pending changes
104
+ session.pendingChanges = this.createEmptyPendingChanges();
105
+ return changes;
106
+ }
107
+ /**
108
+ * Get number of active sessions
109
+ */
110
+ get sessionCount() {
111
+ return this.sessions.size;
112
+ }
113
+ /**
114
+ * Check if any sessions exist
115
+ */
116
+ get hasSessions() {
117
+ return this.sessions.size > 0;
118
+ }
119
+ createWatcher(sessionId, cwd) {
120
+ const watcher = new FileWatcher({ cwd });
121
+ watcher.onFilesChanged(() => {
122
+ const session = this.sessions.get(sessionId);
123
+ if (session) {
124
+ session.pendingChanges.filesChanged = true;
125
+ }
126
+ });
127
+ watcher.onFileChanged((filePath) => {
128
+ const session = this.sessions.get(sessionId);
129
+ if (session) {
130
+ session.pendingChanges.changedFiles.add(filePath);
131
+ }
132
+ });
133
+ watcher.onGitChanged(() => {
134
+ const session = this.sessions.get(sessionId);
135
+ if (session) {
136
+ session.pendingChanges.gitChanged = true;
137
+ }
138
+ });
139
+ watcher.start();
140
+ return watcher;
141
+ }
142
+ createEmptyPendingChanges() {
143
+ return {
144
+ filesChanged: false,
145
+ gitChanged: false,
146
+ changedFiles: new Set(),
147
+ };
148
+ }
149
+ /**
150
+ * Reset the timeout timer for a session.
151
+ * Called on every activity (register, poll, etc.)
152
+ */
153
+ resetSessionTimeout(sessionId) {
154
+ const session = this.sessions.get(sessionId);
155
+ if (!session)
156
+ return;
157
+ // Clear existing timeout
158
+ if (session.timeoutTimer) {
159
+ clearTimeout(session.timeoutTimer);
160
+ }
161
+ // Set new timeout
162
+ session.timeoutTimer = setTimeout(() => {
163
+ log(`[SessionManager] Session timed out: ${sessionId}`);
164
+ this.unregisterSession(sessionId).catch(error => {
165
+ log(`[SessionManager] Error during session unregister for ${sessionId}:`, error);
166
+ });
167
+ }, this.options.sessionTimeoutMs);
168
+ }
169
+ }