portacode 0.3.4.dev0__py3-none-any.whl → 1.4.11.dev0__py3-none-any.whl

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.

Potentially problematic release.


This version of portacode might be problematic. Click here for more details.

Files changed (93) hide show
  1. portacode/_version.py +16 -3
  2. portacode/cli.py +155 -19
  3. portacode/connection/client.py +152 -12
  4. portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +1577 -0
  5. portacode/connection/handlers/__init__.py +43 -1
  6. portacode/connection/handlers/base.py +122 -18
  7. portacode/connection/handlers/chunked_content.py +244 -0
  8. portacode/connection/handlers/diff_handlers.py +603 -0
  9. portacode/connection/handlers/file_handlers.py +902 -17
  10. portacode/connection/handlers/project_aware_file_handlers.py +226 -0
  11. portacode/connection/handlers/project_state/README.md +312 -0
  12. portacode/connection/handlers/project_state/__init__.py +92 -0
  13. portacode/connection/handlers/project_state/file_system_watcher.py +179 -0
  14. portacode/connection/handlers/project_state/git_manager.py +1502 -0
  15. portacode/connection/handlers/project_state/handlers.py +875 -0
  16. portacode/connection/handlers/project_state/manager.py +1331 -0
  17. portacode/connection/handlers/project_state/models.py +108 -0
  18. portacode/connection/handlers/project_state/utils.py +50 -0
  19. portacode/connection/handlers/project_state_handlers.py +45 -0
  20. portacode/connection/handlers/proxmox_infra.py +307 -0
  21. portacode/connection/handlers/registry.py +53 -10
  22. portacode/connection/handlers/session.py +705 -53
  23. portacode/connection/handlers/system_handlers.py +142 -8
  24. portacode/connection/handlers/tab_factory.py +389 -0
  25. portacode/connection/handlers/terminal_handlers.py +150 -11
  26. portacode/connection/handlers/update_handler.py +61 -0
  27. portacode/connection/multiplex.py +60 -2
  28. portacode/connection/terminal.py +695 -28
  29. portacode/keypair.py +63 -1
  30. portacode/link_capture/__init__.py +38 -0
  31. portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
  32. portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
  33. portacode/link_capture/bin/elinks +3 -0
  34. portacode/link_capture/bin/gio-open +3 -0
  35. portacode/link_capture/bin/gnome-open +3 -0
  36. portacode/link_capture/bin/gvfs-open +3 -0
  37. portacode/link_capture/bin/kde-open +3 -0
  38. portacode/link_capture/bin/kfmclient +3 -0
  39. portacode/link_capture/bin/link_capture_exec.sh +11 -0
  40. portacode/link_capture/bin/link_capture_wrapper.py +75 -0
  41. portacode/link_capture/bin/links +3 -0
  42. portacode/link_capture/bin/links2 +3 -0
  43. portacode/link_capture/bin/lynx +3 -0
  44. portacode/link_capture/bin/mate-open +3 -0
  45. portacode/link_capture/bin/netsurf +3 -0
  46. portacode/link_capture/bin/sensible-browser +3 -0
  47. portacode/link_capture/bin/w3m +3 -0
  48. portacode/link_capture/bin/x-www-browser +3 -0
  49. portacode/link_capture/bin/xdg-open +3 -0
  50. portacode/logging_categories.py +140 -0
  51. portacode/pairing.py +103 -0
  52. portacode/service.py +6 -0
  53. portacode/static/js/test-ntp-clock.html +63 -0
  54. portacode/static/js/utils/ntp-clock.js +232 -0
  55. portacode/utils/NTP_ARCHITECTURE.md +136 -0
  56. portacode/utils/__init__.py +1 -0
  57. portacode/utils/diff_apply.py +456 -0
  58. portacode/utils/diff_renderer.py +371 -0
  59. portacode/utils/ntp_clock.py +65 -0
  60. portacode-1.4.11.dev0.dist-info/METADATA +298 -0
  61. portacode-1.4.11.dev0.dist-info/RECORD +97 -0
  62. {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info}/WHEEL +1 -1
  63. portacode-1.4.11.dev0.dist-info/top_level.txt +3 -0
  64. test_modules/README.md +296 -0
  65. test_modules/__init__.py +1 -0
  66. test_modules/test_device_online.py +44 -0
  67. test_modules/test_file_operations.py +743 -0
  68. test_modules/test_git_status_ui.py +370 -0
  69. test_modules/test_login_flow.py +50 -0
  70. test_modules/test_navigate_testing_folder.py +361 -0
  71. test_modules/test_play_store_screenshots.py +294 -0
  72. test_modules/test_terminal_buffer_performance.py +261 -0
  73. test_modules/test_terminal_interaction.py +80 -0
  74. test_modules/test_terminal_loading_race_condition.py +95 -0
  75. test_modules/test_terminal_start.py +56 -0
  76. testing_framework/.env.example +21 -0
  77. testing_framework/README.md +334 -0
  78. testing_framework/__init__.py +17 -0
  79. testing_framework/cli.py +326 -0
  80. testing_framework/core/__init__.py +1 -0
  81. testing_framework/core/base_test.py +336 -0
  82. testing_framework/core/cli_manager.py +177 -0
  83. testing_framework/core/hierarchical_runner.py +577 -0
  84. testing_framework/core/playwright_manager.py +520 -0
  85. testing_framework/core/runner.py +447 -0
  86. testing_framework/core/shared_cli_manager.py +234 -0
  87. testing_framework/core/test_discovery.py +112 -0
  88. testing_framework/requirements.txt +12 -0
  89. portacode-0.3.4.dev0.dist-info/METADATA +0 -236
  90. portacode-0.3.4.dev0.dist-info/RECORD +0 -27
  91. portacode-0.3.4.dev0.dist-info/top_level.txt +0 -1
  92. {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info}/entry_points.txt +0 -0
  93. {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info/licenses}/LICENSE +0 -0
