portacode 0.3.19.dev4__py3-none-any.whl → 1.4.11.dev1__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 (92) hide show
  1. portacode/_version.py +16 -3
  2. portacode/cli.py +143 -17
  3. portacode/connection/client.py +149 -10
  4. portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +824 -21
  5. portacode/connection/handlers/__init__.py +28 -1
  6. portacode/connection/handlers/base.py +78 -16
  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 -2185
  20. portacode/connection/handlers/proxmox_infra.py +361 -0
  21. portacode/connection/handlers/registry.py +15 -4
  22. portacode/connection/handlers/session.py +483 -32
  23. portacode/connection/handlers/system_handlers.py +147 -8
  24. portacode/connection/handlers/tab_factory.py +53 -46
  25. portacode/connection/handlers/terminal_handlers.py +21 -8
  26. portacode/connection/handlers/update_handler.py +61 -0
  27. portacode/connection/multiplex.py +60 -2
  28. portacode/connection/terminal.py +214 -24
  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/static/js/test-ntp-clock.html +63 -0
  53. portacode/static/js/utils/ntp-clock.js +232 -0
  54. portacode/utils/NTP_ARCHITECTURE.md +136 -0
  55. portacode/utils/__init__.py +1 -0
  56. portacode/utils/diff_apply.py +456 -0
  57. portacode/utils/diff_renderer.py +371 -0
  58. portacode/utils/ntp_clock.py +65 -0
  59. portacode-1.4.11.dev1.dist-info/METADATA +298 -0
  60. portacode-1.4.11.dev1.dist-info/RECORD +97 -0
  61. {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info}/WHEEL +1 -1
  62. portacode-1.4.11.dev1.dist-info/top_level.txt +3 -0
  63. test_modules/README.md +296 -0
  64. test_modules/__init__.py +1 -0
  65. test_modules/test_device_online.py +44 -0
  66. test_modules/test_file_operations.py +743 -0
  67. test_modules/test_git_status_ui.py +370 -0
  68. test_modules/test_login_flow.py +50 -0
  69. test_modules/test_navigate_testing_folder.py +361 -0
  70. test_modules/test_play_store_screenshots.py +294 -0
  71. test_modules/test_terminal_buffer_performance.py +261 -0
  72. test_modules/test_terminal_interaction.py +80 -0
  73. test_modules/test_terminal_loading_race_condition.py +95 -0
  74. test_modules/test_terminal_start.py +56 -0
  75. testing_framework/.env.example +21 -0
  76. testing_framework/README.md +334 -0
  77. testing_framework/__init__.py +17 -0
  78. testing_framework/cli.py +326 -0
  79. testing_framework/core/__init__.py +1 -0
  80. testing_framework/core/base_test.py +336 -0
  81. testing_framework/core/cli_manager.py +177 -0
  82. testing_framework/core/hierarchical_runner.py +577 -0
  83. testing_framework/core/playwright_manager.py +520 -0
  84. testing_framework/core/runner.py +447 -0
  85. testing_framework/core/shared_cli_manager.py +234 -0
  86. testing_framework/core/test_discovery.py +112 -0
  87. testing_framework/requirements.txt +12 -0
  88. portacode-0.3.19.dev4.dist-info/METADATA +0 -241
  89. portacode-0.3.19.dev4.dist-info/RECORD +0 -30
  90. portacode-0.3.19.dev4.dist-info/top_level.txt +0 -1
  91. {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info}/entry_points.txt +0 -0
  92. {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info/licenses}/LICENSE +0 -0
@@ -1,17 +1,155 @@
1
1
  """System command handlers."""
2
2
 
3
+ import concurrent.futures
4
+ import getpass
5
+ import importlib.util
3
6
  import logging
4
7
  import os
5
8
  import platform
9
+ import shutil
10
+ import threading
6
11
  from pathlib import Path
7
12
  from typing import Any, Dict
13
+
8
14
  from portacode import __version__
9
15
  import psutil
10
16
 
17
+ try:
18
+ from importlib import metadata as importlib_metadata
19
+ except ImportError: # pragma: no cover - py<3.8
20
+ import importlib_metadata
21
+
11
22
  from .base import SyncHandler
23
+ from .proxmox_infra import get_infra_snapshot
12
24
 
13
25
  logger = logging.getLogger(__name__)
14
26
 
27
+ # Global CPU monitoring
28
+ _cpu_percent = 0.0
29
+ _cpu_thread = None
30
+ _cpu_lock = threading.Lock()
31
+
32
+ def _cpu_monitor():
33
+ """Background thread to update CPU usage every 5 seconds."""
34
+ global _cpu_percent
35
+ while True:
36
+ _cpu_percent = psutil.cpu_percent(interval=5.0)
37
+
38
+ def _ensure_cpu_thread():
39
+ """Ensure CPU monitoring thread is running (singleton)."""
40
+ global _cpu_thread
41
+ with _cpu_lock:
42
+ if _cpu_thread is None or not _cpu_thread.is_alive():
43
+ _cpu_thread = threading.Thread(target=_cpu_monitor, daemon=True)
44
+ _cpu_thread.start()
45
+
46
+
47
+ def _get_user_context() -> Dict[str, Any]:
48
+ """Gather current CLI user plus permission hints."""
49
+ context = {}
50
+ login_source = "os.getlogin"
51
+ try:
52
+ username = os.getlogin()
53
+ except Exception:
54
+ login_source = "getpass"
55
+ username = getpass.getuser()
56
+
57
+ context["username"] = username
58
+ context["username_source"] = login_source
59
+ context["home"] = str(Path.home())
60
+
61
+ uid = getattr(os, "getuid", None)
62
+ euid = getattr(os, "geteuid", None)
63
+ context["uid"] = uid() if uid else None
64
+ context["euid"] = euid() if euid else context["uid"]
65
+ if os.name == "nt":
66
+ try:
67
+ import ctypes
68
+
69
+ context["is_root"] = bool(ctypes.windll.shell32.IsUserAnAdmin())
70
+ except Exception:
71
+ context["is_root"] = None
72
+ else:
73
+ context["is_root"] = context["euid"] == 0 if context["euid"] is not None else False
74
+
75
+ context["has_sudo"] = shutil.which("sudo") is not None
76
+ context["sudo_user"] = os.environ.get("SUDO_USER")
77
+ context["is_sudo_session"] = bool(os.environ.get("SUDO_UID"))
78
+ return context
79
+
80
+
81
+ def _get_playwright_info() -> Dict[str, Any]:
82
+ """Return Playwright presence, version, and browser binaries if available."""
83
+ result: Dict[str, Any] = {
84
+ "installed": False,
85
+ "version": None,
86
+ "browsers": {},
87
+ "error": None,
88
+ }
89
+
90
+ if importlib.util.find_spec("playwright") is None:
91
+ return result
92
+
93
+ result["installed"] = True
94
+ try:
95
+ result["version"] = importlib_metadata.version("playwright")
96
+ except Exception as exc:
97
+ logger.debug("Unable to read Playwright version metadata: %s", exc)
98
+
99
+ def _inspect_browsers() -> Dict[str, Any]:
100
+ from playwright.sync_api import sync_playwright
101
+
102
+ browsers_data: Dict[str, Any] = {}
103
+ with sync_playwright() as p:
104
+ for name in ("chromium", "firefox", "webkit"):
105
+ browser_type = getattr(p, name, None)
106
+ if browser_type is None:
107
+ continue
108
+ exec_path = getattr(browser_type, "executable_path", None)
109
+ browsers_data[name] = {
110
+ "available": bool(exec_path),
111
+ "executable_path": exec_path,
112
+ }
113
+ return browsers_data
114
+
115
+ try:
116
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
117
+ future = executor.submit(_inspect_browsers)
118
+ browsers = future.result(timeout=5)
119
+ result["browsers"] = browsers
120
+ except concurrent.futures.TimeoutError:
121
+ msg = "Playwright inspection timed out"
122
+ logger.warning(msg)
123
+ result["error"] = msg
124
+ except Exception as exc:
125
+ logger.warning("Playwright browser inspection failed: %s", exc)
126
+ result["error"] = str(exc)
127
+
128
+ return result
129
+
130
+
131
+ def _get_proxmox_info() -> Dict[str, Any]:
132
+ """Detect if the current host is a Proxmox node."""
133
+ info: Dict[str, Any] = {"is_proxmox_node": False, "version": None}
134
+ release_file = Path("/etc/proxmox-release")
135
+ if release_file.exists():
136
+ info["is_proxmox_node"] = True
137
+ try:
138
+ info["version"] = release_file.read_text().strip()
139
+ except Exception:
140
+ info["version"] = None
141
+ elif Path("/etc/pve").exists():
142
+ info["is_proxmox_node"] = True
143
+ if not info["version"]:
144
+ version_hint = Path("/etc/pve/.version")
145
+ if version_hint.exists():
146
+ try:
147
+ info["version"] = version_hint.read_text().strip()
148
+ except Exception:
149
+ info["version"] = None
150
+ info["infra"] = get_infra_snapshot()
151
+ return info
152
+
15
153
 
16
154
  def _get_os_info() -> Dict[str, Any]:
17
155
  """Get operating system information with robust error handling."""
@@ -98,15 +236,13 @@ class SystemInfoHandler(SyncHandler):
98
236
  """Get system information including OS details."""
99
237
  logger.debug("Collecting system information...")
100
238
 
239
+ # Ensure CPU monitoring thread is running
240
+ _ensure_cpu_thread()
241
+
101
242
  # Collect basic system metrics
102
243
  info = {}
103
244
 
104
- try:
105
- info["cpu_percent"] = psutil.cpu_percent(interval=0.1)
106
- logger.debug("CPU usage: %s%%", info["cpu_percent"])
107
- except Exception as e:
108
- logger.warning("Failed to get CPU info: %s", e)
109
- info["cpu_percent"] = 0.0
245
+ info["cpu_percent"] = _cpu_percent
110
246
 
111
247
  try:
112
248
  info["memory"] = psutil.virtual_memory()._asdict()
@@ -124,11 +260,14 @@ class SystemInfoHandler(SyncHandler):
124
260
 
125
261
  # Add OS information - this is critical for proper shell detection
126
262
  info["os_info"] = _get_os_info()
127
- logger.info("System info collected successfully with OS info: %s", info.get("os_info", {}).get("os_type", "Unknown"))
263
+ info["user_context"] = _get_user_context()
264
+ info["playwright"] = _get_playwright_info()
265
+ info["proxmox"] = _get_proxmox_info()
266
+ # logger.info("System info collected successfully with OS info: %s", info.get("os_info", {}).get("os_type", "Unknown"))
128
267
 
129
268
  info["portacode_version"] = __version__
130
269
 
131
270
  return {
132
271
  "event": "system_info",
133
272
  "info": info,
134
- }
273
+ }
@@ -14,6 +14,8 @@ from pathlib import Path
14
14
  from typing import Optional, Dict, Any
15
15
 
16
16
  from .project_state_handlers import TabInfo
17
+ from .project_state.utils import generate_content_hash
18
+ from .file_handlers import cache_content
17
19
 
18
20
  logger = logging.getLogger(__name__)
19
21
 
@@ -57,7 +59,7 @@ MEDIA_EXTENSIONS = {
57
59
  '.ico', '.icns', '.cur', '.psd', '.ai', '.eps', '.raw', '.cr2', '.nef',
58
60
 
59
61
  # Audio
60
- '.mp3', '.wav', '.flac', '.aac', '.ogg', '.wma', '.m4a', '.opus', '.webm',
62
+ '.mp3', '.wav', '.flac', '.aac', '.ogg', '.oga', '.wma', '.m4a', '.opus', '.webm',
61
63
 
62
64
  # Video
63
65
  '.mp4', '.avi', '.mkv', '.mov', '.wmv', '.flv', '.webm', '.m4v', '.3gp',
@@ -140,7 +142,11 @@ class TabFactory:
140
142
  # Determine how to handle the file
141
143
  if extension in IGNORED_EXTENSIONS:
142
144
  tab_info['metadata']['ignored'] = True
143
- tab_info['content'] = f"# Binary file not displayed\n# File: {file_path.name}\n# Size: {self._format_file_size(file_size)}"
145
+ content = f"# Binary file not displayed\n# File: {file_path.name}\n# Size: {self._format_file_size(file_size)}"
146
+ tab_info['content'] = content
147
+ content_hash = generate_content_hash(content)
148
+ tab_info['content_hash'] = content_hash
149
+ cache_content(content_hash, content)
144
150
  return TabInfo(**tab_info)
145
151
 
146
152
  # Handle different file types
@@ -156,45 +162,7 @@ class TabFactory:
156
162
  await self._load_binary_content(file_path, tab_info, file_size)
157
163
 
158
164
  return TabInfo(**tab_info)
159
-
160
- async def create_diff_tab(self, file_path: str, original_content: str,
161
- modified_content: str, tab_id: Optional[str] = None,
162
- diff_details: Optional[Dict[str, Any]] = None) -> TabInfo:
163
- """Create a diff tab for comparing file versions.
164
-
165
- Args:
166
- file_path: Path to the file being compared
167
- original_content: Original version of the file
168
- modified_content: Modified version of the file
169
- tab_id: Optional tab ID, will generate UUID if not provided
170
- diff_details: Optional detailed diff information from diff-match-patch
171
-
172
- Returns:
173
- TabInfo object configured for diff viewing
174
- """
175
- if tab_id is None:
176
- tab_id = str(uuid.uuid4())
177
-
178
- file_path = Path(file_path)
179
-
180
- metadata = {'diff_mode': True}
181
- if diff_details:
182
- metadata['diff_details'] = diff_details
183
-
184
- return TabInfo(
185
- tab_id=tab_id,
186
- tab_type='diff',
187
- title=f"{file_path.name} (diff)",
188
- file_path=str(file_path),
189
- content=None, # Diff tabs don't use regular content
190
- original_content=original_content,
191
- modified_content=modified_content,
192
- is_dirty=False,
193
- mime_type=None,
194
- encoding='utf-8',
195
- metadata=metadata
196
- )
197
-
165
+
198
166
  async def create_diff_tab_with_title(self, file_path: str, original_content: str,
199
167
  modified_content: str, title: str,
200
168
  tab_id: Optional[str] = None,
@@ -219,6 +187,12 @@ class TabFactory:
219
187
  if diff_details:
220
188
  metadata['diff_details'] = diff_details
221
189
 
190
+ # Cache diff content
191
+ original_hash = generate_content_hash(original_content)
192
+ modified_hash = generate_content_hash(modified_content)
193
+ cache_content(original_hash, original_content)
194
+ cache_content(modified_hash, modified_content)
195
+
222
196
  return TabInfo(
223
197
  tab_id=tab_id,
224
198
  tab_type='diff',
@@ -227,6 +201,8 @@ class TabFactory:
227
201
  content=None, # Diff tabs don't use regular content
228
202
  original_content=original_content,
229
203
  modified_content=modified_content,
204
+ original_content_hash=original_hash,
205
+ modified_content_hash=modified_hash,
230
206
  is_dirty=False,
231
207
  mime_type=None,
232
208
  encoding='utf-8',
@@ -248,12 +224,17 @@ class TabFactory:
248
224
  if tab_id is None:
249
225
  tab_id = str(uuid.uuid4())
250
226
 
227
+ # Cache untitled content
228
+ content_hash = generate_content_hash(content)
229
+ cache_content(content_hash, content)
230
+
251
231
  return TabInfo(
252
232
  tab_id=tab_id,
253
233
  tab_type='untitled',
254
234
  title="Untitled",
255
235
  file_path=None,
256
236
  content=content,
237
+ content_hash=content_hash,
257
238
  original_content=None,
258
239
  modified_content=None,
259
240
  is_dirty=bool(content), # Dirty if has initial content
@@ -265,7 +246,11 @@ class TabFactory:
265
246
  async def _load_text_content(self, file_path: Path, tab_info: Dict[str, Any], file_size: int):
266
247
  """Load text content from file."""
267
248
  if file_size > MAX_TEXT_FILE_SIZE:
268
- tab_info['content'] = f"# File too large to display\n# File: {file_path.name}\n# Size: {self._format_file_size(file_size)}\n# Maximum size for text files: {self._format_file_size(MAX_TEXT_FILE_SIZE)}"
249
+ content = f"# File too large to display\n# File: {file_path.name}\n# Size: {self._format_file_size(file_size)}\n# Maximum size for text files: {self._format_file_size(MAX_TEXT_FILE_SIZE)}"
250
+ tab_info['content'] = content
251
+ content_hash = generate_content_hash(content)
252
+ tab_info['content_hash'] = content_hash
253
+ cache_content(content_hash, content)
269
254
  tab_info['metadata']['truncated'] = True
270
255
  return
271
256
 
@@ -275,6 +260,9 @@ class TabFactory:
275
260
  try:
276
261
  content = file_path.read_text(encoding=encoding)
277
262
  tab_info['content'] = content
263
+ content_hash = generate_content_hash(content)
264
+ tab_info['content_hash'] = content_hash
265
+ cache_content(content_hash, content)
278
266
  tab_info['encoding'] = encoding
279
267
  self.logger.debug(f"Successfully loaded {file_path} with {encoding} encoding")
280
268
  return
@@ -287,14 +275,22 @@ class TabFactory:
287
275
 
288
276
  except OSError as e:
289
277
  self.logger.error(f"Error reading file {file_path}: {e}")
290
- tab_info['content'] = f"# Error reading file\n# {e}"
278
+ content = f"# Error reading file\n# {e}"
279
+ tab_info['content'] = content
280
+ content_hash = generate_content_hash(content)
281
+ tab_info['content_hash'] = content_hash
282
+ cache_content(content_hash, content)
291
283
  tab_info['metadata']['error'] = str(e)
292
284
 
293
285
  async def _load_media_content(self, file_path: Path, tab_info: Dict[str, Any],
294
286
  file_size: int, mime_type: Optional[str]):
295
287
  """Load media content as base64."""
296
288
  if file_size > MAX_BINARY_FILE_SIZE:
297
- tab_info['content'] = f"# Media file too large to display\n# File: {file_path.name}\n# Size: {self._format_file_size(file_size)}"
289
+ content = f"# Media file too large to display\n# File: {file_path.name}\n# Size: {self._format_file_size(file_size)}"
290
+ tab_info['content'] = content
291
+ content_hash = generate_content_hash(content)
292
+ tab_info['content_hash'] = content_hash
293
+ cache_content(content_hash, content)
298
294
  tab_info['metadata']['too_large'] = True
299
295
  return
300
296
 
@@ -313,6 +309,9 @@ class TabFactory:
313
309
  base64_content = base64.b64encode(binary_content).decode('ascii')
314
310
 
315
311
  tab_info['content'] = base64_content
312
+ content_hash = generate_content_hash(base64_content)
313
+ tab_info['content_hash'] = content_hash
314
+ cache_content(content_hash, base64_content)
316
315
  tab_info['encoding'] = 'base64'
317
316
  tab_info['metadata']['original_size'] = file_size
318
317
 
@@ -320,12 +319,20 @@ class TabFactory:
320
319
 
321
320
  except OSError as e:
322
321
  self.logger.error(f"Error reading media file {file_path}: {e}")
323
- tab_info['content'] = f"# Error loading media file\n# {e}"
322
+ content = f"# Error loading media file\n# {e}"
323
+ tab_info['content'] = content
324
+ content_hash = generate_content_hash(content)
325
+ tab_info['content_hash'] = content_hash
326
+ cache_content(content_hash, content)
324
327
  tab_info['metadata']['error'] = str(e)
325
328
 
326
329
  async def _load_binary_content(self, file_path: Path, tab_info: Dict[str, Any], file_size: int):
327
330
  """Handle binary files that can't be displayed."""
328
- tab_info['content'] = f"# Binary file\n# File: {file_path.name}\n# Size: {self._format_file_size(file_size)}\n# Type: {tab_info.get('mime_type', 'Unknown')}\n\n# This file contains binary data and cannot be displayed as text."
331
+ content = f"# Binary file\n# File: {file_path.name}\n# Size: {self._format_file_size(file_size)}\n# Type: {tab_info.get('mime_type', 'Unknown')}\n\n# This file contains binary data and cannot be displayed as text."
332
+ tab_info['content'] = content
333
+ content_hash = generate_content_hash(content)
334
+ tab_info['content_hash'] = content_hash
335
+ cache_content(content_hash, content)
329
336
  tab_info['metadata']['binary'] = True
330
337
  self.logger.debug(f"Marked {file_path} as binary file")
331
338
 
@@ -218,24 +218,33 @@ class TerminalListHandler(AsyncHandler):
218
218
 
219
219
  async def handle(self, message: Dict[str, Any], reply_channel: Optional[str] = None) -> None:
220
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",
221
+ logger.info("handler: Processing command %s with reply_channel=%s",
222
222
  self.command_name, reply_channel)
223
-
223
+
224
224
  try:
225
225
  response = await self.execute(message)
226
226
  logger.info("handler: Command %s executed successfully", self.command_name)
227
-
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
+
228
232
  # Get the source client session from the message
229
233
  source_client_session = message.get("source_client_session")
230
234
  project_id = response.get("project_id")
231
-
232
- logger.info("handler: %s response project_id=%s, source_client_session=%s",
235
+
236
+ logger.info("handler: %s response project_id=%s, source_client_session=%s",
233
237
  self.command_name, project_id, source_client_session)
234
-
238
+
235
239
  # Send response only to the requesting client session
236
240
  if source_client_session:
237
241
  # Add client_sessions field to target only the requesting session
238
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
+
239
248
  await self.control_channel.send(response)
240
249
  else:
241
250
  # Fallback to original behavior if no source_client_session
@@ -249,15 +258,19 @@ class TerminalListHandler(AsyncHandler):
249
258
  session_manager = self.context.get("session_manager")
250
259
  if not session_manager:
251
260
  raise RuntimeError("Session manager not available")
252
-
261
+
253
262
  # Accept project_id argument: None (default) = only no project, 'all' = all, else = filter by project_id
254
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))
255
265
 
256
266
  if requested_project_id == "all":
267
+ logger.info("terminal_list: Using 'all' mode to list all terminals")
257
268
  sessions = session_manager.list_sessions(project_id="all")
258
269
  else:
270
+ logger.info("terminal_list: Filtering by project_id=%r", requested_project_id)
259
271
  sessions = session_manager.list_sessions(project_id=requested_project_id)
260
-
272
+
273
+ logger.info("terminal_list: Found %d sessions, returning with project_id=%r", len(sessions), requested_project_id)
261
274
  return {
262
275
  "event": "terminal_list",
263
276
  "sessions": sessions,
@@ -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: