portacode 1.3.32__py3-none-any.whl → 1.4.15.dev3__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.
Files changed (57) hide show
  1. portacode/_version.py +2 -2
  2. portacode/cli.py +158 -14
  3. portacode/connection/client.py +127 -8
  4. portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +600 -4
  5. portacode/connection/handlers/__init__.py +30 -1
  6. portacode/connection/handlers/diff_handlers.py +603 -0
  7. portacode/connection/handlers/file_handlers.py +674 -17
  8. portacode/connection/handlers/project_aware_file_handlers.py +11 -0
  9. portacode/connection/handlers/project_state/file_system_watcher.py +31 -61
  10. portacode/connection/handlers/project_state/git_manager.py +139 -572
  11. portacode/connection/handlers/project_state/handlers.py +28 -14
  12. portacode/connection/handlers/project_state/manager.py +226 -101
  13. portacode/connection/handlers/proxmox_infra.py +2082 -0
  14. portacode/connection/handlers/session.py +465 -84
  15. portacode/connection/handlers/system_handlers.py +311 -9
  16. portacode/connection/handlers/tab_factory.py +1 -47
  17. portacode/connection/handlers/test_proxmox_infra.py +13 -0
  18. portacode/connection/handlers/update_handler.py +61 -0
  19. portacode/connection/terminal.py +64 -10
  20. portacode/keypair.py +63 -1
  21. portacode/link_capture/__init__.py +38 -0
  22. portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
  23. portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
  24. portacode/link_capture/bin/elinks +3 -0
  25. portacode/link_capture/bin/gio-open +3 -0
  26. portacode/link_capture/bin/gnome-open +3 -0
  27. portacode/link_capture/bin/gvfs-open +3 -0
  28. portacode/link_capture/bin/kde-open +3 -0
  29. portacode/link_capture/bin/kfmclient +3 -0
  30. portacode/link_capture/bin/link_capture_exec.sh +11 -0
  31. portacode/link_capture/bin/link_capture_wrapper.py +75 -0
  32. portacode/link_capture/bin/links +3 -0
  33. portacode/link_capture/bin/links2 +3 -0
  34. portacode/link_capture/bin/lynx +3 -0
  35. portacode/link_capture/bin/mate-open +3 -0
  36. portacode/link_capture/bin/netsurf +3 -0
  37. portacode/link_capture/bin/sensible-browser +3 -0
  38. portacode/link_capture/bin/w3m +3 -0
  39. portacode/link_capture/bin/x-www-browser +3 -0
  40. portacode/link_capture/bin/xdg-open +3 -0
  41. portacode/pairing.py +103 -0
  42. portacode/static/js/utils/ntp-clock.js +170 -79
  43. portacode/utils/diff_apply.py +456 -0
  44. portacode/utils/diff_renderer.py +371 -0
  45. portacode/utils/ntp_clock.py +45 -131
  46. {portacode-1.3.32.dist-info → portacode-1.4.15.dev3.dist-info}/METADATA +71 -3
  47. portacode-1.4.15.dev3.dist-info/RECORD +98 -0
  48. {portacode-1.3.32.dist-info → portacode-1.4.15.dev3.dist-info}/WHEEL +1 -1
  49. test_modules/test_device_online.py +1 -1
  50. test_modules/test_login_flow.py +8 -4
  51. test_modules/test_play_store_screenshots.py +294 -0
  52. testing_framework/.env.example +4 -1
  53. testing_framework/core/playwright_manager.py +63 -9
  54. portacode-1.3.32.dist-info/RECORD +0 -70
  55. {portacode-1.3.32.dist-info → portacode-1.4.15.dev3.dist-info}/entry_points.txt +0 -0
  56. {portacode-1.3.32.dist-info → portacode-1.4.15.dev3.dist-info}/licenses/LICENSE +0 -0
  57. {portacode-1.3.32.dist-info → portacode-1.4.15.dev3.dist-info}/top_level.txt +0 -0
@@ -1,17 +1,318 @@
1
1
  """System command handlers."""
2
+ from __future__ import annotations
2
3
 
4
+ import concurrent.futures
5
+ import getpass
6
+ import importlib.util
3
7
  import logging
4
8
  import os
5
9
  import platform
10
+ import shutil
11
+ import subprocess
12
+ import threading
13
+ import time
6
14
  from pathlib import Path
7
- from typing import Any, Dict
15
+ from typing import Any, Dict, Optional
16
+
8
17
  from portacode import __version__
9
18
  import psutil
10
19
 
20
+ try:
21
+ from importlib import metadata as importlib_metadata
22
+ except ImportError: # pragma: no cover - py<3.8
23
+ import importlib_metadata
24
+
11
25
  from .base import SyncHandler
26
+ from .proxmox_infra import get_infra_snapshot
12
27
 
13
28
  logger = logging.getLogger(__name__)
14
29
 
30
+ # Global CPU monitoring
31
+ _cpu_percent = 0.0
32
+ _cpu_thread = None
33
+ _cpu_lock = threading.Lock()
34
+
35
+ # Cgroup v2 tracking
36
+ _CGROUP_ROOT = Path("/sys/fs/cgroup")
37
+ _cgroup_path: Optional[Path] = None
38
+ _cgroup_v2_supported: Optional[bool] = None
39
+ _CGROUP_CPU_STAT = "cpu.stat"
40
+ _CGROUP_CPU_MAX = "cpu.max"
41
+ _last_cgroup_usage: Optional[int] = None
42
+ _last_cgroup_time: Optional[float] = None
43
+
44
+ def _cpu_monitor():
45
+ """Background thread to update CPU usage every 5 seconds."""
46
+ global _cpu_percent
47
+ while True:
48
+ percent = _get_cgroup_cpu_percent()
49
+ if percent is None:
50
+ # Fall back to psutil when cgroup stats are not available yet.
51
+ percent = psutil.cpu_percent(interval=5.0)
52
+ else:
53
+ time.sleep(5.0)
54
+ _cpu_percent = percent
55
+
56
+ def _ensure_cpu_thread():
57
+ """Ensure CPU monitoring thread is running (singleton)."""
58
+ global _cpu_thread
59
+ with _cpu_lock:
60
+ if _cpu_thread is None or not _cpu_thread.is_alive():
61
+ _cpu_thread = threading.Thread(target=_cpu_monitor, daemon=True)
62
+ _cpu_thread.start()
63
+
64
+
65
+ def _get_user_context() -> Dict[str, Any]:
66
+ """Gather current CLI user plus permission hints."""
67
+ context = {}
68
+ login_source = "os.getlogin"
69
+ try:
70
+ username = os.getlogin()
71
+ except Exception:
72
+ login_source = "getpass"
73
+ username = getpass.getuser()
74
+
75
+ context["username"] = username
76
+ context["username_source"] = login_source
77
+ context["home"] = str(Path.home())
78
+
79
+ uid = getattr(os, "getuid", None)
80
+ euid = getattr(os, "geteuid", None)
81
+ context["uid"] = uid() if uid else None
82
+ context["euid"] = euid() if euid else context["uid"]
83
+ if os.name == "nt":
84
+ try:
85
+ import ctypes
86
+
87
+ context["is_root"] = bool(ctypes.windll.shell32.IsUserAnAdmin())
88
+ except Exception:
89
+ context["is_root"] = None
90
+ else:
91
+ context["is_root"] = context["euid"] == 0 if context["euid"] is not None else False
92
+
93
+ context["has_sudo"] = shutil.which("sudo") is not None
94
+ context["sudo_user"] = os.environ.get("SUDO_USER")
95
+ context["is_sudo_session"] = bool(os.environ.get("SUDO_UID"))
96
+ return context
97
+
98
+
99
+ def _get_playwright_info() -> Dict[str, Any]:
100
+ """Return Playwright presence, version, and browser binaries if available."""
101
+ result: Dict[str, Any] = {
102
+ "installed": False,
103
+ "version": None,
104
+ "browsers": {},
105
+ "error": None,
106
+ }
107
+
108
+ if importlib.util.find_spec("playwright") is None:
109
+ return result
110
+
111
+ result["installed"] = True
112
+ try:
113
+ result["version"] = importlib_metadata.version("playwright")
114
+ except Exception as exc:
115
+ logger.debug("Unable to read Playwright version metadata: %s", exc)
116
+
117
+ def _inspect_browsers() -> Dict[str, Any]:
118
+ from playwright.sync_api import sync_playwright
119
+
120
+ browsers_data: Dict[str, Any] = {}
121
+ with sync_playwright() as p:
122
+ for name in ("chromium", "firefox", "webkit"):
123
+ browser_type = getattr(p, name, None)
124
+ if browser_type is None:
125
+ continue
126
+ exec_path = getattr(browser_type, "executable_path", None)
127
+ browsers_data[name] = {
128
+ "available": bool(exec_path),
129
+ "executable_path": exec_path,
130
+ }
131
+ return browsers_data
132
+
133
+ try:
134
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
135
+ future = executor.submit(_inspect_browsers)
136
+ browsers = future.result(timeout=5)
137
+ result["browsers"] = browsers
138
+ except concurrent.futures.TimeoutError:
139
+ msg = "Playwright inspection timed out"
140
+ logger.warning(msg)
141
+ result["error"] = msg
142
+ except Exception as exc:
143
+ logger.warning("Playwright browser inspection failed: %s", exc)
144
+ result["error"] = str(exc)
145
+
146
+ return result
147
+
148
+
149
+ def _resolve_cgroup_path() -> Path:
150
+ global _cgroup_path
151
+ if _cgroup_path is not None and _cgroup_path.exists():
152
+ return _cgroup_path
153
+ path = _CGROUP_ROOT
154
+ cgroup_file = Path("/proc/self/cgroup")
155
+ if cgroup_file.exists():
156
+ try:
157
+ contents = cgroup_file.read_text()
158
+ except OSError:
159
+ pass
160
+ else:
161
+ for line in contents.splitlines():
162
+ line = line.strip()
163
+ if not line:
164
+ continue
165
+ parts = line.split(":", 2)
166
+ if len(parts) < 3:
167
+ continue
168
+ rel_path = parts[-1].lstrip("/")
169
+ candidate = _CGROUP_ROOT / rel_path
170
+ # Fallback to root path if the relative path is empty
171
+ candidate = candidate if rel_path else _CGROUP_ROOT
172
+ if candidate.exists():
173
+ path = candidate
174
+ break
175
+ _cgroup_path = path
176
+ return _cgroup_path
177
+
178
+
179
+ def _cgroup_file(name: str) -> Path:
180
+ return _resolve_cgroup_path() / name
181
+
182
+
183
+ def _detect_cgroup_v2() -> bool:
184
+ global _cgroup_v2_supported
185
+ if _cgroup_v2_supported is not None:
186
+ return _cgroup_v2_supported
187
+ controllers = _cgroup_file("cgroup.controllers")
188
+ cpu_stat = _cgroup_file(_CGROUP_CPU_STAT)
189
+ _cgroup_v2_supported = controllers.exists() and cpu_stat.exists()
190
+ return _cgroup_v2_supported
191
+
192
+
193
+ def _read_cgroup_cpu_usage() -> Optional[int]:
194
+ path = _cgroup_file(_CGROUP_CPU_STAT)
195
+ try:
196
+ data = path.read_text()
197
+ except (OSError, UnicodeDecodeError):
198
+ return None
199
+ for line in data.splitlines():
200
+ parts = line.strip().split()
201
+ if len(parts) >= 2 and parts[0] == "usage_usec":
202
+ try:
203
+ return int(parts[1])
204
+ except ValueError:
205
+ return None
206
+ return None
207
+
208
+
209
+ def _read_cgroup_cpu_limit() -> Optional[float]:
210
+ """Return the allowed CPU (cores) for this cgroup, if limited."""
211
+ path = _cgroup_file(_CGROUP_CPU_MAX)
212
+ if not path.exists():
213
+ return None
214
+ try:
215
+ data = path.read_text().strip()
216
+ except (OSError, UnicodeDecodeError):
217
+ return None
218
+ parts = data.split()
219
+ if len(parts) < 2:
220
+ return None
221
+ quota, period = parts[0], parts[1]
222
+ if quota == "max":
223
+ return None
224
+ try:
225
+ quota_value = int(quota)
226
+ period_value = int(period)
227
+ except ValueError:
228
+ return None
229
+ if period_value <= 0:
230
+ return None
231
+ return quota_value / period_value
232
+
233
+
234
+ def _get_cgroup_cpu_percent() -> Optional[float]:
235
+ if not _detect_cgroup_v2():
236
+ return None
237
+ usage = _read_cgroup_cpu_usage()
238
+ if usage is None:
239
+ return None
240
+ now = time.monotonic()
241
+ global _last_cgroup_usage, _last_cgroup_time
242
+ prev_usage = _last_cgroup_usage
243
+ prev_time = _last_cgroup_time
244
+ _last_cgroup_usage = usage
245
+ _last_cgroup_time = now
246
+ if prev_usage is None or prev_time is None:
247
+ return None
248
+ delta_usage = usage - prev_usage
249
+ delta_time = now - prev_time
250
+ if delta_time <= 0 or delta_usage < 0:
251
+ return None
252
+ cpu_ratio = (delta_usage / 1_000_000) / delta_time
253
+ limit_cpus = _read_cgroup_cpu_limit()
254
+ if limit_cpus and limit_cpus > 0:
255
+ percent = (cpu_ratio / limit_cpus) * 100.0
256
+ else:
257
+ cpu_count = psutil.cpu_count(logical=True) or 1
258
+ percent = (cpu_ratio / cpu_count) * 100.0
259
+ return max(0.0, min(percent, 100.0))
260
+
261
+
262
+ def _run_probe_command(cmd: list[str]) -> str | None:
263
+ try:
264
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=3)
265
+ except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
266
+ return None
267
+ return result.stdout.strip()
268
+
269
+
270
+ def _parse_pveversion(output: str) -> str | None:
271
+ first_token = output.split(None, 1)[0] if output else ""
272
+ if not first_token:
273
+ return None
274
+ if "/" in first_token:
275
+ return first_token.split("/", 1)[1]
276
+ return first_token
277
+
278
+
279
+ def _parse_dpkg_version(output: str) -> str | None:
280
+ for line in output.splitlines():
281
+ if line.lower().startswith("version:"):
282
+ return line.split(":", 1)[1].strip()
283
+ return None
284
+
285
+
286
+ def _get_proxmox_version() -> str | None:
287
+ release_file = Path("/etc/proxmox-release")
288
+ if release_file.exists():
289
+ try:
290
+ return release_file.read_text().strip()
291
+ except Exception:
292
+ pass
293
+ value = _run_probe_command(["pveversion"])
294
+ parsed = _parse_pveversion(value or "")
295
+ if parsed:
296
+ return parsed
297
+ for pkg in ("pve-manager", "proxmox-ve"):
298
+ pkg_output = _run_probe_command(["dpkg", "-s", pkg])
299
+ parsed = _parse_dpkg_version(pkg_output or "")
300
+ if parsed:
301
+ return parsed
302
+ return None
303
+
304
+
305
+ def _get_proxmox_info() -> Dict[str, Any]:
306
+ """Detect if the current host is a Proxmox node."""
307
+ info: Dict[str, Any] = {"is_proxmox_node": False, "version": None}
308
+ if Path("/etc/proxmox-release").exists() or Path("/etc/pve").exists():
309
+ info["is_proxmox_node"] = True
310
+ version = _get_proxmox_version()
311
+ if version:
312
+ info["version"] = version
313
+ info["infra"] = get_infra_snapshot()
314
+ return info
315
+
15
316
 
