@profoundlogic/coderflow-cli 0.2.1

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.
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Local Repository State Capture (Client-side)
3
+ * Captures complete git state including local branches, staged/unstaged changes, untracked files
4
+ */
5
+
6
+ import { exec } from 'child_process';
7
+ import { promisify } from 'util';
8
+ import path from 'path';
9
+ import { promises as fs } from 'fs';
10
+
11
+ const execAsync = promisify(exec);
12
+
13
+ /**
14
+ * Execute git command in a repository
15
+ */
16
+ async function execGit(repoPath, command) {
17
+ try {
18
+ const { stdout, stderr } = await execAsync(`git ${command}`, {
19
+ cwd: repoPath,
20
+ maxBuffer: 50 * 1024 * 1024, // 50MB for large diffs
21
+ encoding: 'utf8'
22
+ });
23
+ return stdout.trim();
24
+ } catch (error) {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Check if a directory is a git repository
31
+ */
32
+ async function isGitRepo(dirPath) {
33
+ try {
34
+ const gitDir = await execGit(dirPath, 'rev-parse --git-dir');
35
+ return gitDir !== null;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Detect all git repositories in a directory tree
43
+ * @param {string} basePath - Directory to search
44
+ * @param {Array} repoConfigs - Repository configurations from environment.json with name, path, etc.
45
+ * @returns {Object} Map of repoName -> absolutePath
46
+ */
47
+ async function discoverRepos(basePath, repoConfigs) {
48
+ const discovered = {};
49
+
50
+ // Strategy 1: Use configured paths from environment.json
51
+ for (const repoConfig of repoConfigs) {
52
+ if (discovered[repoConfig.name]) continue;
53
+
54
+ // Use the path from config, or fall back to name
55
+ const repoPath = repoConfig.path || repoConfig.name;
56
+
57
+ // Try the path relative to basePath
58
+ const fullPath = path.join(basePath, repoPath);
59
+ try {
60
+ if (await isGitRepo(fullPath)) {
61
+ discovered[repoConfig.name] = fullPath;
62
+ continue;
63
+ }
64
+ } catch {
65
+ // Path doesn't exist
66
+ }
67
+
68
+ // Try parent directory + configured path
69
+ const parentPath = path.resolve(basePath, '..');
70
+ const parentFullPath = path.join(parentPath, repoPath);
71
+ try {
72
+ if (await isGitRepo(parentFullPath)) {
73
+ discovered[repoConfig.name] = parentFullPath;
74
+ continue;
75
+ }
76
+ } catch {
77
+ // Path doesn't exist
78
+ }
79
+ }
80
+
81
+ // Strategy 2: Check if basePath itself is one of the repos
82
+ const baseRepoName = path.basename(basePath);
83
+ const baseRepo = repoConfigs.find(r => r.name === baseRepoName);
84
+ if (baseRepo && !discovered[baseRepoName] && await isGitRepo(basePath)) {
85
+ discovered[baseRepoName] = basePath;
86
+ }
87
+
88
+ // Strategy 3: Search nested directories as fallback (for repos not yet configured with paths)
89
+ async function searchNested(dir, depth = 0) {
90
+ if (depth > 2) return; // Don't search too deep
91
+
92
+ try {
93
+ const entries = await fs.readdir(dir, { withFileTypes: true });
94
+
95
+ for (const entry of entries) {
96
+ if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') {
97
+ continue;
98
+ }
99
+
100
+ const fullPath = path.join(dir, entry.name);
101
+
102
+ // Check if this directory matches any repo name
103
+ const matchingRepo = repoConfigs.find(r => r.name === entry.name);
104
+ if (matchingRepo && !discovered[matchingRepo.name]) {
105
+ if (await isGitRepo(fullPath)) {
106
+ discovered[matchingRepo.name] = fullPath;
107
+ }
108
+ }
109
+
110
+ // Recurse into subdirectories
111
+ await searchNested(fullPath, depth + 1);
112
+ }
113
+ } catch {
114
+ // Skip directories we can't read
115
+ }
116
+ }
117
+
118
+ await searchNested(basePath);
119
+
120
+ return discovered;
121
+ }
122
+
123
+ /**
124
+ * Get contents of untracked files
125
+ */
126
+ async function getUntrackedFiles(repoPath) {
127
+ const untrackedList = await execGit(repoPath, 'ls-files --others --exclude-standard');
128
+ if (!untrackedList) return {};
129
+
130
+ const files = {};
131
+ const fileNames = untrackedList.split('\n').filter(f => f.trim());
132
+
133
+ for (const fileName of fileNames) {
134
+ const filePath = path.join(repoPath, fileName);
135
+ try {
136
+ // Check file size first (skip large files)
137
+ const stats = await fs.stat(filePath);
138
+ if (stats.size > 1024 * 1024) { // Skip files > 1MB
139
+ console.warn(`Skipping large untracked file: ${fileName} (${stats.size} bytes)`);
140
+ continue;
141
+ }
142
+
143
+ const content = await fs.readFile(filePath, 'utf8');
144
+ files[fileName] = content;
145
+ } catch (error) {
146
+ console.warn(`Could not read untracked file ${fileName}: ${error.message}`);
147
+ }
148
+ }
149
+
150
+ return files;
151
+ }
152
+
153
+ /**
154
+ * Capture complete state of a single repository
155
+ */
156
+ async function captureRepoState(repoPath, repoName) {
157
+ const currentBranch = await execGit(repoPath, 'rev-parse --abbrev-ref HEAD');
158
+ if (!currentBranch) {
159
+ throw new Error(`Could not determine current branch for ${repoName}`);
160
+ }
161
+
162
+ // Check if branch exists on remote
163
+ const remoteBranch = await execGit(repoPath, 'rev-parse --abbrev-ref @{u}');
164
+ const isRemoteTracking = remoteBranch !== null;
165
+
166
+ const state = {
167
+ repo_name: repoName,
168
+ repo_path: repoPath,
169
+ current_branch: currentBranch,
170
+ is_remote_tracking: isRemoteTracking,
171
+ remote_branch: remoteBranch,
172
+ };
173
+
174
+ // Capture local commits (both local-only branches and unpushed commits on remote-tracking branches)
175
+ let baseBranch = null;
176
+ let commitsAhead = 0;
177
+
178
+ if (isRemoteTracking) {
179
+ // Remote-tracking branch - check for unpushed commits
180
+ const remoteBranchRef = `origin/${currentBranch}`;
181
+ const remoteExists = await execGit(repoPath, `rev-parse --verify ${remoteBranchRef} 2>/dev/null`);
182
+
183
+ if (remoteExists) {
184
+ baseBranch = remoteBranchRef;
185
+ const ahead = await execGit(repoPath, `rev-list --count ${remoteBranchRef}..HEAD`);
186
+ commitsAhead = parseInt(ahead) || 0;
187
+ }
188
+ } else if (currentBranch !== 'HEAD') {
189
+ // Local-only branch - find base branch point
190
+ for (const candidate of ['dev', 'main', 'master']) {
191
+ const exists = await execGit(repoPath, `rev-parse --verify ${candidate} 2>/dev/null`);
192
+ if (exists) {
193
+ baseBranch = candidate;
194
+ break;
195
+ }
196
+ }
197
+
198
+ if (baseBranch) {
199
+ const ahead = await execGit(repoPath, `rev-list --count ${baseBranch}..HEAD`);
200
+ commitsAhead = parseInt(ahead) || 0;
201
+ }
202
+ }
203
+
204
+ // Capture commit patches if we have commits ahead
205
+ if (baseBranch && commitsAhead > 0) {
206
+ state.base_branch = baseBranch;
207
+ state.commits_ahead = commitsAhead;
208
+
209
+ const commitPatch = await execGit(repoPath, `format-patch ${baseBranch}..HEAD --stdout`);
210
+ if (commitPatch) {
211
+ state.commit_patch = commitPatch;
212
+ }
213
+ }
214
+
215
+ // Capture uncommitted changes (unstaged working directory changes)
216
+ const unstaged = await execGit(repoPath, 'diff HEAD');
217
+ if (unstaged) {
218
+ state.unstaged = unstaged;
219
+ }
220
+
221
+ // Capture staged changes
222
+ const staged = await execGit(repoPath, 'diff --cached');
223
+ if (staged) {
224
+ state.staged = staged;
225
+ }
226
+
227
+ // Capture untracked files
228
+ const untracked = await getUntrackedFiles(repoPath);
229
+ const untrackedCount = Object.keys(untracked).length;
230
+ if (untrackedCount > 0) {
231
+ state.untracked_files = untracked;
232
+ }
233
+
234
+ // Get current commit hash for reference
235
+ state.current_commit = await execGit(repoPath, 'rev-parse HEAD');
236
+
237
+ // Get remote URL for verification
238
+ state.remote_url = await execGit(repoPath, 'config --get remote.origin.url');
239
+
240
+ return state;
241
+ }
242
+
243
+ /**
244
+ * Main function: Capture state of all repositories
245
+ *
246
+ * @param {string} sourcePath - Directory to search for repos (typically cwd)
247
+ * @param {Array} repoConfigs - Repository configurations from environment.json (objects with name, path, etc.)
248
+ * @returns {Promise<Object>} Complete state object
249
+ */
250
+ export async function captureAllRepos(sourcePath, repoConfigs) {
251
+ const repoNames = repoConfigs.map(r => r.name);
252
+
253
+ // Discover repositories
254
+ const discovered = await discoverRepos(sourcePath, repoConfigs);
255
+
256
+ const foundCount = Object.keys(discovered).length;
257
+
258
+ if (foundCount === 0) {
259
+ const error = `No repositories found!
260
+
261
+ Expected repos: ${repoNames.join(', ')}
262
+ Search path: ${sourcePath}
263
+
264
+ Please run this command from:
265
+ - The parent directory containing all repos, or
266
+ - Inside one of the repository directories
267
+
268
+ The system will automatically discover repos in the current directory,
269
+ subdirectories, and parent directory.`;
270
+
271
+ throw new Error(error);
272
+ }
273
+
274
+ // Warn about missing repos
275
+ const missing = repoNames.filter(r => !discovered[r]);
276
+ if (missing.length > 0) {
277
+ console.warn(`Warning: Missing repositories: ${missing.join(', ')} - will use container defaults`);
278
+ }
279
+
280
+ // Capture state for each discovered repo
281
+ const states = {};
282
+ for (const [repoName, repoPath] of Object.entries(discovered)) {
283
+ try {
284
+ states[repoName] = await captureRepoState(repoPath, repoName);
285
+ } catch (error) {
286
+ throw new Error(`Failed to capture state for ${repoName}: ${error.message}`);
287
+ }
288
+ }
289
+
290
+ return {
291
+ captured_at: new Date().toISOString(),
292
+ source_path: sourcePath,
293
+ repositories: states,
294
+ repos_found: Object.keys(states),
295
+ repos_missing: missing
296
+ };
297
+ }
298
+
299
+ /**
300
+ * Validate that captured state can be applied
301
+ * Returns validation result with any errors
302
+ */
303
+ export function validateState(stateObj) {
304
+ const errors = [];
305
+ const warnings = [];
306
+
307
+ if (!stateObj.repositories || Object.keys(stateObj.repositories).length === 0) {
308
+ errors.push('No repositories in state object');
309
+ return { valid: false, errors, warnings };
310
+ }
311
+
312
+ for (const [repoName, state] of Object.entries(stateObj.repositories)) {
313
+ if (!state.current_branch) {
314
+ errors.push(`${repoName}: Missing current_branch`);
315
+ }
316
+
317
+ // Warn about very large patches (but don't fail)
318
+ if (state.commit_patch && state.commit_patch.length > 10 * 1024 * 1024) {
319
+ warnings.push(`${repoName}: Commit patch is very large (${(state.commit_patch.length / 1024 / 1024).toFixed(1)}MB)`);
320
+ }
321
+
322
+ if (state.unstaged && state.unstaged.length > 10 * 1024 * 1024) {
323
+ warnings.push(`${repoName}: Unstaged diff is very large (${(state.unstaged.length / 1024 / 1024).toFixed(1)}MB)`);
324
+ }
325
+
326
+ if (state.untracked_files && Object.keys(state.untracked_files).length > 100) {
327
+ warnings.push(`${repoName}: Many untracked files (${Object.keys(state.untracked_files).length})`);
328
+ }
329
+ }
330
+
331
+ return {
332
+ valid: errors.length === 0,
333
+ errors,
334
+ warnings
335
+ };
336
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Task grouping utilities - mirrors the web UI grouping logic
3
+ */
4
+
5
+ /**
6
+ * Compare parameters excluding 'instructions' field
7
+ */
8
+ function areParametersEqual(params1, params2) {
9
+ const keys1 = Object.keys(params1 || {}).filter(k => k !== 'instructions').sort();
10
+ const keys2 = Object.keys(params2 || {}).filter(k => k !== 'instructions').sort();
11
+
12
+ if (keys1.length !== keys2.length) return false;
13
+
14
+ return keys1.every(key => params1[key] === params2[key]);
15
+ }
16
+
17
+ /**
18
+ * Group related tasks as variants
19
+ * Criteria:
20
+ * - Same environment
21
+ * - Same taskType
22
+ * - Same testName (if task_type is 'test')
23
+ * - Same parameters (excluding instructions)
24
+ * - Created within 25 seconds of each other
25
+ */
26
+ export function groupRelatedTasks(tasks) {
27
+ const groups = [];
28
+ const seen = new Set();
29
+
30
+ for (const task of tasks) {
31
+ if (seen.has(task.taskId)) continue;
32
+
33
+ // Find all tasks submitted ~same time with same params
34
+ const related = tasks.filter(t =>
35
+ !seen.has(t.taskId) &&
36
+ t.environment === task.environment &&
37
+ t.taskType === task.taskType &&
38
+ (t.taskType !== 'test' || t.testName === task.testName) &&
39
+ areParametersEqual(t.parameters, task.parameters) &&
40
+ Math.abs(new Date(t.createdAt) - new Date(task.createdAt)) < 25000
41
+ );
42
+
43
+ if (related.length > 1) {
44
+ // Sort variants by agent (claude first, then codex) and creation time
45
+ const sorted = related.sort((a, b) => {
46
+ const agentA = a.envVars?.CODER_AGENT || a.envVars?.default_agent || '';
47
+ const agentB = b.envVars?.CODER_AGENT || b.envVars?.default_agent || '';
48
+ if (agentA !== agentB) {
49
+ // Sort claude before codex
50
+ if (agentA === 'claude') return -1;
51
+ if (agentB === 'claude') return 1;
52
+ }
53
+ // Same agent, sort by creation time
54
+ const timeA = new Date(a.createdAt).getTime();
55
+ const timeB = new Date(b.createdAt).getTime();
56
+ return timeA - timeB;
57
+ });
58
+
59
+ groups.push({
60
+ primary: sorted[0],
61
+ variants: sorted,
62
+ isGroup: true
63
+ });
64
+ related.forEach(t => seen.add(t.taskId));
65
+ } else {
66
+ groups.push({
67
+ primary: task,
68
+ variants: [task],
69
+ isGroup: false
70
+ });
71
+ seen.add(task.taskId);
72
+ }
73
+ }
74
+
75
+ return groups;
76
+ }
77
+
78
+ /**
79
+ * Get a brief description of what the task did
80
+ */
81
+ function getTaskDescription(task) {
82
+ // Try summary first (if included from server)
83
+ if (task.summary) {
84
+ // Extract first line or first 80 chars
85
+ const firstLine = task.summary.split('\n')[0].trim();
86
+
87
+ // Skip generic completion messages
88
+ if (firstLine.includes('completed successfully') ||
89
+ firstLine.includes('Task completed') ||
90
+ firstLine.match(/^Task \d+-\w+ completed/)) {
91
+ // Skip this line, try second line if available
92
+ const lines = task.summary.split('\n');
93
+ if (lines.length > 1) {
94
+ const secondLine = lines[1].trim();
95
+ if (secondLine && secondLine.length > 0) {
96
+ if (secondLine.length > 80) {
97
+ return secondLine.substring(0, 77) + '...';
98
+ }
99
+ return secondLine;
100
+ }
101
+ }
102
+ // Fall through to try instructions
103
+ } else {
104
+ if (firstLine.length > 80) {
105
+ return firstLine.substring(0, 77) + '...';
106
+ }
107
+ return firstLine;
108
+ }
109
+ }
110
+
111
+ // Try parameters.instructions
112
+ if (task.parameters?.instructions) {
113
+ const firstLine = task.parameters.instructions.split('\n')[0].trim();
114
+ if (firstLine.length > 80) {
115
+ return firstLine.substring(0, 77) + '...';
116
+ }
117
+ return firstLine;
118
+ }
119
+
120
+ // Try test name
121
+ if (task.taskType === 'test' && task.testName) {
122
+ return `Test: ${task.testName}`;
123
+ }
124
+
125
+ // Try task type
126
+ if (task.taskType && task.taskType !== 'manual') {
127
+ return task.taskType;
128
+ }
129
+
130
+ // Fallback
131
+ return 'No description';
132
+ }
133
+
134
+ /**
135
+ * Format a relative time string
136
+ */
137
+ function formatRelativeTime(date) {
138
+ const now = new Date();
139
+ const diffMs = now - date;
140
+ const diffMins = Math.floor(diffMs / 60000);
141
+ const diffHours = Math.floor(diffMs / 3600000);
142
+ const diffDays = Math.floor(diffMs / 86400000);
143
+
144
+ if (diffMins < 1) return 'just now';
145
+ if (diffMins < 60) return `${diffMins}m ago`;
146
+ if (diffHours < 24) return `${diffHours}h ago`;
147
+ if (diffDays === 1) return 'yesterday';
148
+ if (diffDays < 7) return `${diffDays}d ago`;
149
+
150
+ // For older tasks, show date
151
+ const month = String(date.getMonth() + 1).padStart(2, '0');
152
+ const day = String(date.getDate()).padStart(2, '0');
153
+ return `${month}/${day}`;
154
+ }
155
+
156
+ /**
157
+ * Format a task for display in selection list
158
+ * @param {Object} task - The task to format
159
+ * @param {boolean} isVariant - Whether this is a variant display (for sub-menu)
160
+ * @param {boolean} showAgent - Whether to show the agent badge (default: true)
161
+ */
162
+ export function formatTaskForDisplay(task, isVariant = false, showAgent = true) {
163
+ const agent = task.envVars?.CODER_AGENT || task.envVars?.default_agent || 'unknown';
164
+ const date = new Date(task.createdAt);
165
+ const timeStr = formatRelativeTime(date);
166
+
167
+ // Agent badge with color indicator
168
+ const agentBadge = agent === 'claude' ? 'Claude' :
169
+ agent === 'codex' ? 'Codex' :
170
+ agent;
171
+
172
+ let label = '';
173
+
174
+ if (isVariant) {
175
+ // Variant display (for sub-menu) - show task ID, agent, and time
176
+ label = `${task.taskId} • ${agentBadge} • ${timeStr}`;
177
+ } else {
178
+ // Primary task display - show description, environment, time, and optionally agent
179
+ const description = getTaskDescription(task);
180
+ const env = task.environment || 'default';
181
+
182
+ if (showAgent) {
183
+ label = `${description} • ${agentBadge} • ${env} • ${timeStr}`;
184
+ } else {
185
+ label = `${description} • ${env} • ${timeStr}`;
186
+ }
187
+ }
188
+
189
+ return label;
190
+ }
191
+
192
+ /**
193
+ * Format a task group for display in selection list
194
+ */
195
+ export function formatGroupForDisplay(group) {
196
+ const task = group.primary;
197
+ const variantCount = group.variants.length;
198
+
199
+ // For multi-agent groups, don't show the agent badge since it would be misleading
200
+ // (only shows one agent when there are multiple)
201
+ const showAgent = !group.isGroup || variantCount === 1;
202
+ let label = formatTaskForDisplay(task, false, showAgent);
203
+
204
+ // Add variant count if this is a group with multiple variants
205
+ if (group.isGroup && variantCount > 1) {
206
+ label += ` (${variantCount} agents)`;
207
+ }
208
+
209
+ return label;
210
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * WebSocket terminal client
3
+ * Connects to server's terminal WebSocket and provides interactive terminal
4
+ */
5
+
6
+ import { WebSocket } from 'ws';
7
+ import { getServerUrl } from './config.js';
8
+
9
+ /**
10
+ * Connect to server's WebSocket terminal and provide interactive session
11
+ * @param {string} containerId - Container ID
12
+ * @param {string} cmd - Command to execute (optional)
13
+ * @returns {Promise<number>} Exit code
14
+ */
15
+ export async function connectTerminal(containerId, cmd = null) {
16
+ const serverUrl = await getServerUrl();
17
+
18
+ // Convert HTTP URL to WebSocket URL
19
+ const wsUrl = new URL(serverUrl);
20
+ wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:';
21
+ wsUrl.pathname = `/ws/containers/${encodeURIComponent(containerId)}`;
22
+ if (cmd) {
23
+ wsUrl.searchParams.set('cmd', cmd);
24
+ }
25
+
26
+ return new Promise((resolve, reject) => {
27
+ const ws = new WebSocket(wsUrl.toString());
28
+ let connected = false;
29
+
30
+ ws.on('open', () => {
31
+ connected = true;
32
+ });
33
+
34
+ ws.on('message', (data) => {
35
+ try {
36
+ const message = JSON.parse(data.toString());
37
+
38
+ if (message.type === 'status') {
39
+ if (message.status === 'connected') {
40
+ // Terminal connected - set up stdin forwarding
41
+ setupStdinForwarding(ws);
42
+ } else if (message.status === 'terminated') {
43
+ // Terminal session ended normally
44
+ cleanupStdin();
45
+ ws.close();
46
+ resolve(0);
47
+ }
48
+ } else if (message.type === 'data') {
49
+ // Output from terminal
50
+ process.stdout.write(message.data);
51
+ } else if (message.type === 'error') {
52
+ // Error from server
53
+ console.error(`\nTerminal error: ${message.message}`);
54
+ cleanupStdin();
55
+ ws.close();
56
+ resolve(1);
57
+ }
58
+ } catch (error) {
59
+ // Ignore JSON parse errors
60
+ }
61
+ });
62
+
63
+ ws.on('error', (error) => {
64
+ if (!connected) {
65
+ console.error(`\nFailed to connect to server terminal: ${error.message}`);
66
+ console.error('\nPossible solutions:');
67
+ console.error(' 1. Check that the server is running');
68
+ console.error(' 2. Verify CODER_SERVER_URL is correct');
69
+ console.error(' 3. Ensure the container still exists');
70
+ } else {
71
+ console.error(`\nWebSocket error: ${error.message}`);
72
+ }
73
+ cleanupStdin();
74
+ reject(error);
75
+ });
76
+
77
+ ws.on('close', (code, reason) => {
78
+ cleanupStdin();
79
+ if (!connected) {
80
+ resolve(1);
81
+ } else if (code !== 1000) {
82
+ // Non-normal closure
83
+ console.error(`\nConnection closed: ${reason || 'Unknown reason'}`);
84
+ resolve(1);
85
+ }
86
+ // Normal closure already handled by 'terminated' status
87
+ });
88
+
89
+ // Handle Ctrl+C gracefully
90
+ process.on('SIGINT', () => {
91
+ cleanupStdin();
92
+ ws.close();
93
+ resolve(130); // Standard exit code for SIGINT
94
+ });
95
+ });
96
+ }
97
+
98
+ /**
99
+ * Set up stdin forwarding to WebSocket
100
+ */
101
+ function setupStdinForwarding(ws) {
102
+ // Set stdin to raw mode for interactive terminal
103
+ if (process.stdin.isTTY) {
104
+ process.stdin.setRawMode(true);
105
+ }
106
+
107
+ process.stdin.resume();
108
+ process.stdin.setEncoding('utf8');
109
+
110
+ // Forward stdin to WebSocket
111
+ const stdinHandler = (data) => {
112
+ if (ws.readyState === WebSocket.OPEN) {
113
+ ws.send(JSON.stringify({ type: 'data', data }));
114
+ }
115
+ };
116
+
117
+ process.stdin.on('data', stdinHandler);
118
+
119
+ // Store handler for cleanup
120
+ process.stdin._terminalHandler = stdinHandler;
121
+
122
+ // Handle terminal resize events
123
+ if (process.stdout.isTTY) {
124
+ const sendResize = () => {
125
+ if (ws.readyState === WebSocket.OPEN) {
126
+ ws.send(JSON.stringify({
127
+ type: 'resize',
128
+ cols: process.stdout.columns,
129
+ rows: process.stdout.rows
130
+ }));
131
+ }
132
+ };
133
+
134
+ // Send initial size
135
+ sendResize();
136
+
137
+ // Send on resize
138
+ process.stdout.on('resize', sendResize);
139
+ process.stdout._resizeHandler = sendResize;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Clean up stdin handlers and restore terminal state
145
+ */
146
+ function cleanupStdin() {
147
+ if (process.stdin._terminalHandler) {
148
+ process.stdin.removeListener('data', process.stdin._terminalHandler);
149
+ delete process.stdin._terminalHandler;
150
+ }
151
+
152
+ if (process.stdout._resizeHandler) {
153
+ process.stdout.removeListener('resize', process.stdout._resizeHandler);
154
+ delete process.stdout._resizeHandler;
155
+ }
156
+
157
+ if (process.stdin.isTTY) {
158
+ process.stdin.setRawMode(false);
159
+ }
160
+
161
+ process.stdin.pause();
162
+ }