@@ -2,7 +2,7 @@
2
2
 
3
3
  import asyncio
4
4
  import logging
5
- from typing import Any, Dict
5
+ from typing import Any, Dict, Optional
6
6
 
7
7
  from .base import AsyncHandler
8
8
  from .session import SessionManager
@@ -21,12 +21,13 @@ class TerminalStartHandler(AsyncHandler):
21
21
  """Start a new terminal session."""
22
22
  shell = message.get("shell")
23
23
  cwd = message.get("cwd")
24
+ project_id = message.get("project_id")
24
25
 
25
26
  session_manager = self.context.get("session_manager")
26
27
  if not session_manager:
27
28
  raise RuntimeError("Session manager not available")
28
29
 
29
- session_info = await session_manager.create_session(shell=shell, cwd=cwd)
30
+ session_info = await session_manager.create_session(shell=shell, cwd=cwd, project_id=project_id)
30
31
 
31
32
  # Start background watcher for process exit
32
33
  asyncio.create_task(self._watch_process_exit(session_info["terminal_id"]))
@@ -35,6 +36,10 @@ class TerminalStartHandler(AsyncHandler):
35
36
  "event": "terminal_started",
36
37
  "terminal_id": session_info["terminal_id"],
37
38
  "channel": session_info["channel"],
39
+ "pid": session_info["pid"],
40
+ "shell": session_info.get("shell"),
41
+ "cwd": session_info.get("cwd"),
42
+ "project_id": session_info.get("project_id"),
38
43
  }
39
44
 
40
45
  async def _watch_process_exit(self, terminal_id: str) -> None:
@@ -49,14 +54,16 @@ class TerminalStartHandler(AsyncHandler):
49
54
 
50
55
  await session.proc.wait()
51
56
 
52
- await self.control_channel.send({
57
+ await self.send_response({
53
58
  "event": "terminal_exit",
54
59
  "terminal_id": terminal_id,
55
60
  "returncode": session.proc.returncode,
56
- })
61
+ "project_id": session.project_id,
62
+ }, project_id=session.project_id)
57
63
 
58
- # Cleanup session
59
- session_manager.remove_session(terminal_id)
64
+ # Only cleanup session if it still exists (not already removed by stop handler)
65
+ if session_manager.get_session(terminal_id):
66
+ session_manager.remove_session(terminal_id)
60
67
 
61
68
 
62
69
  class TerminalSendHandler(AsyncHandler):
@@ -100,22 +107,106 @@ class TerminalStopHandler(AsyncHandler):
100
107
  terminal_id = message.get("terminal_id")
101
108
 
102
109
  if not terminal_id:
110
+ logger.error("terminal_stop: Missing terminal_id in message")
103
111
  raise ValueError("terminal_id is required")
104
112
 