16
317
  def _get_os_info() -> Dict[str, Any]:
17
318
  """Get operating system information with robust error handling."""
@@ -98,15 +399,13 @@ class SystemInfoHandler(SyncHandler):
98
399
  """Get system information including OS details."""
99
400
  logger.debug("Collecting system information...")
100
401
 
402
+ # Ensure CPU monitoring thread is running
403
+ _ensure_cpu_thread()
404
+
101
405
  # Collect basic system metrics
102
406
  info = {}
103
407
 
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
408
+ info["cpu_percent"] = _cpu_percent
110
409
 
111
410
  try:
112
411
  info["memory"] = psutil.virtual_memory()._asdict()
@@ -124,11 +423,14 @@ class SystemInfoHandler(SyncHandler):
124
423
 
125
424
  # Add OS information - this is critical for proper shell detection
126
425
  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"))
426
+ info["user_context"] = _get_user_context()
427
+ info["playwright"] = _get_playwright_info()
428
+ info["proxmox"] = _get_proxmox_info()
429
+ # logger.info("System info collected successfully with OS info: %s", info.get("os_info", {}).get("os_type", "Unknown"))
128
430
 
129
431
  info["portacode_version"] = __version__
130
432
 
131
433
  return {
132
434
  "event": "system_info",
133
435
  "info": info,
134
- }
436
+ }
@@ -162,53 +162,7 @@ class TabFactory:
162
162
  await self._load_binary_content(file_path, tab_info, file_size)
