portacode 0.2.3.dev0__tar.gz → 0.2.4.dev0__tar.gz

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 (37) hide show
  1. {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/PKG-INFO +1 -1
  2. {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/_version.py +2 -2
  3. portacode-0.2.4.dev0/portacode/connection/handlers/README.md +427 -0
  4. {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/connection/handlers/__init__.py +13 -0
  5. portacode-0.2.4.dev0/portacode/connection/handlers/file_handlers.py +209 -0
  6. {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/connection/handlers/session.py +7 -13
  7. {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/connection/multiplex.py +7 -7
  8. {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode.egg-info/PKG-INFO +1 -1
  9. {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode.egg-info/SOURCES.txt +2 -0
  10. {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/.gitignore +0 -0
  11. {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/.gitmodules +0 -0
  12. {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/MANIFEST.in +0 -0
  13. {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/Makefile +0 -0
  14. {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/README.md +0 -0
  15. {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/docker-compose.yaml +0 -0
  16. {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/README.md +0 -0
  17. {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/__init__.py +0 -0
  18. {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/__main__.py +0 -0
  19. {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/cli.py +0 -0
  20. {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/connection/README.md +0 -0
  21. {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/connection/__init__.py +0 -0
  22. {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/connection/client.py +0 -0
  23. {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/connection/handlers/base.py +0 -0
  24. {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/connection/handlers/registry.py +0 -0
  25. {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/connection/handlers/system_handlers.py +0 -0
  26. {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/connection/handlers/terminal_handlers.py +0 -0
  27. {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/connection/terminal.py +0 -0
  28. {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/data.py +0 -0
  29. {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/keypair.py +0 -0
  30. {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/service.py +0 -0
  31. {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode.egg-info/dependency_links.txt +0 -0
  32. {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode.egg-info/entry_points.txt +0 -0
  33. {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode.egg-info/requires.txt +0 -0
  34. {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode.egg-info/top_level.txt +0 -0
  35. {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/pyproject.toml +0 -0
  36. {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/setup.cfg +0 -0
  37. {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: portacode
3
- Version: 0.2.3.dev0
3
+ Version: 0.2.4.dev0
4
4
  Summary: Portacode CLI client and SDK
5
5
  Home-page: https://github.com/portacode/portacode
6
6
  Author: Meena Erian
@@ -17,5 +17,5 @@ __version__: str
17
17
  __version_tuple__: VERSION_TUPLE
18
18
  version_tuple: VERSION_TUPLE
19
19
 
20
- __version__ = version = '0.2.3.dev'
21
- __version_tuple__ = version_tuple = (0, 2, 3, 'dev0')
20
+ __version__ = version = '0.2.4.dev'
21
+ __version_tuple__ = version_tuple = (0, 2, 4, 'dev0')
@@ -0,0 +1,427 @@
1
+ # Portacode Handler System
2
+
3
+ This directory contains the modular command handler system for the Portacode client. The system provides a clean, extensible architecture for processing commands from the gateway.
4
+
5
+ ## Architecture Overview
6
+
7
+ The handler system consists of:
8
+
9
+ - **Base Handler Classes**: `BaseHandler`, `AsyncHandler`, `SyncHandler`
10
+ - **Command Registry**: `CommandRegistry` for managing and dispatching handlers
11
+ - **Session Management**: `SessionManager` for terminal session lifecycle
12
+ - **Specific Handlers**: Individual command implementations
13
+
14
+ ## Existing Handlers
15
+
16
+ ### Terminal Handlers (`terminal_handlers.py`)
17
+
18
+ 1. **`TerminalStartHandler`** - `terminal_start`
19
+ - Starts new terminal sessions
20
+ - Supports shell and cwd parameters
21
+ - Handles both Windows (ConPTY) and Unix (PTY) systems
22
+
23
+ 2. **`TerminalSendHandler`** - `terminal_send`
24
+ - Sends data to existing terminal sessions
25
+ - Requires terminal_id and data parameters
26
+
27
+ 3. **`TerminalStopHandler`** - `terminal_stop`
28
+ - Terminates terminal sessions
29
+ - Cleans up resources and sends exit events
30
+
31
+ 4. **`TerminalListHandler`** - `terminal_list`
32
+ - Lists all active terminal sessions
33
+ - Returns session metadata and buffer contents
34
+
35
+ ### System Handlers (`system_handlers.py`)
36
+
37
+ 1. **`SystemInfoHandler`** - `system_info`
38
+ - Provides system resource information
39
+ - Returns CPU, memory, and disk usage data
40
+
41
+ ## Adding New Handlers
42
+
43
+ ### 1. Asynchronous Handlers
44
+
45
+ For commands that need to perform I/O operations, use `AsyncHandler`:
46
+
47
+ ```python
48
+ from .base import AsyncHandler
49
+ from typing import Any, Dict
50
+
51
+ class FileReadHandler(AsyncHandler):
52
+ """Handler for reading file contents."""
53
+
54
+ @property
55
+ def command_name(self) -> str:
56
+ return "file_read"
57
+
58
+ async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
59
+ """Read file contents asynchronously."""
60
+ file_path = message.get("path")
61
+ if not file_path:
62
+ raise ValueError("path parameter is required")
63
+
64
+ # Async file operations
65
+ import aiofiles
66
+ async with aiofiles.open(file_path, 'r') as f:
67
+ content = await f.read()
68
+
69
+ return {
70
+ "event": "file_read_response",
71
+ "path": file_path,
72
+ "content": content,
73
+ }
74
+ ```
75
+
76
+ ### 2. Synchronous Handlers
77
+
78
+ For CPU-bound operations or simple synchronous tasks, use `SyncHandler`:
79
+
80
+ ```python
81
+ from .base import SyncHandler
82
+ from typing import Any, Dict
83
+ import os
84
+
85
+ class DirectoryListHandler(SyncHandler):
86
+ """Handler for listing directory contents."""
87
+
88
+ @property
89
+ def command_name(self) -> str:
90
+ return "directory_list"
91
+
92
+ def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
93
+ """List directory contents synchronously."""
94
+ path = message.get("path", ".")
95
+
96
+ try:
97
+ items = []
98
+ for item in os.listdir(path):
99
+ item_path = os.path.join(path, item)
100
+ items.append({
101
+ "name": item,
102
+ "is_dir": os.path.isdir(item_path),
103
+ "size": os.path.getsize(item_path) if os.path.isfile(item_path) else 0,
104
+ })
105
+
106
+ return {
107
+ "event": "directory_list_response",
108
+ "path": path,
109
+ "items": items,
110
+ }
111
+ except Exception as e:
112
+ raise RuntimeError(f"Failed to list directory: {e}")
113
+ ```
114
+
115
+ ### 3. Complex Handler with Context Access
116
+
117
+ Handlers can access shared context and services:
118
+
119
+ ```python
120
+ from .base import AsyncHandler
121
+ from typing import Any, Dict
122
+
123
+ class ProcessManagementHandler(AsyncHandler):
124
+ """Handler for managing processes on the device."""
125
+
126
+ @property
127
+ def command_name(self) -> str:
128
+ return "process_management"
129
+
130
+ async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
131
+ """Manage processes with access to session manager."""
132
+ action = message.get("action") # "list", "kill", "start"
133
+
134
+ # Access session manager from context
135
+ session_manager = self.context.get("session_manager")
136
+ if not session_manager:
137
+ raise RuntimeError("Session manager not available")
138
+
139
+ if action == "list":
140
+ import psutil
141
+ processes = []
142
+ for proc in psutil.process_iter(['pid', 'name', 'cpu_percent']):
143
+ try:
144
+ processes.append(proc.info)
145
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
146
+ continue
147
+
148
+ return {
149
+ "event": "process_list_response",
150
+ "processes": processes,
151
+ }
152
+
153
+ elif action == "kill":
154
+ pid = message.get("pid")
155
+ if not pid:
156
+ raise ValueError("pid parameter required for kill action")
157
+
158
+ import psutil
159
+ try:
160
+ process = psutil.Process(pid)
161
+ process.terminate()
162
+ return {
163
+ "event": "process_killed",
164
+ "pid": pid,
165
+ }
166
+ except psutil.NoSuchProcess:
167
+ raise ValueError(f"Process {pid} not found")
168
+
169
+ else:
170
+ raise ValueError(f"Unknown action: {action}")
171
+ ```
172
+
173
+ ### 4. Handler with Custom Response Handling
174
+
175
+ You can override the `handle` method for custom response behavior:
176
+
177
+ ```python
178
+ from .base import BaseHandler
179
+ from typing import Any, Dict, Optional
180
+
181
+ class StreamingHandler(BaseHandler):
182
+ """Handler that streams responses."""
183
+
184
+ @property
185
+ def command_name(self) -> str:
186
+ return "streaming_data"
187
+
188
+ async def handle(self, message: Dict[str, Any], reply_channel: Optional[str] = None) -> None:
189
+ """Handle streaming data with custom response handling."""
190
+ try:
191
+ # Send initial response
192
+ await self.send_response({
193
+ "event": "streaming_started",
194
+ "message": "Starting data stream",
195
+ }, reply_channel)
196
+
197
+ # Stream data
198
+ for i in range(10):
199
+ await asyncio.sleep(0.1)
200
+ await self.send_response({
201
+ "event": "streaming_data",
202
+ "chunk": i,
203
+ "data": f"Data chunk {i}",
204
+ }, reply_channel)
205
+
206
+ # Send completion
207
+ await self.send_response({
208
+ "event": "streaming_completed",
209
+ "message": "Stream completed",
210
+ }, reply_channel)
211
+
212
+ except Exception as exc:
213
+ await self.send_error(str(exc), reply_channel)
214
+ ```
215
+
216
+ ## Registering Handlers
217
+
218
+ ### 1. Automatic Registration
219
+
220
+ Add your handler to the `__init__.py` file and register it in `TerminalManager._register_default_handlers()`:
221
+
222
+ ```python
223
+ # In __init__.py
224
+ from .file_handlers import FileReadHandler, DirectoryListHandler
225
+
226
+ # In terminal.py _register_default_handlers()
227
+ def _register_default_handlers(self) -> None:
228
+ """Register the default command handlers."""
229
+ # ... existing handlers ...
230
+ self._command_registry.register(FileReadHandler)
231
+ self._command_registry.register(DirectoryListHandler)
232
+ ```
233
+
234
+ ### 2. Dynamic Registration
235
+
236
+ Register handlers at runtime:
237
+
238
+ ```python
239
+ # In your code
240
+ terminal_manager.register_handler(MyCustomHandler)
241
+
242
+ # List all registered commands
243
+ commands = terminal_manager.list_commands()
244
+ print(f"Available commands: {commands}")
245
+
246
+ # Unregister if needed
247
+ terminal_manager.unregister_handler("my_command")
248
+ ```
249
+
250
+ ## Handler Guidelines
251
+
252
+ ### Best Practices
253
+
254
+ 1. **Command Naming**: Use descriptive, action-oriented names (`file_read`, `system_info`)
255
+ 2. **Error Handling**: Always validate input parameters and provide meaningful error messages
256
+ 3. **Response Format**: Use consistent response formats with `event` field
257
+ 4. **Resource Management**: Clean up resources in exception handlers
258
+ 5. **Logging**: Use appropriate logging levels for debugging and monitoring
259
+
260
+ ### Parameter Validation
261
+
262
+ ```python
263
+ def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
264
+ # Validate required parameters
265
+ required_params = ["param1", "param2"]
266
+ for param in required_params:
267
+ if param not in message:
268
+ raise ValueError(f"Missing required parameter: {param}")
269
+
270
+ # Validate parameter types
271
+ if not isinstance(message.get("count"), int):
272
+ raise ValueError("count parameter must be an integer")
273
+
274
+ # Your logic here...
275
+ ```
276
+
277
+ ### Error Handling
278
+
279
+ ```python
280
+ async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
281
+ try:
282
+ # Your logic here
283
+ result = await some_async_operation()
284
+ return {"event": "success", "result": result}
285
+ except FileNotFoundError:
286
+ raise ValueError("File not found")
287
+ except PermissionError:
288
+ raise RuntimeError("Permission denied")
289
+ except Exception as e:
290
+ raise RuntimeError(f"Unexpected error: {e}")
291
+ ```
292
+
293
+ ## Testing Handlers
294
+
295
+ ### Unit Testing
296
+
297
+ ```python
298
+ import pytest
299
+ from unittest.mock import Mock, AsyncMock
300
+ from portacode.connection.handlers.your_handler import YourHandler
301
+
302
+ @pytest.mark.asyncio
303
+ async def test_your_handler():
304
+ # Mock control channel
305
+ control_channel = AsyncMock()
306
+
307
+ # Mock context
308
+ context = {"session_manager": Mock()}
309
+
310
+ # Create handler
311
+ handler = YourHandler(control_channel, context)
312
+
313
+ # Test execute
314
+ message = {"param1": "value1"}
315
+ result = await handler.execute(message)
316
+
317
+ assert result["event"] == "expected_event"
318
+ assert result["data"] == "expected_data"
319
+ ```
320
+
321
+ ### Integration Testing
322
+
323
+ ```python
324
+ # Test with real TerminalManager
325
+ terminal_manager = TerminalManager(mock_multiplexer)
326
+ terminal_manager.register_handler(YourHandler)
327
+
328
+ # Simulate command
329
+ message = {"cmd": "your_command", "param1": "value1"}
330
+ # Process through control loop...
331
+ ```
332
+
333
+ ## Communication Protocol
334
+
335
+ ### Command Format
336
+
337
+ Commands from the gateway follow this format:
338
+
339
+ ```json
340
+ {
341
+ "cmd": "command_name",
342
+ "param1": "value1",
343
+ "param2": "value2",
344
+ "reply_channel": "optional_reply_channel"
345
+ }
346
+ ```
347
+
348
+ ### Response Format
349
+
350
+ Responses to the gateway should follow this format:
351
+
352
+ ```json
353
+ {
354
+ "event": "event_name",
355
+ "data": "response_data",
356
+ "reply_channel": "reply_channel_if_provided"
357
+ }
358
+ ```
359
+
360
+ ### Error Format
361
+
362
+ Error responses:
363
+
364
+ ```json
365
+ {
366
+ "event": "error",
367
+ "message": "Error description",
368
+ "reply_channel": "reply_channel_if_provided"
369
+ }
370
+ ```
371
+
372
+ ## Advanced Topics
373
+
374
+ ### Custom Context
375
+
376
+ You can extend the context with custom services:
377
+
378
+ ```python
379
+ # In TerminalManager._set_mux()
380
+ self._context = {
381
+ "session_manager": self._session_manager,
382
+ "mux": mux,
383
+ "file_manager": FileManager(),
384
+ "process_manager": ProcessManager(),
385
+ }
386
+ ```
387
+
388
+ ### Handler Chaining
389
+
390
+ Handlers can call other handlers:
391
+
392
+ ```python
393
+ async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
394
+ # Get another handler
395
+ other_handler = self.context.get("command_registry").get_handler("other_command")
396
+ if other_handler:
397
+ intermediate_result = await other_handler.execute(message)
398
+ # Process intermediate result...
399
+
400
+ return final_result
401
+ ```
402
+
403
+ ### Middleware Pattern
404
+
405
+ Create middleware handlers that wrap other handlers:
406
+
407
+ ```python
408
+ class LoggingMiddleware(BaseHandler):
409
+ def __init__(self, control_channel, context, wrapped_handler):
410
+ super().__init__(control_channel, context)
411
+ self.wrapped_handler = wrapped_handler
412
+
413
+ async def handle(self, message, reply_channel=None):
414
+ logger.info(f"Executing command: {message.get('cmd')}")
415
+ start_time = time.time()
416
+
417
+ try:
418
+ result = await self.wrapped_handler.handle(message, reply_channel)
419
+ duration = time.time() - start_time
420
+ logger.info(f"Command completed in {duration:.2f}s")
421
+ return result
422
+ except Exception as e:
423
+ logger.error(f"Command failed: {e}")
424
+ raise
425
+ ```
426
+
427
+ This modular system provides a clean, extensible architecture for adding new functionality to the Portacode client while maintaining backward compatibility and code organization.
@@ -14,6 +14,13 @@ from .terminal_handlers import (
14
14
  TerminalListHandler,
15
15
  )
16
16
  from .system_handlers import SystemInfoHandler
17
+ from .file_handlers import (
18
+ FileReadHandler,
19
+ FileWriteHandler,
20
+ DirectoryListHandler,
21
+ FileInfoHandler,
22
+ FileDeleteHandler,
23
+ )
17
24
 
18
25
  __all__ = [
19
26
  "BaseHandler",
@@ -25,4 +32,10 @@ __all__ = [
25
32
  "TerminalStopHandler",
26
33
  "TerminalListHandler",
27
34
  "SystemInfoHandler",
35
+ # File operation handlers (optional - register as needed)
36
+ "FileReadHandler",
37
+ "FileWriteHandler",
38
+ "DirectoryListHandler",
39
+ "FileInfoHandler",
40
+ "FileDeleteHandler",
28
41
  ]
@@ -0,0 +1,209 @@
1
+ """File operation handlers for demonstrating the send_command functionality."""
2
+
3
+ import os
4
+ import logging
5
+ from typing import Any, Dict
6
+ from pathlib import Path
7
+
8
+ from .base import AsyncHandler, SyncHandler
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class FileReadHandler(SyncHandler):
14
+ """Handler for reading file contents."""
15
+
16
+ @property
17
+ def command_name(self) -> str:
18
+ return "file_read"
19
+
20
+ def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
21
+ """Read file contents."""
22
+ file_path = message.get("path")
23
+ if not file_path:
24
+ raise ValueError("path parameter is required")
25
+
26
+ try:
27
+ with open(file_path, 'r', encoding='utf-8') as f:
28
+ content = f.read()
29
+
30
+ return {
31
+ "event": "file_read_response",
32
+ "path": file_path,
33
+ "content": content,
34
+ "size": len(content),
35
+ }
36
+ except FileNotFoundError:
37
+ raise ValueError(f"File not found: {file_path}")
38
+ except PermissionError:
39
+ raise RuntimeError(f"Permission denied: {file_path}")
40
+ except UnicodeDecodeError:
41
+ raise RuntimeError(f"File is not text or uses unsupported encoding: {file_path}")
42
+
43
+
44
+ class FileWriteHandler(SyncHandler):
45
+ """Handler for writing file contents."""
46
+
47
+ @property
48
+ def command_name(self) -> str:
49
+ return "file_write"
50
+
51
+ def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
52
+ """Write file contents."""
53
+ file_path = message.get("path")
54
+ content = message.get("content", "")
55
+
56
+ if not file_path:
57
+ raise ValueError("path parameter is required")
58
+
59
+ try:
60
+ # Create parent directories if they don't exist
61
+ Path(file_path).parent.mkdir(parents=True, exist_ok=True)
62
+
63
+ with open(file_path, 'w', encoding='utf-8') as f:
64
+ f.write(content)
65
+
66
+ return {
67
+ "event": "file_write_response",
68
+ "path": file_path,
69
+ "bytes_written": len(content.encode('utf-8')),
70
+ "success": True,
71
+ }
72
+ except PermissionError:
73
+ raise RuntimeError(f"Permission denied: {file_path}")
74
+ except OSError as e:
75
+ raise RuntimeError(f"Failed to write file: {e}")
76
+
77
+
78
+ class DirectoryListHandler(SyncHandler):
79
+ """Handler for listing directory contents."""
80
+
81
+ @property
82
+ def command_name(self) -> str:
83
+ return "directory_list"
84
+
85
+ def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
86
+ """List directory contents."""
87
+ path = message.get("path", ".")
88
+ show_hidden = message.get("show_hidden", False)
89
+
90
+ try:
91
+ items = []
92
+ for item in os.listdir(path):
93
+ # Skip hidden files unless requested
94
+ if not show_hidden and item.startswith('.'):
95
+ continue
96
+
97
+ item_path = os.path.join(path, item)
98
+ try:
99
+ stat_info = os.stat(item_path)
100
+ items.append({
101
+ "name": item,
102
+ "is_dir": os.path.isdir(item_path),
103
+ "is_file": os.path.isfile(item_path),
104
+ "size": stat_info.st_size,
105
+ "modified": stat_info.st_mtime,
106
+ "permissions": oct(stat_info.st_mode)[-3:],
107
+ })
108
+ except (OSError, PermissionError):
109
+ # Skip items we can't stat
110
+ continue
111
+
112
+ return {
113
+ "event": "directory_list_response",
114
+ "path": path,
115
+ "items": items,
116
+ "count": len(items),
117
+ }
118
+ except FileNotFoundError:
119
+ raise ValueError(f"Directory not found: {path}")
120
+ except PermissionError:
121
+ raise RuntimeError(f"Permission denied: {path}")
122
+ except NotADirectoryError:
123
+ raise ValueError(f"Path is not a directory: {path}")
124
+
125
+
126
+ class FileInfoHandler(SyncHandler):
127
+ """Handler for getting file/directory information."""
128
+
129
+ @property
130
+ def command_name(self) -> str:
131
+ return "file_info"
132
+
133
+ def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
134
+ """Get file or directory information."""
135
+ path = message.get("path")
136
+ if not path:
137
+ raise ValueError("path parameter is required")
138
+
139
+ try:
140
+ stat_info = os.stat(path)
141
+
142
+ return {
143
+ "event": "file_info_response",
144
+ "path": path,
145
+ "exists": True,
146
+ "is_file": os.path.isfile(path),
147
+ "is_dir": os.path.isdir(path),
148
+ "is_symlink": os.path.islink(path),
149
+ "size": stat_info.st_size,
150
+ "modified": stat_info.st_mtime,
151
+ "accessed": stat_info.st_atime,
152
+ "created": stat_info.st_ctime,
153
+ "permissions": oct(stat_info.st_mode)[-3:],
154
+ "owner_uid": stat_info.st_uid,
155
+ "group_gid": stat_info.st_gid,
156
+ }
157
+ except FileNotFoundError:
158
+ return {
159
+ "event": "file_info_response",
160
+ "path": path,
161
+ "exists": False,
162
+ }
163
+ except PermissionError:
164
+ raise RuntimeError(f"Permission denied: {path}")
165
+
166
+
167
+ class FileDeleteHandler(SyncHandler):
168
+ """Handler for deleting files and directories."""
169
+
170
+ @property
171
+ def command_name(self) -> str:
172
+ return "file_delete"
173
+
174
+ def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
175
+ """Delete a file or directory."""
176
+ path = message.get("path")
177
+ recursive = message.get("recursive", False)
178
+
179
+ if not path:
180
+ raise ValueError("path parameter is required")
181
+
182
+ try:
183
+ if os.path.isfile(path):
184
+ os.remove(path)
185
+ deleted_type = "file"
186
+ elif os.path.isdir(path):
187
+ if recursive:
188
+ import shutil
189
+ shutil.rmtree(path)
190
+ else:
191
+ os.rmdir(path)
192
+ deleted_type = "directory"
193
+ else:
194
+ raise ValueError(f"Path does not exist: {path}")
195
+
196
+ return {
197
+ "event": "file_delete_response",
198
+ "path": path,
199
+ "deleted_type": deleted_type,
200
+ "success": True,
201
+ }
202
+ except FileNotFoundError:
203
+ raise ValueError(f"Path not found: {path}")
204
+ except PermissionError:
205
+ raise RuntimeError(f"Permission denied: {path}")
206
+ except OSError as e:
207
+ if "Directory not empty" in str(e):
208
+ raise ValueError(f"Directory not empty (use recursive=True): {path}")
209
+ raise RuntimeError(f"Failed to delete: {e}")
@@ -164,13 +164,10 @@ class SessionManager:
164
164
  def __init__(self, mux):
165
165
  self.mux = mux
166
166
  self._sessions: Dict[str, TerminalSession] = {}
167
- self._next_channel = 100
168
167
 
169
- def _allocate_channel_id(self) -> int:
170
- """Allocate a new channel ID for a terminal session."""
171
- cid = self._next_channel
172
- self._next_channel += 1
173
- return cid
168
+ def _allocate_channel_id(self) -> str:
169
+ """Allocate a new unique channel ID for a terminal session using UUID."""
170
+ return uuid.uuid4().hex
174
171
 
175
172
  async def create_session(self, shell: Optional[str] = None, cwd: Optional[str] = None) -> Dict[str, Any]:
176
173
  """Create a new terminal session."""
@@ -182,7 +179,7 @@ class SessionManager:
182
179
  if shell is None:
183
180
  shell = os.getenv("SHELL") if not _IS_WINDOWS else os.getenv("COMSPEC", "cmd.exe")
184
181
 
185
- logger.info("Launching terminal %s using shell=%s on channel=%d", term_id, shell, channel_id)
182
+ logger.info("Launching terminal %s using shell=%s on channel=%s", term_id, shell, channel_id)
186
183
 
187
184
  if _IS_WINDOWS:
188
185
  try:
@@ -265,11 +262,8 @@ class SessionManager:
265
262
  def reattach_sessions(self, mux):
266
263
  """Reattach sessions to a new multiplexer after reconnection."""
267
264
  self.mux = mux
268
- highest_cid = self._next_channel
269
265
 
270
266
  for sess in self._sessions.values():
271
- cid = sess.channel.id
272
- sess.channel = self.mux.get_channel(cid)
273
- highest_cid = max(highest_cid, cid + 1)
274
-
275
- self._next_channel = max(self._next_channel, highest_cid)
267
+ # Get the existing channel ID (now UUID string)
268
+ channel_id = sess.channel.id
269
+ sess.channel = self.mux.get_channel(channel_id)
@@ -4,7 +4,7 @@ import asyncio
4
4
  import json
5
5
  import logging
6
6
  from asyncio import Queue
7
- from typing import Any, Dict
7
+ from typing import Any, Dict, Union
8
8
 
9
9
  __all__ = ["Multiplexer", "Channel"]
10
10
 
@@ -14,7 +14,7 @@ logger = logging.getLogger(__name__)
14
14
  class Channel:
15
15
  """Represents a virtual duplex channel over a single WebSocket connection."""
16
16
 
17
- def __init__(self, channel_id: int, multiplexer: "Multiplexer"):
17
+ def __init__(self, channel_id: Union[int, str], multiplexer: "Multiplexer"):
18
18
  self.id = channel_id
19
19
  self._mux = multiplexer
20
20
  self._incoming: "Queue[Any]" = asyncio.Queue()
@@ -35,27 +35,27 @@ class Multiplexer:
35
35
 
36
36
  Messages exchanged over the WebSocket are JSON objects with two keys:
37
37
 
38
- * ``channel`` – integer channel id.
38
+ * ``channel`` – integer or string channel id.
39
39
  * ``payload`` – arbitrary JSON-serialisable object.
40
40
  """
41
41
 
42
42
  def __init__(self, send_func):
43
43
  self._send_func = send_func # async function (str) -> None
44
- self._channels: Dict[int, Channel] = {}
44
+ self._channels: Dict[Union[int, str], Channel] = {}
45
45
 
46
- def get_channel(self, channel_id: int) -> Channel:
46
+ def get_channel(self, channel_id: Union[int, str]) -> Channel:
47
47
  if channel_id not in self._channels:
48
48
  self._channels[channel_id] = Channel(channel_id, self)
49
49
  return self._channels[channel_id]
50
50
 
51
- async def _send_on_channel(self, channel_id: int, payload: Any) -> None:
51
+ async def _send_on_channel(self, channel_id: Union[int, str], payload: Any) -> None:
52
52
  frame = json.dumps({"channel": channel_id, "payload": payload})
53
53
  await self._send_func(frame)
54
54
 
55
55
  async def on_raw_message(self, raw: str) -> None:
56
56
  try:
57
57
  data = json.loads(raw)
58
- channel_id = int(data["channel"])
58
+ channel_id = data["channel"] # Can be int or str now
59
59
  payload = data.get("payload")
60
60
  except (ValueError, KeyError) as exc:
61
61
  logger.warning("Discarding malformed frame: %s", exc)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: portacode
3
- Version: 0.2.3.dev0
3
+ Version: 0.2.4.dev0
4
4
  Summary: Portacode CLI client and SDK
5
5
  Home-page: https://github.com/portacode/portacode
6
6
  Author: Meena Erian
@@ -25,8 +25,10 @@ portacode/connection/__init__.py
25
25
  portacode/connection/client.py
26
26
  portacode/connection/multiplex.py
27
27
  portacode/connection/terminal.py
28
+ portacode/connection/handlers/README.md
28
29
  portacode/connection/handlers/__init__.py
29
30
  portacode/connection/handlers/base.py
31
+ portacode/connection/handlers/file_handlers.py
30
32
  portacode/connection/handlers/registry.py
31
33
  portacode/connection/handlers/session.py
32
34
  portacode/connection/handlers/system_handlers.py
File without changes
File without changes
File without changes
File without changes