portacode 0.3.12.dev8__tar.gz → 0.3.12.dev9__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 (43) hide show
  1. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/PKG-INFO +1 -1
  2. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/portacode/_version.py +2 -2
  3. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +79 -11
  4. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/portacode/connection/handlers/session.py +108 -34
  5. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/portacode.egg-info/PKG-INFO +1 -1
  6. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/.claude/agents/communication-manager.md +0 -0
  7. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/.claude/settings.local.json +0 -0
  8. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/.gitignore +0 -0
  9. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/.gitmodules +0 -0
  10. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/LICENSE +0 -0
  11. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/MANIFEST.in +0 -0
  12. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/Makefile +0 -0
  13. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/README.md +0 -0
  14. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/backup.sh +0 -0
  15. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/docker-compose.yaml +0 -0
  16. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/portacode/README.md +0 -0
  17. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/portacode/__init__.py +0 -0
  18. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/portacode/__main__.py +0 -0
  19. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/portacode/cli.py +0 -0
  20. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/portacode/connection/README.md +0 -0
  21. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/portacode/connection/__init__.py +0 -0
  22. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/portacode/connection/client.py +0 -0
  23. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/portacode/connection/handlers/README.md +0 -0
  24. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/portacode/connection/handlers/__init__.py +0 -0
  25. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/portacode/connection/handlers/base.py +0 -0
  26. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/portacode/connection/handlers/file_handlers.py +0 -0
  27. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/portacode/connection/handlers/registry.py +0 -0
  28. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/portacode/connection/handlers/system_handlers.py +0 -0
  29. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/portacode/connection/handlers/terminal_handlers.py +0 -0
  30. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/portacode/connection/multiplex.py +0 -0
  31. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/portacode/connection/terminal.py +0 -0
  32. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/portacode/data.py +0 -0
  33. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/portacode/keypair.py +0 -0
  34. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/portacode/service.py +0 -0
  35. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/portacode.egg-info/SOURCES.txt +0 -0
  36. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/portacode.egg-info/dependency_links.txt +0 -0
  37. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/portacode.egg-info/entry_points.txt +0 -0
  38. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/portacode.egg-info/requires.txt +0 -0
  39. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/portacode.egg-info/top_level.txt +0 -0
  40. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/pyproject.toml +0 -0
  41. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/restore.sh +0 -0
  42. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/setup.cfg +0 -0
  43. {portacode-0.3.12.dev8 → portacode-0.3.12.dev9}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: portacode