163
163
 
164
164
  return TabInfo(**tab_info)
165
-
166
- async def create_diff_tab(self, file_path: str, original_content: str,
167
- modified_content: str, tab_id: Optional[str] = None,
168
- diff_details: Optional[Dict[str, Any]] = None) -> TabInfo:
169
- """Create a diff tab for comparing file versions.
170
-
171
- Args:
172
- file_path: Path to the file being compared
173
- original_content: Original version of the file
174
- modified_content: Modified version of the file
175
- tab_id: Optional tab ID, will generate UUID if not provided
176
- diff_details: Optional detailed diff information from diff-match-patch
177
-
178
- Returns:
179
- TabInfo object configured for diff viewing
180
- """
181
- if tab_id is None:
182
- tab_id = str(uuid.uuid4())
183
-
184
- file_path = Path(file_path)
185
-
186
- metadata = {'diff_mode': True}
187
- if diff_details:
188
- metadata['diff_details'] = diff_details
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
-
196
- return TabInfo(
197
- tab_id=tab_id,
198
- tab_type='diff',
199
- title=f"{file_path.name} (diff)",
200
- file_path=str(file_path),
201
- content=None, # Diff tabs don't use regular content
202
- original_content=original_content,
203
- modified_content=modified_content,
204
- original_content_hash=original_hash,
205
- modified_content_hash=modified_hash,
206
- is_dirty=False,
207
- mime_type=None,
208
- encoding='utf-8',
209
- metadata=metadata
210
- )
211
-
165
+
212
166
  async def create_diff_tab_with_title(self, file_path: str, original_content: str,
213
167
  modified_content: str, title: str,
214
168
  tab_id: Optional[str] = None,
@@ -0,0 +1,13 @@
1
+ from unittest import TestCase
2
+
3
+ from portacode.connection.handlers.proxmox_infra import _build_bootstrap_steps
4
+
5
+
6
+ class ProxmoxInfraHandlerTests(TestCase):
7
+ def test_build_bootstrap_steps_includes_portacode_connect_by_default(self):
8
+ steps = _build_bootstrap_steps("svcuser", "pass", "", include_portacode_connect=True)
9
+ self.assertTrue(any(step.get("name") == "portacode_connect" for step in steps))
10
+
11
+ def test_build_bootstrap_steps_skips_portacode_connect_when_requested(self):
12
+ steps = _build_bootstrap_steps("svcuser", "pass", "", include_portacode_connect=False)
13
+ self.assertFalse(any(step.get("name") == "portacode_connect" for step in steps))
@@ -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
+ }
@@ -21,6 +21,8 @@ import time
21
21
  from dataclasses import asdict
