portacode 0.3.12.dev1__tar.gz → 0.3.12.dev3__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 (42) hide show
  1. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/PKG-INFO +1 -1
  2. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/portacode/_version.py +2 -2
  3. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/portacode/connection/handlers/base.py +52 -16
  4. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/portacode/connection/handlers/registry.py +34 -5
  5. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/portacode/connection/handlers/terminal_handlers.py +10 -10
  6. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/portacode/connection/terminal.py +69 -2
  7. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/portacode.egg-info/PKG-INFO +1 -1
  8. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/.claude/settings.local.json +0 -0
  9. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/.gitignore +0 -0
  10. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/.gitmodules +0 -0
  11. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/LICENSE +0 -0
  12. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/MANIFEST.in +0 -0
  13. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/Makefile +0 -0
  14. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/README.md +0 -0
  15. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/backup.sh +0 -0
  16. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/docker-compose.yaml +0 -0
  17. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/portacode/README.md +0 -0
  18. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/portacode/__init__.py +0 -0
  19. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/portacode/__main__.py +0 -0
  20. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/portacode/cli.py +0 -0
  21. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/portacode/connection/README.md +0 -0
  22. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/portacode/connection/__init__.py +0 -0
  23. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/portacode/connection/client.py +0 -0
  24. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/portacode/connection/handlers/README.md +0 -0
  25. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +0 -0
  26. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/portacode/connection/handlers/__init__.py +0 -0
  27. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/portacode/connection/handlers/file_handlers.py +0 -0
  28. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/portacode/connection/handlers/session.py +0 -0
  29. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/portacode/connection/handlers/system_handlers.py +0 -0
  30. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/portacode/connection/multiplex.py +0 -0
  31. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/portacode/data.py +0 -0
  32. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/portacode/keypair.py +0 -0
  33. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/portacode/service.py +0 -0
  34. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/portacode.egg-info/SOURCES.txt +0 -0
  35. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/portacode.egg-info/dependency_links.txt +0 -0
  36. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/portacode.egg-info/entry_points.txt +0 -0
  37. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/portacode.egg-info/requires.txt +0 -0
  38. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/portacode.egg-info/top_level.txt +0 -0
  39. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/pyproject.toml +0 -0
  40. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/restore.sh +0 -0
  41. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/setup.cfg +0 -0
  42. {portacode-0.3.12.dev1 → portacode-0.3.12.dev3}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: portacode
3
- Version: 0.3.12.dev1
3
+ Version: 0.3.12.dev3
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.dev1'
21
- __version_tuple__ = version_tuple = (0, 3, 12, 'dev1')
20
+ __version__ = version = '0.3.12.dev3'
21
+ __version_tuple__ = version_tuple = (0, 3, 12, 'dev3')
@@ -40,28 +40,54 @@ class BaseHandler(ABC):
40
40
  """
41
41
  pass
42
42
 
43
- async def send_response(self, payload: Dict[str, Any], reply_channel: Optional[str] = None) -> None:
44
- """Send a response back to the gateway.
43
+ async def send_response(self, payload: Dict[str, Any], reply_channel: Optional[str] = None, project_id: str = None) -> None:
44
+ """Send a response back to the gateway with client session awareness.
45
45
 
46
46
  Args:
47
47
  payload: Response payload
48
- reply_channel: Optional reply channel
48
+ reply_channel: Optional reply channel for backward compatibility
49
+ project_id: Optional project filter for targeting specific sessions
49
50
  """
50
- if reply_channel:
51
- payload["reply_channel"] = reply_channel
52
- await self.control_channel.send(payload)
51
+ # Get client session manager from context
52
+ client_session_manager = self.context.get("client_session_manager")
53
+
54
+ if client_session_manager and client_session_manager.has_interested_clients():
55
+ # Get target sessions
56
+ target_sessions = client_session_manager.get_target_sessions(project_id)
57
+ if not target_sessions:
58
+ logger.debug("handler: No target sessions found, skipping response send")
59
+ return
60
+
61
+ # Add session targeting information
62
+ enhanced_payload = dict(payload)
63
+ enhanced_payload["client_sessions"] = target_sessions
64
+
65
+ # Add backward compatibility reply_channel (first session if not provided)
66
+ if not reply_channel:
67
+ reply_channel = client_session_manager.get_reply_channel_for_compatibility()
68
+ if reply_channel:
69
+ enhanced_payload["reply_channel"] = reply_channel
70
+
71
+ logger.debug("handler: Sending response to %d client sessions: %s",
72
+ len(target_sessions), target_sessions)
73
+
74
+ await self.control_channel.send(enhanced_payload)
75
+ else:
76
+ # Fallback to original behavior if no client session manager or no clients
77
+ if reply_channel:
78
+ payload["reply_channel"] = reply_channel
79
+ await self.control_channel.send(payload)
53
80
 
54
- async def send_error(self, message: str, reply_channel: Optional[str] = None) -> None:
55
- """Send an error response.
81
+ async def send_error(self, message: str, reply_channel: Optional[str] = None, project_id: str = None) -> None:
82
+ """Send an error response with client session awareness.
56
83
 
57
84
  Args:
58
85
  message: Error message
59
- reply_channel: Optional reply channel
86
+ reply_channel: Optional reply channel for backward compatibility
87
+ project_id: Optional project filter for targeting specific sessions
60
88
  """
61
89
  payload = {"event": "error", "message": message}
62
- if reply_channel:
63
- payload["reply_channel"] = reply_channel
64
- await self.control_channel.send(payload)
90
+ await self.send_response(payload, reply_channel, project_id)
65
91
 
66
92
 
67
93
  class AsyncHandler(BaseHandler):
@@ -87,10 +113,15 @@ class AsyncHandler(BaseHandler):
87
113
  try:
88
114
  response = await self.execute(message)
89
115
  logger.info("handler: Command %s executed successfully", self.command_name)
90
- await self.send_response(response, reply_channel)
116
+
117
+ # Extract project_id from response for session targeting
118
+ project_id = response.get("project_id")
119
+ await self.send_response(response, reply_channel, project_id)
91
120
  except Exception as exc:
92
121
  logger.exception("handler: Error in async handler %s: %s", self.command_name, exc)
93
- await self.send_error(str(exc), reply_channel)
122
+ # Extract project_id from original message for error targeting
123
+ project_id = message.get("project_id")
124
+ await self.send_error(str(exc), reply_channel, project_id)
94
125
 
95
126
 
96
127
  class SyncHandler(BaseHandler):
@@ -113,7 +144,12 @@ class SyncHandler(BaseHandler):
113
144
  try:
114
145
  loop = asyncio.get_running_loop()
115
146
  response = await loop.run_in_executor(None, self.execute, message)
116
- await self.send_response(response, reply_channel)
147
+
148
+ # Extract project_id from response for session targeting
149
+ project_id = response.get("project_id")
150
+ await self.send_response(response, reply_channel, project_id)
117
151
  except Exception as exc:
118
152
  logger.exception("Error in sync handler %s: %s", self.command_name, exc)
119
- await self.send_error(str(exc), reply_channel)
153
+ # Extract project_id from original message for error targeting
154
+ project_id = message.get("project_id")
155
+ await self.send_error(str(exc), reply_channel, project_id)
@@ -94,13 +94,42 @@ class CommandRegistry:
94
94
  return True
95
95
  except Exception as exc:
96
96
  logger.exception("registry: Error dispatching command %s: %s", command_name, exc)
97
- # Send error response
98
- error_payload = {"event": "error", "message": str(exc)}
99
- if reply_channel:
100
- error_payload["reply_channel"] = reply_channel
101
- await self.control_channel.send(error_payload)
97
+ # Send session-aware error response
98
+ await self._send_session_aware_error(str(exc), reply_channel, message.get("project_id"))
102
99
  return False
103
100
 
101
+ async def _send_session_aware_error(self, message: str, reply_channel: Optional[str] = None, project_id: str = None) -> None:
102
+ """Send an error response with client session awareness."""
103
+ error_payload = {"event": "error", "message": message}
104
+
105
+ # Get client session manager from context
106
+ client_session_manager = self.context.get("client_session_manager")
107
+
108
+ if client_session_manager and client_session_manager.has_interested_clients():
109
+ # Get target sessions
110
+ target_sessions = client_session_manager.get_target_sessions(project_id)
111
+ if not target_sessions:
112
+ logger.debug("registry: No target sessions found, skipping error send")
113
+ return
114
+
115
+ # Add session targeting information
116
+ error_payload["client_sessions"] = target_sessions
117
+
118
+ # Add backward compatibility reply_channel (first session if not provided)
119
+ if not reply_channel:
120
+ reply_channel = client_session_manager.get_reply_channel_for_compatibility()
121
+ if reply_channel:
122
+ error_payload["reply_channel"] = reply_channel
123
+
124
+ logger.debug("registry: Sending error to %d client sessions: %s",
125
+ len(target_sessions), target_sessions)
126
+ else:
127
+ # Fallback to original behavior
128
+ if reply_channel:
129
+ error_payload["reply_channel"] = reply_channel
130
+
131
+ await self.control_channel.send(error_payload)
132
+
104
133
  def update_context(self, context: Dict[str, Any]) -> None:
105
134
  """Update the shared context for all handlers.
106
135
 
@@ -51,12 +51,12 @@ class TerminalStartHandler(AsyncHandler):
51
51
 
52
52
  await session.proc.wait()
53
53
 
54
- await self.control_channel.send({
54
+ await self.send_response({
55
55
  "event": "terminal_exit",
56
56
  "terminal_id": terminal_id,
57
57
  "returncode": session.proc.returncode,
58
58
  "project_id": session.project_id,
59
- })
59
+ }, project_id=session.project_id)
60
60
 
61
61
  # Only cleanup session if it still exists (not already removed by stop handler)
62
62
  if session_manager.get_session(terminal_id):
@@ -152,13 +152,13 @@ class TerminalStopHandler(AsyncHandler):
152
152
  logger.info("terminal_stop: Successfully stopped terminal %s", terminal_id)
153
153
 
154
154
  # Send success notification
155
- await self.control_channel.send({
155
+ await self.send_response({
156
156
  "event": "terminal_stop_completed",
157
157
  "terminal_id": terminal_id,
158
158
  "status": "success",
159
159
  "message": "Terminal stopped successfully",
160
160
  "project_id": project_id,
161
- })
161
+ }, project_id=project_id)
162
162
 
163
163
  except asyncio.TimeoutError:
164
164
  logger.warning("terminal_stop: Stop timeout for terminal %s, forcing kill", terminal_id)
@@ -175,35 +175,35 @@ class TerminalStopHandler(AsyncHandler):
175
175
  logger.error("terminal_stop: Failed to force kill terminal %s: %s", terminal_id, kill_exc)
176
176
 
177
177
  # Send timeout notification
178
- await self.control_channel.send({
178
+ await self.send_response({
179
179
  "event": "terminal_stop_completed",
180
180
  "terminal_id": terminal_id,
181
181
  "status": "timeout",
182
182
  "message": "Terminal stop timed out, process was force killed",
183
183
  "project_id": project_id,
184
- })
184
+ }, project_id=project_id)
185
185
 
186
186
  except Exception as exc:
187
187
  logger.exception("terminal_stop: Error stopping terminal %s: %s", terminal_id, exc)
188
188
 
189
189
  # Send error notification
190
- await self.control_channel.send({
190
+ await self.send_response({
191
191
  "event": "terminal_stop_completed",
192
192
  "terminal_id": terminal_id,
193
193
  "status": "error",
194
194
  "message": f"Error stopping terminal: {str(exc)}",
195
195
  "project_id": project_id,
196
- })
196
+ }, project_id=project_id)
197
197
 
198
198
  async def _send_not_found_completion(self, terminal_id: str, project_id: Optional[str] = None) -> None:
199
199
  """Send completion event for not found terminals."""
200
- await self.control_channel.send({
200
+ await self.send_response({
201
201
  "event": "terminal_stop_completed",
202
202
  "terminal_id": terminal_id,
203
203
  "status": "not_found",
204
204
  "message": "Terminal was not found or already stopped",
205
205
  "project_id": project_id,
206
- })
206
+ }, project_id=project_id)
207
207
 
208
208
 
209
209
  class TerminalListHandler(AsyncHandler):
@@ -78,6 +78,37 @@ class ClientSessionManager:
78
78
  """Check if there are any connected clients interested in this device."""
79
79
  return len(self._client_sessions) > 0
80
80
 
81
+ def get_target_sessions(self, project_id: str = None) -> List[str]:
82
+ """Get list of channel_names for target client sessions.
83
+
84
+ Args:
85
+ project_id: If specified, only include sessions for this project
86
+
87
+ Returns:
88
+ List of channel_names to target
89
+ """
90
+ if not self._client_sessions:
91
+ return []
92
+
93
+ target_sessions = []
94
+ for session in self._client_sessions.values():
95
+ # If project_id specified, filter by project
96
+ if project_id and session.get("project_id") != project_id:
97
+ continue
98
+ target_sessions.append(session.get("channel_name"))
99
+
100
+ return [s for s in target_sessions if s] # Filter out None values
101
+
102
+ def get_reply_channel_for_compatibility(self) -> Optional[str]:
103
+ """Get the first session's channel_name for backward compatibility.
104
+
105
+ Returns:
106
+ First available channel_name or None
107
+ """
108
+ if not self._client_sessions:
109
+ return None
110
+ return next(iter(self._client_sessions.keys()), None)
111
+
81
112
  def _write_debug_file(self) -> None:
82
113
  """Write current client sessions to debug JSON file."""
83
114
  try:
@@ -205,10 +236,12 @@ class TerminalManager:
205
236
  reply_chan = message.get("reply_channel")
206
237
 
207
238
  logger.info("terminal_manager: Processing command '%s' with reply_channel=%s", cmd, reply_chan)
239
+ logger.debug("terminal_manager: Full message: %s", message)
208
240
 
209
241
  # Handle client sessions update directly (special case)
210
242
  if cmd == "client_sessions_update":
211
243
  sessions = message.get("sessions", [])
244
+ logger.info("terminal_manager: Handling client_sessions_update with %d sessions", len(sessions))
212
245
  self._client_session_manager.update_sessions(sessions)
213
246
  logger.info("terminal_manager: Updated client sessions (%d sessions)", len(sessions))
214
247
  continue
@@ -260,7 +293,39 @@ class TerminalManager:
260
293
  payload = {"event": "error", "message": message}
261
294
  if reply_channel:
262
295
  payload["reply_channel"] = reply_channel
263
- await self._control_channel.send(payload)
296
+ await self._send_session_aware(payload)
297
+
298
+ async def _send_session_aware(self, payload: dict, project_id: str = None) -> None:
299
+ """Send a message with client session awareness.
300
+
301
+ Args:
302
+ payload: The message payload to send
303
+ project_id: Optional project filter for targeting specific sessions
304
+ """
305
+ # Check if there are any interested clients
306
+ if not self._client_session_manager.has_interested_clients():
307
+ logger.debug("terminal_manager: No interested clients, skipping message send")
308
+ return
309
+
310
+ # Get target sessions
311
+ target_sessions = self._client_session_manager.get_target_sessions(project_id)
312
+ if not target_sessions:
313
+ logger.debug("terminal_manager: No target sessions found, skipping message send")
314
+ return
315
+
316
+ # Add session targeting information
317
+ enhanced_payload = dict(payload)
318
+ enhanced_payload["client_sessions"] = target_sessions
319
+
320
+ # Add backward compatibility reply_channel (first session)
321
+ reply_channel = self._client_session_manager.get_reply_channel_for_compatibility()
322
+ if reply_channel and "reply_channel" not in enhanced_payload:
323
+ enhanced_payload["reply_channel"] = reply_channel
324
+
325
+ logger.debug("terminal_manager: Sending to %d client sessions: %s",
326
+ len(target_sessions), target_sessions)
327
+
328
+ await self._control_channel.send(enhanced_payload)
264
329
 
265
330
  async def _send_terminal_list(self) -> None:
266
331
  """Send terminal list for reconnection reconciliation."""
@@ -272,7 +337,7 @@ class TerminalManager:
272
337
  "event": "terminal_list",
273
338
  "sessions": sessions,
274
339
  }
275
- await self._control_channel.send(payload)
340
+ await self._send_session_aware(payload)
276
341
  except Exception as exc:
277
342
  logger.warning("Failed to send terminal list: %s", exc)
278
343
 
@@ -282,6 +347,8 @@ class TerminalManager:
282
347
  payload = {
283
348
  "event": "request_client_sessions"
284
349
  }
350
+ # This is a special case - always send regardless of current client sessions
351
+ # because we're trying to get the client sessions list
285
352
  await self._control_channel.send(payload)
286
353
  logger.info("Requested client sessions from server")
287
354
  except Exception as exc:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: portacode
3
- Version: 0.3.12.dev1
3
+ Version: 0.3.12.dev3
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