113
+ logger.info("terminal_stop: Processing stop request for terminal_id=%s", terminal_id)
114
+
105
115
  session_manager = self.context.get("session_manager")
106
116
  if not session_manager:
117
+ logger.error("terminal_stop: Session manager not available in context")
107
118
  raise RuntimeError("Session manager not available")
108
119
 
120
+ # Remove session from manager first
109
121
  session = session_manager.remove_session(terminal_id)
110
122
  if not session:
111
- raise ValueError(f"terminal_id {terminal_id} not found")
123
+ logger.warning("terminal_stop: Terminal %s not found, may have already been stopped", terminal_id)
124
+ # Send completion event immediately for not found terminals
125
+ asyncio.create_task(self._send_not_found_completion(terminal_id, None))
126
+ return {
127
+ "event": "terminal_stopped",
128
+ "terminal_id": terminal_id,
129
+ "status": "not_found",
130
+ "message": "Terminal was not found or already stopped",
131
+ "project_id": None,
132
+ }
133
+
134
+ logger.info("terminal_stop: Found session for terminal %s (PID: %s), starting background stop process",
135
+ terminal_id, getattr(session.proc, 'pid', 'unknown'))
112
136
 
113
- await session.stop()
137
+ # Start stop process in background without blocking the control channel
138
+ asyncio.create_task(self._stop_session_safely(session, terminal_id, session.project_id))
114
139
 
115
140
  return {
116
141
  "event": "terminal_stopped",
117
142
  "terminal_id": terminal_id,
143
+ "status": "stopping",
144
+ "message": "Terminal stop process initiated",
145
+ "project_id": session.project_id,
118
146
  }
147
+
148
+ async def _stop_session_safely(self, session, terminal_id: str, project_id: Optional[str] = None) -> None:
149
+ """Safely stop a session in the background with timeout and error handling."""
150
+ logger.info("terminal_stop: Starting background stop process for terminal %s", terminal_id)
151
+
152
+ try:
153
+ # Attempt graceful stop with timeout
154
+ await asyncio.wait_for(session.stop(), timeout=10.0)
155
+ logger.info("terminal_stop: Successfully stopped terminal %s", terminal_id)
156
+
157
+ # Send success notification
158
+ await self.send_response({
159
+ "event": "terminal_stop_completed",
160
+ "terminal_id": terminal_id,
161
+ "status": "success",
162
+ "message": "Terminal stopped successfully",
163
+ "project_id": project_id,
164
+ }, project_id=project_id)
165
+
166
+ except asyncio.TimeoutError:
167
+ logger.warning("terminal_stop: Stop timeout for terminal %s, forcing kill", terminal_id)
168
+
169
+ # Force kill the process
170
+ try:
171
+ if hasattr(session.proc, 'kill'):
172
+ session.proc.kill()
173
+ logger.info("terminal_stop: Force killed terminal %s", terminal_id)
174
+ elif hasattr(session.proc, 'terminate'):
175
+ session.proc.terminate()
176
+ logger.info("terminal_stop: Force terminated terminal %s", terminal_id)
177
+ except Exception as kill_exc:
178
+ logger.error("terminal_stop: Failed to force kill terminal %s: %s", terminal_id, kill_exc)
179
+
180
+ # Send timeout notification
181
+ await self.send_response({
182
+ "event": "terminal_stop_completed",
183
+ "terminal_id": terminal_id,
184
+ "status": "timeout",
185
+ "message": "Terminal stop timed out, process was force killed",
186
+ "project_id": project_id,
187
+ }, project_id=project_id)
188
+
189
+ except Exception as exc:
190
+ logger.exception("terminal_stop: Error stopping terminal %s: %s", terminal_id, exc)
191
+
192
+ # Send error notification
193
+ await self.send_response({
194
+ "event": "terminal_stop_completed",
195
+ "terminal_id": terminal_id,
196
+ "status": "error",
197
+ "message": f"Error stopping terminal: {str(exc)}",
198
+ "project_id": project_id,
199
+ }, project_id=project_id)
200
+
201
+ async def _send_not_found_completion(self, terminal_id: str, project_id: Optional[str] = None) -> None:
202
+ """Send completion event for not found terminals."""
203
+ await self.send_response({
204
+ "event": "terminal_stop_completed",
205
+ "terminal_id": terminal_id,
206
+ "status": "not_found",
207
+ "message": "Terminal was not found or already stopped",
208
+ "project_id": project_id,
209
+ }, project_id=project_id)
119
210
 