22
22
  from typing import Any, Dict, Optional, List
23
23
 
24
+ from websockets.exceptions import ConnectionClosedError
25
+
24
26
  from .multiplex import Multiplexer, Channel
25
27
  from .handlers import (
26
28
  CommandRegistry,
@@ -33,8 +35,11 @@ from .handlers import (
33
35
  DirectoryListHandler,
34
36
  FileInfoHandler,
35
37
  FileDeleteHandler,
38
+ FileSearchHandler,
36
39
  FileRenameHandler,
37
40
  ContentRequestHandler,
41
+ FileApplyDiffHandler,
42
+ FilePreviewDiffHandler,
38
43
  ProjectStateFolderExpandHandler,
39
44
  ProjectStateFolderCollapseHandler,
40
45
  ProjectStateFileOpenHandler,
@@ -46,6 +51,14 @@ from .handlers import (
46
51
  ProjectStateGitUnstageHandler,
47
52
  ProjectStateGitRevertHandler,
48
53
  ProjectStateGitCommitHandler,
54
+ UpdatePortacodeHandler,
55
+ ConfigureProxmoxInfraHandler,
56
+ CreateProxmoxContainerHandler,
57
+ RevertProxmoxInfraHandler,
58
+ StartPortacodeServiceHandler,
59
+ StartProxmoxContainerHandler,
60
+ StopProxmoxContainerHandler,
61
+ RemoveProxmoxContainerHandler,
49
62
  )
50
63
  from .handlers.project_aware_file_handlers import (
51
64
  ProjectAwareFileWriteHandler,
@@ -108,7 +121,7 @@ class ClientSessionManager:
108
121
  self._write_debug_file()
109
122
  return newly_added_sessions
110
123
 
111
- def cleanup_client_session_explicitly(self, client_session_id: str):
124
+ async def cleanup_client_session_explicitly(self, client_session_id: str):
112
125
  """Explicitly clean up resources for a client session when notified by server."""
113
126
  logger.info("Explicitly cleaning up resources for client session: %s", client_session_id)
114
127
 
@@ -124,7 +137,7 @@ class ClientSessionManager:
124
137
  if control_channel:
125
138
  project_manager = _get_or_create_project_state_manager(context, control_channel)
126
139
  logger.info("Cleaning up project state for client session: %s", client_session_id)
127
- project_manager.cleanup_projects_by_client_session(client_session_id)
140
+ await project_manager.cleanup_projects_by_client_session(client_session_id)
128
141
  else:
129
142
  logger.warning("No control channel available for project state cleanup")
130
143
  else:
@@ -173,7 +186,7 @@ class ClientSessionManager:
173
186
  for session_id in existing_project_states:
174
187
  if session_id not in current_project_sessions:
175
188
  logger.info(f"Cleaning up project state for session {session_id} (no longer a project session)")
176
- project_manager.cleanup_project(session_id)
189
+ await project_manager.cleanup_project(session_id)
177
190
 
178
191
  # Initialize project states for new project sessions
179
192
  for session_name in newly_added_sessions:
@@ -185,6 +198,9 @@ class ClientSessionManager:
185
198
  project_folder_path = session.get('project_folder_path')
186
199
 
187
200
  if project_id is not None and project_folder_path:
201
+ if session_name in project_manager.projects:
202
+ logger.info("Project state already exists for session %s, skipping re-init", session_name)
203
+ continue
188
204
  logger.info(f"Initializing project state for new project session {session_name}: {project_folder_path}")
189
205
 
190
206
  try:
@@ -197,7 +213,6 @@ class ClientSessionManager:
197
213
 
198
214
  except Exception as e:
199
215
  logger.error(f"Failed to initialize project state for {session_name}: {e}")
200
-
201
216
  except Exception as e:
202
217
  logger.error("Error managing project states for session changes: %s", e)
203
218
 
@@ -216,7 +231,7 @@ class ClientSessionManager:
216
231
  control_channel = getattr(self._terminal_manager, '_control_channel', None)
217
232
  if control_channel:
218
233
  project_manager = _get_or_create_project_state_manager(context, control_channel)
219
- project_manager.cleanup_orphaned_project_states(current_sessions)
234
+ await project_manager.cleanup_orphaned_project_states(current_sessions)
220
235
  else:
221
236
  logger.warning("No control channel available for orphaned project state cleanup")
222
237
  else:
@@ -403,6 +418,7 @@ class TerminalManager:
403
418
  "mux": mux,
404
419
  "use_content_caching": True, # Enable content caching optimization
405
420
  "debug": self.debug,
421
+ "event_loop": asyncio.get_running_loop(),
406
422
  }
407
423
 
408
424
  # Initialize command registry
@@ -419,6 +435,14 @@ class TerminalManager:
419
435
  pass
420
436
  self._ctl_task = asyncio.create_task(self._control_loop())
421
437
 
438
+ # Start periodic system info sender
439
+ if getattr(self, "_system_info_task", None):
440
+ try:
441
+ self._system_info_task.cancel()
442
+ except Exception:
443
+ pass
444
+ self._system_info_task = asyncio.create_task(self._periodic_system_info())
445
+
422
446
  # For initial connections, request client sessions after control loop starts
423
447
  if is_initial:
424
448
  asyncio.create_task(self._initial_connection_setup())
@@ -439,7 +463,10 @@ class TerminalManager:
439
463
  self._command_registry.register(ProjectAwareFileCreateHandler) # Use project-aware version
440
464
  self._command_registry.register(ProjectAwareFolderCreateHandler) # Use project-aware version
441
465
  self._command_registry.register(FileRenameHandler)
466
+ self._command_registry.register(FileSearchHandler)
442
467
  self._command_registry.register(ContentRequestHandler)
468
+ self._command_registry.register(FileApplyDiffHandler)
469
+ self._command_registry.register(FilePreviewDiffHandler)
443
470
  # Project state handlers
444
471
  self._command_registry.register(ProjectStateFolderExpandHandler)
445
472
  self._command_registry.register(ProjectStateFolderCollapseHandler)
@@ -452,6 +479,15 @@ class TerminalManager:
452
479
  self._command_registry.register(ProjectStateGitUnstageHandler)
453
480
  self._command_registry.register(ProjectStateGitRevertHandler)
454
481
  self._command_registry.register(ProjectStateGitCommitHandler)
482
+ # System management handlers
483
+ self._command_registry.register(ConfigureProxmoxInfraHandler)
484
+ self._command_registry.register(CreateProxmoxContainerHandler)
485
+ self._command_registry.register(StartPortacodeServiceHandler)
486
+ self._command_registry.register(StartProxmoxContainerHandler)
487
+ self._command_registry.register(StopProxmoxContainerHandler)
488
+ self._command_registry.register(RemoveProxmoxContainerHandler)
489
+ self._command_registry.register(RevertProxmoxInfraHandler)
490
+ self._command_registry.register(UpdatePortacodeHandler)
455
491
 
456
492
  # ---------------------------------------------------------------------
457
493
  # Control loop – receives commands from gateway
@@ -516,6 +552,20 @@ class TerminalManager:
516
552
  # Continue processing other messages
517
553
  continue
518
554
 
555
+ async def _periodic_system_info(self) -> None:
556
+ """Send system_info event every 10 seconds when clients are connected."""
557
+ while True:
558
+ try:
559
+ await asyncio.sleep(10)
560
+ if self._client_session_manager.has_interested_clients():
561
+ from .handlers.system_handlers import SystemInfoHandler
562
+ handler = SystemInfoHandler(self._control_channel, self._context)
563
+ system_info = handler.execute({})
564
+ await self._send_session_aware(system_info)
565
+ except Exception as exc:
566
+ logger.exception("Error in periodic system info: %s", exc)
567
+ continue
568
+
519
569
  async def _send_initial_data_to_clients(self, newly_added_sessions: List[str] = None):
520
570
  """Send initial system info and terminal list to connected clients.
521
571
 
@@ -767,11 +817,15 @@ class TerminalManager:
767
817
  terminal_id = payload.get("channel", "unknown")
768
818
  logger.info("terminal_manager: Dispatching %s event (terminal_id=%s, data_size=%d bytes) to %d client sessions",
769
819
  event_type, terminal_id, data_size, len(target_sessions))
770
- else:
771
- logger.info("terminal_manager: Dispatching %s event to %d client sessions",
772
- event_type, len(target_sessions))
820
+ # else:
821
+ # logger.info("terminal_manager: Dispatching %s event to %d client sessions",
822
+ # event_type, len(target_sessions))
773
823
 
774
- await self._control_channel.send(enhanced_payload)
824
+ try:
825
+ await self._control_channel.send(enhanced_payload)
826
+ except ConnectionClosedError as exc:
827
+ logger.warning("terminal_manager: Connection closed (%s); skipping %s event", exc, event_type)
828
+ return
775
829
 
776
830
  async def _send_terminal_list(self) -> None:
777
831
  """Send terminal list for reconnection reconciliation."""
@@ -828,4 +882,4 @@ class TerminalManager:
828
882
  await self._request_client_sessions()
829
883
  logger.info("Client session request sent after reconnection")
830
884
  except Exception as exc:
831
- logger.error("Failed to handle reconnection: %s", exc)
885
+ logger.error("Failed to handle reconnection: %s", exc)