3
- Version: 0.3.12.dev8
3
+ Version: 0.3.12.dev9
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.3.12.dev8'
21
- __version_tuple__ = version_tuple = (0, 3, 12, 'dev8')
20
+ __version__ = version = '0.3.12.dev9'
21
+ __version_tuple__ = version_tuple = (0, 3, 12, 'dev9')
@@ -26,6 +26,7 @@ This document outlines the WebSocket communication protocol between the Portacod
26
26
  - [`error`](#error)
27
27
  - [Terminal Events](#terminal-events)
28
28
  - [`terminal_started`](#terminal_started)
29
+ - [`terminal_data`](#terminal_data)
29
30
  - [`terminal_exit`](#terminal_exit)
30
31
  - [`terminal_send_ack`](#terminal_send_ack)
31
32
  - [`terminal_stopped`](#terminal_stopped)
@@ -79,14 +80,14 @@ Actions are messages sent from the server to the device, placed within the `payl
79
80
  "payload": {
80
81
  "arg1": "value1",
81
82
  "...": "..."
82
- },
83
- "reply_channel": "<channel_name>"
83
+ }
84
84
  }
85
85
  ```
86
86
 
87
87
  * `command` (string, mandatory): The name of the action to be executed (e.g., `terminal_start`).
88
88
  * `payload` (object, mandatory): An object containing the specific arguments for the action.
89
- * `reply_channel` (string, optional): **DEPRECATED** - A channel name for backward compatibility. Modern implementations should use the `client_sessions` mechanism instead.
89
+
90
+ **Note**: Actions do not require targeting information - responses are automatically routed using the client session management system.
90
91
 
91
92
  ### `terminal_start`
92
93
 
@@ -248,13 +249,22 @@ Events are messages sent from the device to the server, placed within the `paylo
248
249
  {
249
250
  "event": "<event_name>",
250
251
  // Event-specific fields...
251
- "reply_channel": "<channel_name>"
252
+ "device_id": 123,
253
+ "project_id": "<project_uuid>",
254
+ "client_sessions": ["channel.abc123", "channel.def456"]
252
255
  }
253
256
  ```
254
257
 
258
+ **Standard Fields (automatically added by the system):**
259
+
255
260
  * `event` (string, mandatory): The name of the event being sent (e.g., `terminal_started`).
256
- * <a name="reply_channel"></a>`reply_channel` (string, optional): **DEPRECATED** - For backward compatibility only. Modern events include `client_sessions` array for targeting.
257
- * `client_sessions` (array, optional): Array of client session channel names that should receive this event. This is the modern way to target specific connected clients.
261
+ * `device_id` (integer, mandatory): The ID of the authenticated device that generated this event. **Added by the server based on the authenticated connection for security** - devices cannot self-identify.
262
+ * `project_id` (string, optional): The project UUID associated with this event, used for project-scoped filtering. Sent by the device.
263
+ * `client_sessions` (array, optional): Array of client session channel names that should receive this event. **Added by the device's terminal manager** based on interested client sessions. When present, the event is sent only to these specific sessions. When absent, the event is broadcast to all sessions for the device owner.
264
+
265
+ **Event-Specific Fields:**
266
+
267
+ Each event type includes additional fields specific to its purpose, documented in the individual event sections below.
258
268
 
259
269
  ### <a name="error"></a>`error`
260
270
 
@@ -274,6 +284,41 @@ Confirms that a new terminal session has been successfully started. Triggered by
274
284
  * `channel` (string, mandatory): The channel name for terminal I/O.
275
285
  * `project_id` (string, optional): The project ID associated with the terminal.
276
286
 
287
+ ### <a name="terminal_data"></a>`terminal_data`
288
+
289
+ Streams real-time terminal output data from a running terminal session to connected clients. This event is automatically generated whenever the terminal process produces output (stdout/stderr). Generated by [`TerminalSession`](./session.py) through the terminal manager's session-aware messaging system.
290
+
291
+ **Event-Specific Fields:**
292
+
293
+ * `channel` (string, mandatory): The terminal UUID identifying which terminal session produced this output. This matches the `terminal_id` from the corresponding `terminal_started` event.
294
+ * `data` (string, mandatory): The raw terminal output data. **See detailed description below.**
295
+
296
+ **The `data` Field - Detailed Specification:**
297
+
298
+ The `data` field contains the exact bytes output by the terminal process, decoded as a UTF-8 string with error handling:
299
+
300
+ * **Encoding**: UTF-8 with `errors="ignore"` - invalid UTF-8 sequences are silently dropped
301
+ * **Content**: Raw terminal output including:
302
+ - Regular command output (stdout)
303
+ - Error messages (stderr) - combined with stdout in PTY mode
304
+ - ANSI escape sequences for colors, cursor positioning, screen clearing, etc.
305
+ - Control characters (newlines, tabs, backspace, etc.)
306
+ - Shell prompts and interactive application output
307
+ * **Buffering**: Data is read in 1024-byte chunks from the terminal process and sent immediately (no line buffering)
308
+ * **Binary Safety**: Binary data is handled via UTF-8 decoding with error tolerance
309
+ * **Size**: Individual chunks are typically ≤1024 characters, but can be smaller for real-time responsiveness
310
+
311
+ **Examples of `data` content:**
312
+ ```
313
+ "Hello, World!\n" // Simple command output
314
+ "\u001b[32mSuccess\u001b[0m\n" // ANSI colored text
315
+ "user@host:~/project$ " // Shell prompt
316
+ "\u001b[2J\u001b[H" // Clear screen escape sequence
317
+ "Progress: [████████████████████] 100%\r" // Progress bar with carriage return
318
+ ```
319
+
320
+ **Security Note**: The `device_id` field is automatically injected by the server based on the authenticated connection - the device cannot and should not specify its own ID. The `project_id` and `client_sessions` fields are added by the device's terminal manager for proper routing and filtering.
321
+
277
322
  ### <a name="terminal_exit"></a>`terminal_exit`
278
323
 
279
324
  Notifies the server that a terminal session has terminated. This can be due to the process ending or the session being stopped. Handled by [`terminal_start`](./terminal_handlers.py).
@@ -402,11 +447,33 @@ This event carries no additional fields.
402
447
 
403
448
  ### Terminal Data
404
449
 
405
- ### <a name="terminal_data"></a>Terminal I/O Data
450
+ ### Terminal I/O Data Formats
451
+
452
+ Terminal I/O data can be sent in two formats depending on the implementation:
453
+
454
+ #### Modern Format (Recommended)
455
+
456
+ Terminal data is sent as a proper [`terminal_data`](#terminal_data) event on the control channel (channel 0) with client session targeting support:
457
+
458
+ ```json
459
+ {
460
+ "channel": 0,
461
+ "payload": {
462
+ "event": "terminal_data",
463
+ "channel": "<terminal_uuid>",
464
+ "data": "<terminal_output_string>",
465
+ "device_id": 123,
466
+ "project_id": "<project_uuid>",
467
+ "client_sessions": ["channel.abc123", "channel.def456"]
468
+ }
469
+ }
470
+ ```
471
+
472
+ This format follows the standard event structure with automatic system field injection (device_id, project_id, client_sessions) for proper routing and security.
406
473
 
407
- Terminal input/output data is sent directly on terminal channels (not on the control channel). Each terminal session has its own dedicated channel identified by the terminal's UUID.
474
+ #### Legacy Format (Deprecated)
408
475
 
409
- **Terminal Data Format:**
476
+ Terminal data sent directly on terminal channels (not on the control channel):
410
477
 
411
478
  ```json
412
479
  {
@@ -416,8 +483,9 @@ Terminal input/output data is sent directly on terminal channels (not on the con
416
483
  ```
417
484
 
418
485
  * Terminal output is sent as raw string data in the payload
419
- * Input to terminals is sent the same way but in the opposite direction
420
- * No event wrapper is used for terminal I/O data
486
+ * Input to terminals is sent the same way but in the opposite direction
487
+ * No event wrapper or client targeting is used
488
+ * This format broadcasts to all sessions for the device owner
421
489
 
422
490
  ### Server-Side Events
423
491
 
@@ -4,6 +4,7 @@ import asyncio
4
4
  import logging
5
5
  import os
6
6
  import sys
7
+ import time
7
8
  import uuid
8
9
  from asyncio.subprocess import Process
9
10
  from pathlib import Path
@@ -13,6 +14,10 @@ from collections import deque
13
14
  if TYPE_CHECKING:
14
15
  from ..multiplex import Channel
15
16
 
17
+ # Terminal data rate limiting configuration
18
+ TERMINAL_DATA_RATE_LIMIT_MS = 200 # Minimum time between terminal_data events (milliseconds)
19
+ TERMINAL_DATA_MAX_WAIT_MS = 1000 # Maximum time to wait before sending accumulated data (milliseconds)
20
+
16
21
  logger = logging.getLogger(__name__)
17
22
 
18
23
  _IS_WINDOWS = sys.platform.startswith("win")
@@ -44,6 +49,11 @@ class TerminalSession:
44
49
  self.terminal_manager = terminal_manager
45
50
  self._reader_task: Optional[asyncio.Task[None]] = None
46
51
  self._buffer: deque[str] = deque(maxlen=400)
52
+
53
+ # Rate limiting for terminal_data events
54
+ self._last_send_time: float = 0
55
+ self._pending_data: str = ""
56
+ self._debounce_task: Optional[asyncio.Task[None]] = None
47
57
 
48
58
  async def start_io_forwarding(self) -> None:
49
59
  """Spawn background task that copies stdout/stderr to the channel."""
@@ -57,23 +67,9 @@ class TerminalSession:
57
67
  break
58
68
  text = data.decode(errors="ignore")
59
69
  logging.getLogger("portacode.terminal").debug(f"[MUX] Terminal {self.id} output: {text!r}")
60
- self._buffer.append(text)
61
- try:
62
- # Send terminal data via control channel with client session targeting
63
- if self.terminal_manager:
64
- await self.terminal_manager._send_session_aware({
65
- "event": "terminal_data",
66
- "channel": self.id,
67
- "data": text,
68
- "project_id": self.project_id
69
- }, project_id=self.project_id)
70
- else:
71
- # Fallback to raw channel for backward compatibility
72
- await self.channel.send(text)
73
- except Exception as exc:
74
- logger.warning("Failed to forward terminal output: %s", exc)
75
- await asyncio.sleep(0.5)
76
- continue
70
+
71
+ # Use rate-limited sending instead of immediate sending
72
+ await self._handle_terminal_data(text)
77
73
  finally:
78
74
  if self.proc and self.proc.returncode is None:
79
75
  pass # Keep alive across reconnects
@@ -129,6 +125,20 @@ class TerminalSession:
129
125
  except asyncio.CancelledError:
130
126
  pass
131
127
 
128
+ # Cancel and flush any pending terminal data
129
+ if self._debounce_task and not self._debounce_task.done():
130
+ logger.info("session.stop: Cancelling debounce task for session %s", self.id)
131
+ self._debounce_task.cancel()
132
+ try:
133
+ await self._debounce_task
134
+ except asyncio.CancelledError:
135
+ pass
136
+
137
+ # Send any remaining pending data
138
+ if self._pending_data:
139
+ logger.info("session.stop: Flushing pending terminal data for session %s", self.id)
140
+ await self._flush_pending_data()
141
+
132
142
  # Wait for process to exit
133
143
  if self.proc.returncode is None:
134
144
  logger.info("session.stop: Waiting for process to exit for session %s", self.id)
@@ -143,6 +153,70 @@ class TerminalSession:
143
153
  logger.exception("session.stop: Error stopping session %s: %s", self.id, exc)
144
154
  raise
145
155
 
156
+ async def _send_terminal_data_now(self, data: str) -> None:
157
+ """Send terminal data immediately and update last send time."""
158
+ self._last_send_time = time.time()
159
+
160
+ # Add to buffer for snapshots
161
+ self._buffer.append(data)
162
+
163
+ try:
164
+ # Send terminal data via control channel with client session targeting
165
+ if self.terminal_manager:
166
+ await self.terminal_manager._send_session_aware({
167
+ "event": "terminal_data",
168
+ "channel": self.id,
169
+ "data": data,
170
+ "project_id": self.project_id
171
+ }, project_id=self.project_id)
172
+ else:
173
+ # Fallback to raw channel for backward compatibility
174
+ await self.channel.send(data)
175
+ except Exception as exc:
176
+ logger.warning("Failed to forward terminal output: %s", exc)
177
+
178
+ async def _flush_pending_data(self) -> None:
179
+ """Send accumulated pending data and reset pending buffer."""
180
+ if self._pending_data:
181
+ data_to_send = self._pending_data
182
+ self._pending_data = ""
183
+ await self._send_terminal_data_now(data_to_send)
184
+
185
+ # Clear the debounce task
186
+ self._debounce_task = None
187
+
188
+ async def _handle_terminal_data(self, data: str) -> None:
189
+ """Handle new terminal data with rate limiting and debouncing."""
190
+ current_time = time.time()
191
+ time_since_last_send = (current_time - self._last_send_time) * 1000 # Convert to milliseconds
192
+
193
+ # Add new data to pending buffer
194
+ self._pending_data += data
195
+
196
+ # Cancel existing debounce task if any
197
+ if self._debounce_task and not self._debounce_task.done():
198
+ self._debounce_task.cancel()
199
+
200
+ if time_since_last_send >= TERMINAL_DATA_RATE_LIMIT_MS:
201
+ # Enough time has passed, send immediately
202
+ await self._flush_pending_data()
203
+ else:
204
+ # Too soon, set up debounce timer
205
+ async def _debounce_timer():
206
+ try:
207
+ # Wait for either the rate limit period or max wait time
208
+ wait_time = min(
209
+ (TERMINAL_DATA_RATE_LIMIT_MS - time_since_last_send) / 1000,
210
+ TERMINAL_DATA_MAX_WAIT_MS / 1000
211
+ )
212
+ await asyncio.sleep(wait_time)
213
+ await self._flush_pending_data()
214
+ except asyncio.CancelledError:
215
+ # Timer was cancelled, another data event came in
216
+ pass
217
+
218
+ self._debounce_task = asyncio.create_task(_debounce_timer())
219
+
146
220
  def snapshot_buffer(self) -> str:
147
221
  """Return concatenated last buffer contents suitable for UI."""
148
222
  return "".join(self._buffer)
@@ -197,23 +271,9 @@ class WindowsTerminalSession(TerminalSession):
197
271
  else:
198
272
  text = data
199
273
  logging.getLogger("portacode.terminal").debug(f"[MUX] Terminal {self.id} output: {text!r}")
200
- self._buffer.append(text)
201
- try:
202
- # Send terminal data via control channel with client session targeting
203
- if self.terminal_manager:
204
- await self.terminal_manager._send_session_aware({
205
- "event": "terminal_data",
206
- "channel": self.id,
207
- "data": text,
208
- "project_id": self.project_id
209
- }, project_id=self.project_id)
210
- else:
211
- # Fallback to raw channel for backward compatibility
212
- await self.channel.send(text)
213
- except Exception as exc:
214
- logger.warning("Failed to forward terminal output: %s", exc)
215
- await asyncio.sleep(0.5)
216
- continue
274
+
275
+ # Use rate-limited sending instead of immediate sending
276
+ await self._handle_terminal_data(text)
217
277
  finally:
218
278
  if self._pty and self._pty.isalive():
219
279
  self._pty.kill()
@@ -258,6 +318,20 @@ class WindowsTerminalSession(TerminalSession):
258
318
  except asyncio.CancelledError:
259
319
  pass
260
320
 
321
+ # Cancel and flush any pending terminal data
322
+ if self._debounce_task and not self._debounce_task.done():
323
+ logger.info("session.stop: Cancelling debounce task for Windows session %s", self.id)
324
+ self._debounce_task.cancel()
325
+ try:
326
+ await self._debounce_task
327
+ except asyncio.CancelledError:
328
+ pass
329
+
330
+ # Send any remaining pending data
331
+ if self._pending_data:
332
+ logger.info("session.stop: Flushing pending terminal data for Windows session %s", self.id)
333
+ await self._flush_pending_data()
334
+
261
335
  logger.info("session.stop: Successfully stopped Windows session %s", self.id)
262
336
 
263
337
  except Exception as exc:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: portacode
3
- Version: 0.3.12.dev8
3
+ Version: 0.3.12.dev9
4
4
  Summary: Portacode CLI client and SDK
5
5
  Home-page: https://github.com/portacode/portacode
6
6
  Author: Meena Erian
File without changes