portacode 0.3.5.dev0__tar.gz → 0.3.7.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 (38) hide show
  1. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/PKG-INFO +2 -1
  2. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/portacode/_version.py +2 -2
  3. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/portacode/cli.py +11 -1
  4. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/portacode/connection/handlers/base.py +5 -1
  5. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/portacode/connection/handlers/registry.py +5 -2
  6. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/portacode/connection/handlers/session.py +78 -10
  7. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/portacode/connection/handlers/terminal_handlers.py +69 -2
  8. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/portacode/connection/terminal.py +35 -21
  9. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/portacode.egg-info/PKG-INFO +2 -1
  10. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/setup.py +1 -0
  11. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/.gitignore +0 -0
  12. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/.gitmodules +0 -0
  13. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/LICENSE +0 -0
  14. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/MANIFEST.in +0 -0
  15. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/Makefile +0 -0
  16. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/README.md +0 -0
  17. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/docker-compose.yaml +0 -0
  18. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/portacode/README.md +0 -0
  19. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/portacode/__init__.py +0 -0
  20. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/portacode/__main__.py +0 -0
  21. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/portacode/connection/README.md +0 -0
  22. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/portacode/connection/__init__.py +0 -0
  23. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/portacode/connection/client.py +0 -0
  24. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/portacode/connection/handlers/README.md +0 -0
  25. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/portacode/connection/handlers/__init__.py +0 -0
  26. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/portacode/connection/handlers/file_handlers.py +0 -0
  27. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/portacode/connection/handlers/system_handlers.py +0 -0
  28. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/portacode/connection/multiplex.py +0 -0
  29. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/portacode/data.py +0 -0
  30. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/portacode/keypair.py +0 -0
  31. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/portacode/service.py +0 -0
  32. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/portacode.egg-info/SOURCES.txt +0 -0
  33. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/portacode.egg-info/dependency_links.txt +0 -0
  34. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/portacode.egg-info/entry_points.txt +0 -0
  35. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/portacode.egg-info/requires.txt +0 -0
  36. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/portacode.egg-info/top_level.txt +0 -0
  37. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/pyproject.toml +0 -0
  38. {portacode-0.3.5.dev0 → portacode-0.3.7.dev0}/setup.cfg +0 -0
@@ -1,9 +1,10 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: portacode
3
- Version: 0.3.5.dev0
3
+ Version: 0.3.7.dev0
4
4
  Summary: Portacode CLI client and SDK
5
5
  Home-page: https://github.com/portacode/portacode
6
6
  Author: Meena Erian
7
+ Author-email: hi@menas.pro
7
8
  Classifier: Programming Language :: Python :: 3
8
9
  Classifier: License :: OSI Approved :: MIT License
9
10
  Classifier: Operating System :: OS Independent
@@ -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.5.dev'
21
- __version_tuple__ = version_tuple = (0, 3, 5, 'dev0')
20
+ __version__ = version = '0.3.7.dev'
21
+ __version_tuple__ = version_tuple = (0, 3, 7, 'dev0')
@@ -29,11 +29,21 @@ def cli() -> None:
29
29
  @cli.command()
30
30
  @click.option("--gateway", "gateway", "-g", help="Gateway websocket URL (overrides env/ default)")
31
31
  @click.option("--detach", "detach", "-d", is_flag=True, help="Run connection in background")
32
+ @click.option("--debug", "debug", is_flag=True, help="Enable debug logging")
32
33
  @click.option("--non-interactive", "non_interactive", is_flag=True, envvar="PORTACODE_NON_INTERACTIVE", hidden=True,
33
34
  help="Skip interactive prompts (used by background service)")
34
- def connect(gateway: str | None, detach: bool, non_interactive: bool) -> None: # noqa: D401 – Click callback
35
+ def connect(gateway: str | None, detach: bool, debug: bool, non_interactive: bool) -> None: # noqa: D401 – Click callback
35
36
  """Connect this machine to Portacode gateway."""
36
37
 
38
+ # Set up debug logging if requested
39
+ if debug:
40
+ import logging
41
+ logging.basicConfig(
42
+ level=logging.DEBUG,
43
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
44
+ )
45
+ click.echo(click.style("🔍 Debug logging enabled", fg="yellow"))
46
+
37
47
  # 1. Ensure only a single connection per user
38
48
  pid_file = get_pid_file()
39
49
  if pid_file.exists():
@@ -81,11 +81,15 @@ class AsyncHandler(BaseHandler):
81
81
 
82
82
  async def handle(self, message: Dict[str, Any], reply_channel: Optional[str] = None) -> None:
83
83
  """Handle the command by executing it and sending the response."""
84
+ logger.info("handler: Processing command %s with reply_channel=%s",
85
+ self.command_name, reply_channel)
86
+
84
87
  try:
85
88
  response = await self.execute(message)
89
+ logger.info("handler: Command %s executed successfully", self.command_name)
86
90
  await self.send_response(response, reply_channel)
87
91
  except Exception as exc:
88
- logger.exception("Error in async handler %s: %s", self.command_name, exc)
92
+ logger.exception("handler: Error in async handler %s: %s", self.command_name, exc)
89
93
  await self.send_error(str(exc), reply_channel)
90
94
 
91
95
 
@@ -81,16 +81,19 @@ class CommandRegistry:
81
81
  Returns:
82
82
  True if handler was found and executed, False otherwise
83
83
  """
84
+ logger.info("registry: Dispatching command '%s' with reply_channel=%s", command_name, reply_channel)
85
+
84
86
  handler = self.get_handler(command_name)
85
87
  if handler is None:
86
- logger.warning("No handler found for command: %s", command_name)
88
+ logger.warning("registry: No handler found for command: %s", command_name)
87
89
  return False
88
90
 
89
91
  try:
90
92
  await handler.handle(message, reply_channel)
93
+ logger.info("registry: Successfully dispatched command '%s'", command_name)
91
94
  return True
92
95
  except Exception as exc:
93
- logger.exception("Error dispatching command %s: %s", command_name, exc)
96
+ logger.exception("registry: Error dispatching command %s: %s", command_name, exc)
94
97
  # Send error response
95
98
  error_payload = {"event": "error", "message": str(exc)}
96
99
  if reply_channel:
@@ -83,11 +83,46 @@ class TerminalSession:
83
83
  logger.warning("Failed to write to terminal %s: %s", self.id, exc)
84
84
 
85
85
  async def stop(self) -> None:
86
- if self.proc.returncode is None:
87
- self.proc.terminate()
88
- if self._reader_task:
89
- await self._reader_task
90
- await self.proc.wait()
86
+ """Stop the terminal session with comprehensive logging."""
87
+ logger.info("session.stop: Starting stop process for session %s (PID: %s)",
88
+ self.id, getattr(self.proc, 'pid', 'unknown'))
89
+
90
+ try:
91
+ # Check if process is still running
92
+ if self.proc.returncode is None:
93
+ logger.info("session.stop: Terminating process for session %s", self.id)
94
+ self.proc.terminate()
95
+ else:
96
+ logger.info("session.stop: Process for session %s already exited (returncode: %s)",
97
+ self.id, self.proc.returncode)
98
+
99
+ # Wait for reader task to complete
100
+ if self._reader_task and not self._reader_task.done():
101
+ logger.info("session.stop: Waiting for reader task to complete for session %s", self.id)
102
+ try:
103
+ await asyncio.wait_for(self._reader_task, timeout=5.0)
104
+ logger.info("session.stop: Reader task completed for session %s", self.id)
105
+ except asyncio.TimeoutError:
106
+ logger.warning("session.stop: Reader task timeout for session %s, cancelling", self.id)
107
+ self._reader_task.cancel()
108
+ try:
109
+ await self._reader_task
110
+ except asyncio.CancelledError:
111
+ pass
112
+
113
+ # Wait for process to exit
114
+ if self.proc.returncode is None:
115
+ logger.info("session.stop: Waiting for process to exit for session %s", self.id)
116
+ await self.proc.wait()
117
+ logger.info("session.stop: Process exited for session %s (returncode: %s)",
118
+ self.id, self.proc.returncode)
119
+ else:
120
+ logger.info("session.stop: Process already exited for session %s (returncode: %s)",
121
+ self.id, self.proc.returncode)
122
+
123
+ except Exception as exc:
124
+ logger.exception("session.stop: Error stopping session %s: %s", self.id, exc)
125
+ raise
91
126
 
92
127
  def snapshot_buffer(self) -> str:
93
128
  """Return concatenated last buffer contents suitable for UI."""
@@ -168,10 +203,37 @@ class WindowsTerminalSession(TerminalSession):
168
203
  logger.warning("Failed to write to terminal %s: %s", self.id, exc)
169
204
 
170
205
  async def stop(self) -> None:
171
- if self._pty.isalive():
172
- self._pty.kill()
173
- if self._reader_task:
174
- await self._reader_task
206
+ """Stop the Windows terminal session with comprehensive logging."""
207
+ logger.info("session.stop: Starting stop process for Windows session %s (PID: %s)",
208
+ self.id, getattr(self._pty, 'pid', 'unknown'))
209
+
210
+ try:
211
+ # Check if PTY is still alive
212
+ if self._pty.isalive():
213
+ logger.info("session.stop: Killing PTY process for session %s", self.id)
214
+ self._pty.kill()
215
+ else:
216
+ logger.info("session.stop: PTY process for session %s already exited", self.id)
217
+
218
+ # Wait for reader task to complete
219
+ if self._reader_task and not self._reader_task.done():
220
+ logger.info("session.stop: Waiting for reader task to complete for Windows session %s", self.id)
221
+ try:
222
+ await asyncio.wait_for(self._reader_task, timeout=5.0)
223
+ logger.info("session.stop: Reader task completed for Windows session %s", self.id)
224
+ except asyncio.TimeoutError:
225
+ logger.warning("session.stop: Reader task timeout for Windows session %s, cancelling", self.id)
226
+ self._reader_task.cancel()
227
+ try:
228
+ await self._reader_task
229
+ except asyncio.CancelledError:
230
+ pass
231
+
232
+ logger.info("session.stop: Successfully stopped Windows session %s", self.id)
233
+
234
+ except Exception as exc:
235
+ logger.exception("session.stop: Error stopping Windows session %s: %s", self.id, exc)
236
+ raise
175
237
 
176
238
 
177
239
  class SessionManager:
@@ -270,7 +332,13 @@ class SessionManager:
270
332
 
271
333
  def remove_session(self, terminal_id: str) -> Optional[TerminalSession]:
272
334
  """Remove and return a terminal session."""
273
- return self._sessions.pop(terminal_id, None)
335
+ session = self._sessions.pop(terminal_id, None)
336
+ if session:
337
+ logger.info("session_manager: Removed session %s (PID: %s) from session manager",
338
+ terminal_id, getattr(session.proc, 'pid', 'unknown'))
339
+ else:
340
+ logger.warning("session_manager: Attempted to remove non-existent session %s", terminal_id)
341
+ return session
274
342
 
275
343
  def list_sessions(self) -> List[Dict[str, Any]]:
276
344
  """List all terminal sessions."""
@@ -100,22 +100,89 @@ class TerminalStopHandler(AsyncHandler):
100
100
  terminal_id = message.get("terminal_id")
101
101
 
102
102
  if not terminal_id:
103
+ logger.error("terminal_stop: Missing terminal_id in message")
103
104
  raise ValueError("terminal_id is required")
104
105
 
106
+ logger.info("terminal_stop: Processing stop request for terminal_id=%s", terminal_id)
107
+
105
108
  session_manager = self.context.get("session_manager")
106
109
  if not session_manager:
110
+ logger.error("terminal_stop: Session manager not available in context")
107
111
  raise RuntimeError("Session manager not available")
108
112
 
113
+ # Remove session from manager first
109
114
  session = session_manager.remove_session(terminal_id)
110
115
  if not session:
111
- raise ValueError(f"terminal_id {terminal_id} not found")
116
+ logger.warning("terminal_stop: Terminal %s not found, may have already been stopped", terminal_id)
117
+ return {
118
+ "event": "terminal_stopped",
119
+ "terminal_id": terminal_id,
120
+ "status": "not_found",
121
+ "message": "Terminal was not found or already stopped"
122
+ }
112
123
 
113
- await session.stop()
124
+ logger.info("terminal_stop: Found session for terminal %s (PID: %s), starting background stop process",
125
+ terminal_id, getattr(session.proc, 'pid', 'unknown'))
126
+
127
+ # Start stop process in background without blocking the control channel
128
+ asyncio.create_task(self._stop_session_safely(session, terminal_id))
114
129
 
115
130
  return {
116
131
  "event": "terminal_stopped",
117
132
  "terminal_id": terminal_id,
133
+ "status": "stopping",
134
+ "message": "Terminal stop process initiated"
118
135
  }
136
+
137
+ async def _stop_session_safely(self, session, terminal_id: str) -> None:
138
+ """Safely stop a session in the background with timeout and error handling."""
139
+ logger.info("terminal_stop: Starting background stop process for terminal %s", terminal_id)
140
+
141
+ try:
142
+ # Attempt graceful stop with timeout
143
+ await asyncio.wait_for(session.stop(), timeout=10.0)
144
+ logger.info("terminal_stop: Successfully stopped terminal %s", terminal_id)
145
+
146
+ # Send success notification
147
+ await self.control_channel.send({
148
+ "event": "terminal_stop_completed",
149
+ "terminal_id": terminal_id,
150
+ "status": "success",
151
+ "message": "Terminal stopped successfully"
152
+ })
153
+
154
+ except asyncio.TimeoutError:
155
+ logger.warning("terminal_stop: Stop timeout for terminal %s, forcing kill", terminal_id)
156
+
157
+ # Force kill the process
158
+ try:
159
+ if hasattr(session.proc, 'kill'):
160
+ session.proc.kill()
161
+ logger.info("terminal_stop: Force killed terminal %s", terminal_id)
162
+ elif hasattr(session.proc, 'terminate'):
163
+ session.proc.terminate()
164
+ logger.info("terminal_stop: Force terminated terminal %s", terminal_id)
165
+ except Exception as kill_exc:
166
+ logger.error("terminal_stop: Failed to force kill terminal %s: %s", terminal_id, kill_exc)
167
+
168
+ # Send timeout notification
169
+ await self.control_channel.send({
170
+ "event": "terminal_stop_completed",
171
+ "terminal_id": terminal_id,
172
+ "status": "timeout",
173
+ "message": "Terminal stop timed out, process was force killed"
174
+ })
175
+
176
+ except Exception as exc:
177
+ logger.exception("terminal_stop: Error stopping terminal %s: %s", terminal_id, exc)
178
+
179
+ # Send error notification
180
+ await self.control_channel.send({
181
+ "event": "terminal_stop_completed",
182
+ "terminal_id": terminal_id,
183
+ "status": "error",
184
+ "message": f"Error stopping terminal: {str(exc)}"
185
+ })
119
186
 
120
187
 
121
188
  class TerminalListHandler(AsyncHandler):
@@ -113,31 +113,45 @@ class TerminalManager:
113
113
  # ---------------------------------------------------------------------
114
114
 
115
115
  async def _control_loop(self) -> None:
116
+ logger.info("terminal_manager: Starting control loop")
116
117
  while True:
117
- message = await self._control_channel.recv()
118
- # Older parts of the system may send *raw* str. Ensure dict.
119
- if isinstance(message, str):
120
- try:
121
- message = json.loads(message)
122
- except Exception:
123
- logger.warning("Discarding non-JSON control frame: %s", message)
118
+ try:
119
+ message = await self._control_channel.recv()
120
+ logger.debug("terminal_manager: Received message: %s", message)
121
+
122
+ # Older parts of the system may send *raw* str. Ensure dict.
123
+ if isinstance(message, str):
124
+ try:
125
+ message = json.loads(message)
126
+ logger.debug("terminal_manager: Parsed string message to dict")
127
+ except Exception:
128
+ logger.warning("terminal_manager: Discarding non-JSON control frame: %s", message)
129
+ continue
130
+ if not isinstance(message, dict):
131
+ logger.warning("terminal_manager: Invalid control frame type: %r", type(message))
124
132
  continue
125
- if not isinstance(message, dict):
126
- logger.warning("Invalid control frame type: %r", type(message))
127
- continue
128
- cmd = message.get("cmd")
129
- if not cmd:
130
- # Ignore frames that are *events* coming from the remote side
131
- if message.get("event"):
133
+ cmd = message.get("cmd")
134
+ if not cmd:
135
+ # Ignore frames that are *events* coming from the remote side
136
+ if message.get("event"):
137
+ logger.debug("terminal_manager: Ignoring event message: %s", message.get("event"))
138
+ continue
139
+ logger.warning("terminal_manager: Missing 'cmd' in control frame: %s", message)
132
140
  continue
133
- logger.warning("Missing 'cmd' in control frame: %s", message)
141
+ reply_chan = message.get("reply_channel")
142
+
143
+ logger.info("terminal_manager: Processing command '%s' with reply_channel=%s", cmd, reply_chan)
144
+
145
+ # Dispatch command through registry
146
+ handled = await self._command_registry.dispatch(cmd, message, reply_chan)
147
+ if not handled:
148
+ logger.warning("terminal_manager: Command '%s' was not handled by any handler", cmd)
149
+ await self._send_error(f"Unknown cmd: {cmd}", reply_chan)
150
+
151
+ except Exception as exc:
152
+ logger.exception("terminal_manager: Error in control loop: %s", exc)
153
+ # Continue processing other messages
134
154
  continue
135
- reply_chan = message.get("reply_channel")
136
-
137
- # Dispatch command through registry
138
- handled = await self._command_registry.dispatch(cmd, message, reply_chan)
139
- if not handled:
140
- await self._send_error(f"Unknown cmd: {cmd}", reply_chan)
141
155
 
142
156
  # ------------------------------------------------------------------
143
157
  # Extension API
@@ -1,9 +1,10 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: portacode
3
- Version: 0.3.5.dev0
3
+ Version: 0.3.7.dev0
4
4
  Summary: Portacode CLI client and SDK
5
5
  Home-page: https://github.com/portacode/portacode
6
6
  Author: Meena Erian
7
+ Author-email: hi@menas.pro
7
8
  Classifier: Programming Language :: Python :: 3
8
9
  Classifier: License :: OSI Approved :: MIT License
9
10
  Classifier: Operating System :: OS Independent
@@ -14,6 +14,7 @@ setup(
14
14
  long_description=README,
15
15
  long_description_content_type="text/markdown",
16
16
  author="Meena Erian",
17
+ author_email="hi@menas.pro",
17
18
  url="https://github.com/portacode/portacode",
18
19
  packages=find_packages(exclude=("tests", "server")),
19
20
  python_requires=">=3.8",
File without changes
File without changes
File without changes
File without changes