120
211
 
121
212
  class TerminalListHandler(AsyncHandler):
@@ -125,15 +216,63 @@ class TerminalListHandler(AsyncHandler):
125
216
  def command_name(self) -> str:
126
217
  return "terminal_list"
127
218
 
219
+ async def handle(self, message: Dict[str, Any], reply_channel: Optional[str] = None) -> None:
220
+ """Handle the command by executing it and sending the response to the requesting client session."""
221
+ logger.info("handler: Processing command %s with reply_channel=%s",
222
+ self.command_name, reply_channel)
223
+
224
+ try:
225
+ response = await self.execute(message)
226
+ logger.info("handler: Command %s executed successfully", self.command_name)
227
+
228
+ # Automatically copy request_id if present in the incoming message
229
+ if "request_id" in message and "request_id" not in response:
230
+ response["request_id"] = message["request_id"]
231
+
232
+ # Get the source client session from the message
233
+ source_client_session = message.get("source_client_session")
234
+ project_id = response.get("project_id")
235
+
236
+ logger.info("handler: %s response project_id=%s, source_client_session=%s",
237
+ self.command_name, project_id, source_client_session)
238
+
239
+ # Send response only to the requesting client session
240
+ if source_client_session:
241
+ # Add client_sessions field to target only the requesting session
242
+ response["client_sessions"] = [source_client_session]
243
+
244
+ import json
245
+ logger.info("handler: 📤 SENDING EVENT '%s' (via direct control_channel.send)", response.get("event", "unknown"))
246
+ logger.info("handler: 📤 FULL EVENT PAYLOAD: %s", json.dumps(response, indent=2, default=str))
247
+
248
+ await self.control_channel.send(response)
249
+ else:
250
+ # Fallback to original behavior if no source_client_session
251
+ await self.send_response(response, reply_channel, project_id)
252
+ except Exception as exc:
253
+ logger.exception("handler: Error in command %s: %s", self.command_name, exc)
254
+ await self.send_error(str(exc), reply_channel, message.get("project_id"))
255
+
128
256
  async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
129
257
  """List all terminal sessions."""
130
258
  session_manager = self.context.get("session_manager")
131
259
  if not session_manager:
132
260
  raise RuntimeError("Session manager not available")
133
-
134
- sessions = session_manager.list_sessions()
135
-
261
+
262
+ # Accept project_id argument: None (default) = only no project, 'all' = all, else = filter by project_id
263
+ requested_project_id = message.get("project_id")
264
+ logger.info("terminal_list: requested_project_id=%r (type: %s)", requested_project_id, type(requested_project_id))
265
+
266
+ if requested_project_id == "all":
267
+ logger.info("terminal_list: Using 'all' mode to list all terminals")
268
+ sessions = session_manager.list_sessions(project_id="all")
269
+ else:
270
+ logger.info("terminal_list: Filtering by project_id=%r", requested_project_id)
271
+ sessions = session_manager.list_sessions(project_id=requested_project_id)
272
+
273
+ logger.info("terminal_list: Found %d sessions, returning with project_id=%r", len(sessions), requested_project_id)
136
274
  return {
137
275
  "event": "terminal_list",
138
276
  "sessions": sessions,
277
+ "project_id": requested_project_id,
139
278
  }
@@ -0,0 +1,61 @@
1
+ """Update handler for Portacode CLI."""
2
+
3
+ import subprocess
4
+ import sys
5
+ import logging
6
+ from typing import Any, Dict
7
+ from .base import AsyncHandler
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class UpdatePortacodeHandler(AsyncHandler):
13
+ """Handler for updating Portacode CLI."""
14
+
15
+ @property
16
+ def command_name(self) -> str:
17
+ return "update_portacode_cli"
18
+
19
+ async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
20
+ """Update Portacode package and restart process."""
21
+ try:
22
+ logger.info("Starting Portacode CLI update...")
23
+
24
+ # Update the package
25
+ result = subprocess.run([
26
+ sys.executable, "-m", "pip", "install", "--upgrade", "portacode"
27
+ ], capture_output=True, text=True, timeout=120)
28
+
29
+ if result.returncode != 0:
30
+ logger.error("Update failed: %s", result.stderr)
31
+ return {
32
+ "event": "update_portacode_response",
33
+ "success": False,
34
+ "error": f"Update failed: {result.stderr}"
35
+ }
36
+
37
+ logger.info("Update successful, restarting process...")
38
+
39
+ # Send success response before exit
40
+ await self.send_response({
41
+ "event": "update_portacode_response",
42
+ "success": True,
43
+ "message": "Update completed. Process restarting..."
44
+ })
45
+
46
+ # Exit with special code to trigger restart
47
+ sys.exit(42)
48
+
49
+ except subprocess.TimeoutExpired:
50
+ return {
51
+ "event": "update_portacode_response",
52
+ "success": False,
53
+ "error": "Update timed out after 120 seconds"
54
+ }
55
+ except Exception as e:
56
+ logger.exception("Update failed with exception")
57
+ return {
58
+ "event": "update_portacode_response",
59
+ "success": False,
60
+ "error": str(e)
61
+ }
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  import json
5
5
  import logging
6
+ import time
6
7
  from asyncio import Queue
7
8
  from typing import Any, Dict, Union
8
9
 
@@ -49,8 +50,65 @@ class Multiplexer:
49
50
  return self._channels[channel_id]
50
51
 
51
52
  async def _send_on_channel(self, channel_id: Union[int, str], payload: Any) -> None:
52
- frame = json.dumps({"channel": channel_id, "payload": payload})
53
- await self._send_func(frame)
53
+ # Start timing the serialization and sending
54
+ start_time = time.time()
55
+
56
+ try:
57
+ # Serialize the frame
58
+ serialization_start = time.time()
59
+ frame = json.dumps({"channel": channel_id, "payload": payload})
60
+ serialization_time = time.time() - serialization_start
61
+
62
+ # Calculate message size
63
+ frame_size_bytes = len(frame.encode('utf-8'))
64
+ frame_size_kb = frame_size_bytes / 1024
65
+
66
+ # Log warnings for large messages
67
+ if frame_size_kb > 500: # Warn for messages > 500KB
68
+ logger.warning("🚨 LARGE WEBSOCKET MESSAGE: %.1f KB on channel %s (event: %s)",
69
+ frame_size_kb, channel_id, payload.get('event', 'unknown'))
70
+
71
+ # Log additional details for very large messages
72
+ if frame_size_kb > 1000: # > 1MB
73
+ logger.warning("🚨 VERY LARGE MESSAGE: %.1f KB - This may cause connection drops!", frame_size_kb)
74
+
75
+ # Try to identify what's making the message large
76
+ if isinstance(payload, dict):
77
+ large_fields = []
78
+ for key, value in payload.items():
79
+ if isinstance(value, (str, list, dict)):
80
+ field_size = len(json.dumps(value).encode('utf-8')) / 1024
81
+ if field_size > 100: # Fields > 100KB
82
+ large_fields.append(f"{key}: {field_size:.1f}KB")
83
+ if large_fields:
84
+ logger.warning("🚨 Large fields detected: %s", ", ".join(large_fields))
85
+
86
+ elif frame_size_kb > 100: # Info for messages > 100KB
87
+ logger.info("📦 Large websocket message: %.1f KB on channel %s (event: %s)",
88
+ frame_size_kb, channel_id, payload.get('event', 'unknown'))
89
+
90
+ # Send the frame
91
+ send_start = time.time()
92
+ await self._send_func(frame)
93
+ send_time = time.time() - send_start
94
+
95
+ total_time = time.time() - start_time
96
+
97
+ # Log performance metrics for large messages or slow operations
98
+ if frame_size_kb > 50 or total_time > 0.1: # Log for messages > 50KB or operations > 100ms
99
+ logger.info("📊 WebSocket send performance: %.1f KB in %.3fs (serialize: %.3fs, send: %.3fs) - channel %s",
100
+ frame_size_kb, total_time, serialization_time, send_time, channel_id)
101
+
102
+ # Log detailed timing for very large messages
103
+ if frame_size_kb > 200:
104
+ logger.info("🔍 Detailed timing - Channel: %s, Event: %s, Size: %.1f KB, Total: %.3fs",
105
+ channel_id, payload.get('event', 'unknown'), frame_size_kb, total_time)
106
+
107
+ except Exception as e:
108
+ total_time = time.time() - start_time
109
+ logger.error("❌ Failed to send websocket message on channel %s after %.3fs: %s",
110
+ channel_id, total_time, e)
111
+ raise
54
112
 
55
113
  async def on_raw_message(self, raw: str) -> None:
56
114
  try: