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
@@ -16,8 +16,13 @@ in the handlers directory.
16
16
  import asyncio
17
17
  import json
18
18
  import logging
19
+ import os
20
+ import time
21
+ from dataclasses import asdict
19
22
  from typing import Any, Dict, Optional, List
20
23
 
24
+ from websockets.exceptions import ConnectionClosedError
25
+
21
26
  from .multiplex import Multiplexer, Channel
22
27
  from .handlers import (
23
28
  CommandRegistry,
@@ -26,13 +31,329 @@ from .handlers import (
26
31
  TerminalStopHandler,
27
32
  TerminalListHandler,
28
33
  SystemInfoHandler,
34
+ FileReadHandler,
35
+ DirectoryListHandler,
36
+ FileInfoHandler,
37
+ FileDeleteHandler,
38
+ FileSearchHandler,
39
+ FileRenameHandler,
40
+ ContentRequestHandler,
41
+ FileApplyDiffHandler,
42
+ FilePreviewDiffHandler,
43
+ ProjectStateFolderExpandHandler,
44
+ ProjectStateFolderCollapseHandler,
45
+ ProjectStateFileOpenHandler,
46
+ ProjectStateTabCloseHandler,
47
+ ProjectStateSetActiveTabHandler,
48
+ ProjectStateDiffOpenHandler,
49
+ ProjectStateDiffContentHandler,
50
+ ProjectStateGitStageHandler,
51
+ ProjectStateGitUnstageHandler,
52
+ ProjectStateGitRevertHandler,
53
+ ProjectStateGitCommitHandler,
54
+ UpdatePortacodeHandler,
55
+ ConfigureProxmoxInfraHandler,
56
+ )
57
+ from .handlers.project_aware_file_handlers import (
58
+ ProjectAwareFileWriteHandler,
59
+ ProjectAwareFileCreateHandler,
60
+ ProjectAwareFolderCreateHandler,
29
61
  )
30
62
  from .handlers.session import SessionManager
31
63
 
32
64
  logger = logging.getLogger(__name__)
33
65
 
66
+ class ClientSessionManager:
67
+ """Manages connected client sessions for the device."""
68
+
69
+ def __init__(self):
70
+ self._client_sessions = {}
71
+ self._debug_file_path = os.path.join(os.getcwd(), "client_sessions.json")
72
+ logger.info("ClientSessionManager initialized")
73
+
74
+ def update_sessions(self, sessions: List[Dict]) -> List[str]:
75
+ """Update the client sessions with new data from server.
76
+
77
+ Returns:
78
+ List of channel_names for newly added sessions
79
+ """
80
+ old_sessions = set(self._client_sessions.keys())
81
+ self._client_sessions = {}
82
+ for session in sessions:
83
+ channel_name = session.get("channel_name")
84
+ if channel_name:
85
+ self._client_sessions[channel_name] = session
86
+
87
+ new_sessions = set(self._client_sessions.keys())
88
+ newly_added_sessions = list(new_sessions - old_sessions)
89
+ disconnected_sessions = list(old_sessions - new_sessions)
90
+
91
+ logger.info(f"Updated client sessions: {len(self._client_sessions)} sessions, {len(newly_added_sessions)} newly added, {len(disconnected_sessions)} disconnected")
92
+ if newly_added_sessions:
93
+ logger.info(f"Newly added sessions: {newly_added_sessions}")
94
+ if disconnected_sessions:
95
+ logger.info(f"Disconnected sessions: {disconnected_sessions}")
96
+ # NOTE: Not automatically cleaning up project states for disconnected sessions
97
+ # to handle temporary disconnections gracefully. Project states will be cleaned
98
+ # up only when explicitly notified by the server of permanent disconnection.
99
+ logger.info("Project states preserved for potential reconnection of these sessions")
100
+
101
+ # Handle project state management based on session changes
102
+ if newly_added_sessions or disconnected_sessions:
103
+ # Schedule project state management to run asynchronously
104
+ import asyncio
105
+ try:
106
+ loop = asyncio.get_event_loop()
107
+ if loop.is_running():
108
+ # Schedule both cleanup and initialization
109
+ loop.create_task(self._manage_project_states_for_session_changes(newly_added_sessions, sessions))
110
+ else:
111
+ logger.debug("No event loop running, skipping project state management")
112
+ except Exception as e:
113
+ logger.debug("Could not schedule project state management: %s", e)
114
+
115
+ self._write_debug_file()
116
+ return newly_added_sessions
117
+
118
+ async def cleanup_client_session_explicitly(self, client_session_id: str):
119
+ """Explicitly clean up resources for a client session when notified by server."""
120
+ logger.info("Explicitly cleaning up resources for client session: %s", client_session_id)
121
+
122
+ # Import here to avoid circular imports
123
+ from .handlers.project_state_handlers import _get_or_create_project_state_manager
124
+
125
+ # Get the project state manager from context if it exists
126
+ if hasattr(self, '_terminal_manager') and self._terminal_manager:
127
+ context = getattr(self._terminal_manager, '_context', {})
128
+ if context:
129
+ # Get or create the project state manager
130
+ control_channel = getattr(self._terminal_manager, '_control_channel', None)
131
+ if control_channel:
132
+ project_manager = _get_or_create_project_state_manager(context, control_channel)
133
+ logger.info("Cleaning up project state for client session: %s", client_session_id)
134
+ await project_manager.cleanup_projects_by_client_session(client_session_id)
135
+ else:
136
+ logger.warning("No control channel available for project state cleanup")
137
+ else:
138
+ logger.warning("No context available for project state cleanup")
139
+ else:
140
+ logger.warning("No terminal manager available for project state cleanup")
141
+
142
+ async def _manage_project_states_for_session_changes(self, newly_added_sessions: List[str], all_sessions: List[Dict]):
143
+ """Comprehensive project state management for session changes."""
144
+ try:
145
+ # Import here to avoid circular imports
146
+ from .handlers.project_state_handlers import _get_or_create_project_state_manager
147
+
148
+ if not hasattr(self, '_terminal_manager') or not self._terminal_manager:
149
+ logger.warning("No terminal manager available for project state management")
150
+ return
151
+
152
+ context = getattr(self._terminal_manager, '_context', {})
153
+ if not context:
154
+ logger.warning("No context available for project state management")
155
+ return
156
+
157
+ control_channel = getattr(self._terminal_manager, '_control_channel', None)
158
+ if not control_channel:
159
+ logger.warning("No control channel available for project state management")
160
+ return
161
+
162
+ project_manager = _get_or_create_project_state_manager(context, control_channel)
163
+
164
+ # Convert sessions list to dict for easier lookup
165
+ sessions_dict = {session.get('channel_name'): session for session in all_sessions if session.get('channel_name')}
166
+
167
+ # First, clean up project states for sessions that are no longer project sessions or don't exist
168
+ current_project_sessions = set()
169
+ for session in all_sessions:
170
+ channel_name = session.get('channel_name')
171
+ project_id = session.get('project_id')
172
+ project_folder_path = session.get('project_folder_path')
173
+
174
+ if channel_name and project_id is not None and project_folder_path:
175
+ current_project_sessions.add(channel_name)
176
+ logger.debug(f"Active project session: {channel_name} -> {project_folder_path} (project_id: {project_id})")
177
+
178
+ # Clean up project states that don't match current project sessions
179
+ existing_project_states = list(project_manager.projects.keys())
180
+ for session_id in existing_project_states:
181
+ if session_id not in current_project_sessions:
182
+ logger.info(f"Cleaning up project state for session {session_id} (no longer a project session)")
183
+ await project_manager.cleanup_project(session_id)
184
+
185
+ # Initialize project states for new project sessions
186
+ for session_name in newly_added_sessions:
187
+ session = sessions_dict.get(session_name)
188
+ if not session:
189
+ continue
190
+
191
+ project_id = session.get('project_id')
192
+ project_folder_path = session.get('project_folder_path')
193
+
194
+ if project_id is not None and project_folder_path:
195
+ if session_name in project_manager.projects:
196
+ logger.info("Project state already exists for session %s, skipping re-init", session_name)
197
+ continue
198
+ logger.info(f"Initializing project state for new project session {session_name}: {project_folder_path}")
199
+
200
+ try:
201
+ # Initialize project state (this includes migration logic)
202
+ project_state = await project_manager.initialize_project_state(session_name, project_folder_path)
203
+ logger.info(f"Successfully initialized project state for {session_name}")
204
+
205
+ # Send initial project state to the client
206
+ # (implementation can be added here if needed)
207
+
208
+ except Exception as e:
209
+ logger.error(f"Failed to initialize project state for {session_name}: {e}")
210
+ except Exception as e:
211
+ logger.error("Error managing project states for session changes: %s", e)
212
+
213
+ async def _cleanup_orphaned_project_states(self):
214
+ """Clean up project states that don't match any current client session."""
215
+ try:
216
+ # Import here to avoid circular imports
217
+ from .handlers.project_state_handlers import _get_or_create_project_state_manager
218
+
219
+ # Get current client session IDs
220
+ current_sessions = list(self._client_sessions.keys())
221
+
222
+ if hasattr(self, '_terminal_manager') and self._terminal_manager:
223
+ context = getattr(self._terminal_manager, '_context', {})
224
+ if context:
225
+ control_channel = getattr(self._terminal_manager, '_control_channel', None)
226
+ if control_channel:
227
+ project_manager = _get_or_create_project_state_manager(context, control_channel)
228
+ await project_manager.cleanup_orphaned_project_states(current_sessions)
229
+ else:
230
+ logger.warning("No control channel available for orphaned project state cleanup")
231
+ else:
232
+ logger.warning("No context available for orphaned project state cleanup")
233
+ else:
234
+ logger.warning("No terminal manager available for orphaned project state cleanup")
235
+
236
+ except Exception as e:
237
+ logger.error("Error cleaning up orphaned project states: %s", e)
238
+
239
+ def _cleanup_disconnected_sessions(self, disconnected_sessions: List[str]):
240
+ """Legacy method - now just logs disconnections without cleanup."""
241
+ logger.info("Sessions disconnected (but preserving project states): %s", disconnected_sessions)
242
+ # Project states are preserved to handle reconnections gracefully
243
+
244
+ def set_terminal_manager(self, terminal_manager):
245
+ """Set reference to terminal manager for cleanup purposes."""
246
+ self._terminal_manager = terminal_manager
247
+
248
+ def get_sessions(self) -> Dict[str, Dict]:
249
+ """Get all current client sessions."""
250
+ return self._client_sessions.copy()
251
+
252
+ def get_session_by_channel(self, channel_name: str) -> Optional[Dict]:
253
+ """Get a specific client session by channel name."""
254
+ return self._client_sessions.get(channel_name)
255
+
256
+ def get_sessions_for_project(self, project_id: str) -> List[Dict]:
257
+ """Get all client sessions for a specific project."""
258
+ return [
259
+ session for session in self._client_sessions.values()
260
+ if session.get("project_id") == project_id
261
+ ]
262
+
263
+ def get_sessions_for_user(self, user_id: int) -> List[Dict]:
264
+ """Get all client sessions for a specific user."""
265
+ return [
266
+ session for session in self._client_sessions.values()
267
+ if session.get("user_id") == user_id
268
+ ]
269
+
270
+ def has_interested_clients(self) -> bool:
271
+ """Check if there are any connected clients interested in this device."""
272
+ return len(self._client_sessions) > 0
273
+
274
+ def get_target_sessions(self, project_id: str = None) -> List[str]:
275
+ """Get list of channel_names for target client sessions.
276
+
277
+ Args:
278
+ project_id: If specified, only include sessions for this project.
279
+ Dashboard sessions only receive events when project_id is None/empty.
280
+
281
+ Returns:
282
+ List of channel_names to target
283
+ """
284
+ if not self._client_sessions:
285
+ return []
286
+
287
+ target_sessions = []
288
+ for session in self._client_sessions.values():
289
+ # Dashboard sessions only receive events when project_id is None/empty
290
+ if session.get("connection_type") == "dashboard":
291
+ if project_id is None:
292
+ target_sessions.append(session.get("channel_name"))
293
+ continue
294
+
295
+ # For project sessions, filter by project_id if specified
296
+ if project_id and session.get("project_id") != project_id:
297
+ continue
298
+ target_sessions.append(session.get("channel_name"))
299
+
300
+ return [s for s in target_sessions if s] # Filter out None values
301
+
302
+ def get_target_sessions_for_new_clients(self, new_session_names: List[str], project_id: str = None) -> List[str]:
303
+ """Get target sessions for newly added client sessions.
304
+
305
+ Args:
306
+ new_session_names: List of newly added session channel names
307
+ project_id: If specified, only include sessions for this project.
308
+ Dashboard sessions only receive events when project_id is None/empty.
309
+
310
+ Returns:
311
+ List of channel_names to target from newly added sessions
312
+ """
313
+ if not new_session_names or not self._client_sessions:
314
+ return []
315
+
316
+ target_sessions = []
317
+ for channel_name in new_session_names:
318
+ session = self._client_sessions.get(channel_name)
319
+ if not session:
320
+ continue
321
+
322
+ # Dashboard sessions only receive events when project_id is None/empty
323
+ if session.get("connection_type") == "dashboard":
324
+ if project_id is None:
325
+ target_sessions.append(channel_name)
326
+ continue
327
+
328
+ # For project sessions, filter by project_id if specified
329
+ if project_id and session.get("project_id") != project_id:
330
+ continue
331
+ target_sessions.append(channel_name)
332
+
333
+ return target_sessions
334
+
335
+ def get_reply_channel_for_compatibility(self) -> Optional[str]:
336
+ """Get the first session's channel_name for backward compatibility.
337
+
338
+ Returns:
339
+ First available channel_name or None
340
+ """
341
+ if not self._client_sessions:
342
+ return None
343
+ return next(iter(self._client_sessions.keys()), None)
344
+
345
+ def _write_debug_file(self) -> None:
346
+ """Write current client sessions to debug JSON file."""
347
+ try:
348
+ with open(self._debug_file_path, 'w') as f:
349
+ json.dump(list(self._client_sessions.values()), f, indent=2, default=str)
350
+ logger.debug(f"Updated client sessions debug file: {self._debug_file_path}")
351
+ except Exception as e:
352
+ logger.error(f"Failed to write client sessions debug file: {e}")
353
+
34
354
  __all__ = [
35
355
  "TerminalManager",
356
+ "ClientSessionManager",
36
357
  ]
37
358
 
38
359
  class TerminalManager:
@@ -40,9 +361,12 @@ class TerminalManager:
40
361
 
41
362
  CONTROL_CHANNEL_ID = 0 # messages with JSON commands/events
42
363
 
43
- def __init__(self, mux: Multiplexer):
364
+ def __init__(self, mux: Multiplexer, debug: bool = False):
44
365
  self.mux = mux
366
+ self.debug = debug
45
367
  self._session_manager = None # Initialize as None first
368
+ self._client_session_manager = ClientSessionManager() # Initialize client session manager
369
+ self._client_session_manager.set_terminal_manager(self) # Set reference for cleanup
46
370
  self._set_mux(mux, is_initial=True)
47
371
 
48
372
  # ------------------------------------------------------------------
@@ -64,8 +388,8 @@ class TerminalManager:
64
388
  # Start async reattachment and reconciliation
65
389
  asyncio.create_task(self._handle_reconnection())
66
390
  else:
67
- # No existing sessions, just send empty terminal list
68
- asyncio.create_task(self._send_terminal_list())
391
+ # No existing sessions, send empty terminal list and request client sessions
392
+ asyncio.create_task(self._initial_connection_setup())
69
393
 
70
394
  def _set_mux(self, mux: Multiplexer, is_initial: bool = False) -> None:
71
395
  self.mux = mux
@@ -73,17 +397,21 @@ class TerminalManager:
73
397
 
74
398
  # Only create new session manager on initial setup, preserve existing one on reconnection
75
399
  if is_initial or self._session_manager is None:
76
- self._session_manager = SessionManager(mux)
400
+ self._session_manager = SessionManager(mux, terminal_manager=self)
77
401
  logger.info("Created new SessionManager")
78
402
  else:
79
- # Update existing session manager's mux reference
403
+ # Update existing session manager's mux and terminal_manager references
80
404
  self._session_manager.mux = mux
405
+ self._session_manager.terminal_manager = self
81
406
  logger.info("Preserved existing SessionManager with %d sessions", len(self._session_manager._sessions))
82
407
 
83
408
  # Create context for handlers
84
409
  self._context = {
85
410
  "session_manager": self._session_manager,
411
+ "client_session_manager": self._client_session_manager,
86
412
  "mux": mux,
413
+ "use_content_caching": True, # Enable content caching optimization
414
+ "debug": self.debug,
87
415
  }
88
416
 
89
417
  # Initialize command registry
@@ -99,6 +427,18 @@ class TerminalManager:
99
427
  except Exception:
100
428
  pass
101
429
  self._ctl_task = asyncio.create_task(self._control_loop())
430
+
431
+ # Start periodic system info sender
432
+ if getattr(self, "_system_info_task", None):
433
+ try:
434
+ self._system_info_task.cancel()
435
+ except Exception:
436
+ pass
437
+ self._system_info_task = asyncio.create_task(self._periodic_system_info())
438
+
439
+ # For initial connections, request client sessions after control loop starts
440
+ if is_initial:
441
+ asyncio.create_task(self._initial_connection_setup())
102
442
 
103
443
  def _register_default_handlers(self) -> None:
104
444
  """Register the default command handlers."""
@@ -107,37 +447,289 @@ class TerminalManager:
107
447
  self._command_registry.register(TerminalStopHandler)
108
448
  self._command_registry.register(TerminalListHandler)
109
449
  self._command_registry.register(SystemInfoHandler)
450
+ # File operation handlers
451
+ self._command_registry.register(FileReadHandler)
452
+ self._command_registry.register(ProjectAwareFileWriteHandler) # Use project-aware version
453
+ self._command_registry.register(DirectoryListHandler)
454
+ self._command_registry.register(FileInfoHandler)
455
+ self._command_registry.register(FileDeleteHandler)
456
+ self._command_registry.register(ProjectAwareFileCreateHandler) # Use project-aware version
457
+ self._command_registry.register(ProjectAwareFolderCreateHandler) # Use project-aware version
458
+ self._command_registry.register(FileRenameHandler)
459
+ self._command_registry.register(FileSearchHandler)
460
+ self._command_registry.register(ContentRequestHandler)
461
+ self._command_registry.register(FileApplyDiffHandler)
462
+ self._command_registry.register(FilePreviewDiffHandler)
463
+ # Project state handlers
464
+ self._command_registry.register(ProjectStateFolderExpandHandler)
465
+ self._command_registry.register(ProjectStateFolderCollapseHandler)
466
+ self._command_registry.register(ProjectStateFileOpenHandler)
467
+ self._command_registry.register(ProjectStateTabCloseHandler)
468
+ self._command_registry.register(ProjectStateSetActiveTabHandler)
469
+ self._command_registry.register(ProjectStateDiffOpenHandler)
470
+ self._command_registry.register(ProjectStateDiffContentHandler)
471
+ self._command_registry.register(ProjectStateGitStageHandler)
472
+ self._command_registry.register(ProjectStateGitUnstageHandler)
473
+ self._command_registry.register(ProjectStateGitRevertHandler)
474
+ self._command_registry.register(ProjectStateGitCommitHandler)
475
+ # System management handlers
476
+ self._command_registry.register(ConfigureProxmoxInfraHandler)
477
+ self._command_registry.register(UpdatePortacodeHandler)
110
478
 
111
479
  # ---------------------------------------------------------------------
112
480
  # Control loop – receives commands from gateway
113
481
  # ---------------------------------------------------------------------
114
482
 
115
483
  async def _control_loop(self) -> None:
484
+ logger.info("terminal_manager: Starting control loop")
116
485
  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)
486
+ try:
487
+ message = await self._control_channel.recv()
488
+ logger.debug("terminal_manager: Received message: %s", message)
489
+
490
+ # Older parts of the system may send *raw* str. Ensure dict.
491
+ if isinstance(message, str):
492
+ try:
493
+ message = json.loads(message)
494
+ logger.debug("terminal_manager: Parsed string message to dict")
495
+ except Exception:
496
+ logger.warning("terminal_manager: Discarding non-JSON control frame: %s", message)
497
+ continue
498
+ if not isinstance(message, dict):
499
+ logger.warning("terminal_manager: Invalid control frame type: %r", type(message))
124
500
  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"):
501
+ cmd = message.get("cmd")
502
+ if not cmd:
503
+ # Ignore frames that are *events* coming from the remote side
504
+ if message.get("event"):
505
+ logger.debug("terminal_manager: Ignoring event message: %s", message.get("event"))
506
+ continue
507
+ logger.warning("terminal_manager: Missing 'cmd' in control frame: %s", message)
132
508
  continue
133
- logger.warning("Missing 'cmd' in control frame: %s", message)
509
+ reply_chan = message.get("reply_channel")
510
+
511
+ logger.info("terminal_manager: Processing command '%s' with reply_channel=%s", cmd, reply_chan)
512
+ logger.debug("terminal_manager: Full message: %s", message)
513
+
514
+ # Handle client sessions update directly (special case)
515
+ if cmd == "client_sessions_update":
516
+ sessions = message.get("sessions", [])
517
+ logger.info("terminal_manager: 🔔 RECEIVED client_sessions_update with %d sessions", len(sessions))
518
+ logger.debug("terminal_manager: Session details: %s", sessions)
519
+ newly_added_sessions = self._client_session_manager.update_sessions(sessions)
520
+ logger.info("terminal_manager: ✅ Updated client sessions (%d sessions)", len(sessions))
521
+
522
+ # Auto-send initial data only to newly added clients
523
+ # Create a background task so it doesn't block the control loop
524
+ if newly_added_sessions:
525
+ logger.info("terminal_manager: 🚀 Triggering auto-send of initial data to newly added clients (non-blocking)")
526
+ asyncio.create_task(self._send_initial_data_to_clients(newly_added_sessions))
527
+ else:
528
+ logger.info("terminal_manager: ℹ️ No new sessions to send data to")
529
+ continue
530
+
531
+ # Dispatch command through registry
532
+ handled = await self._command_registry.dispatch(cmd, message, reply_chan)
533
+ if not handled:
534
+ logger.warning("terminal_manager: Command '%s' was not handled by any handler", cmd)
535
+ await self._send_error(f"Unknown cmd: {cmd}", reply_chan)
536
+
537
+ except Exception as exc:
538
+ logger.exception("terminal_manager: Error in control loop: %s", exc)
539
+ # Continue processing other messages
540
+ continue
541
+
542
+ async def _periodic_system_info(self) -> None:
543
+ """Send system_info event every 10 seconds when clients are connected."""
544
+ while True:
545
+ try:
546
+ await asyncio.sleep(10)
547
+ if self._client_session_manager.has_interested_clients():
548
+ from .handlers.system_handlers import SystemInfoHandler
549
+ handler = SystemInfoHandler(self._control_channel, self._context)
550
+ system_info = handler.execute({})
551
+ await self._send_session_aware(system_info)
552
+ except Exception as exc:
553
+ logger.exception("Error in periodic system info: %s", exc)
134
554
  continue
135
- reply_chan = message.get("reply_channel")
555
+
556
+ async def _send_initial_data_to_clients(self, newly_added_sessions: List[str] = None):
557
+ """Send initial system info and terminal list to connected clients.
558
+
559
+ Args:
560
+ newly_added_sessions: If provided, only send data to these specific sessions
561
+ """
562
+ if newly_added_sessions:
563
+ logger.info("terminal_manager: 📤 Starting to send initial data to newly added clients: %s", newly_added_sessions)
564
+ else:
565
+ logger.info("terminal_manager: 📤 Starting to send initial data to all connected clients")
566
+
567
+ try:
568
+ # Send system_info (always broadcasts to all clients for now)
569
+ logger.info("terminal_manager: 📊 Dispatching system_info command")
570
+ await self._command_registry.dispatch("system_info", {}, None)
571
+ logger.info("terminal_manager: ✅ system_info dispatch completed")
572
+
573
+ # Send terminal_list only to newly added clients or to all if not specified
574
+ logger.info("terminal_manager: 📋 Preparing to send terminal_list to clients")
575
+
576
+ if newly_added_sessions:
577
+ # Get unique project IDs from the newly added sessions
578
+ project_ids = set()
579
+ all_sessions = self._client_session_manager.get_sessions()
580
+
581
+ for session_name in newly_added_sessions:
582
+ session = all_sessions.get(session_name)
583
+ if session:
584
+ project_id = session.get("project_id")
585
+ connection_type = session.get("connection_type", "unknown")
586
+ logger.debug(f"terminal_manager: New session {session_name}: project_id={project_id}, type={connection_type}")
587
+ if project_id:
588
+ project_ids.add(project_id)
589
+
590
+ logger.info(f"terminal_manager: Found {len(project_ids)} unique project IDs from new sessions: {list(project_ids)}")
591
+
592
+ # Initialize project states for sessions with project_folder_path
593
+ await self._initialize_project_states_for_new_sessions(newly_added_sessions, all_sessions)
594
+
595
+ # Send terminal_list for each project to interested new sessions
596
+ for project_id in project_ids:
597
+ target_sessions = self._client_session_manager.get_target_sessions_for_new_clients(newly_added_sessions, project_id)
598
+ if target_sessions:
599
+ logger.info(f"terminal_manager: 📋 Sending terminal_list for project {project_id} to sessions: {target_sessions}")
600
+ await self._send_targeted_terminal_list({"project_id": project_id}, target_sessions)
601
+ logger.info(f"terminal_manager: ✅ Project {project_id} terminal_list sent to new sessions")
602
+
603
+ # Also send general terminal_list for dashboard connections (project_id=None)
604
+ dashboard_targets = self._client_session_manager.get_target_sessions_for_new_clients(newly_added_sessions, None)
605
+ if dashboard_targets:
606
+ logger.info("terminal_manager: 📋 Sending general terminal_list to new dashboard sessions: %s", dashboard_targets)
607
+ await self._send_targeted_terminal_list({}, dashboard_targets)
608
+ logger.info("terminal_manager: ✅ General terminal_list sent to new dashboard sessions")
609
+ else:
610
+ # Original behavior for all clients
611
+ # Get unique project IDs from connected clients
612
+ project_ids = set()
613
+ all_sessions = self._client_session_manager.get_sessions()
614
+ logger.info(f"terminal_manager: Analyzing {len(all_sessions)} client sessions for project IDs")
615
+
616
+ for session in all_sessions.values():
617
+ project_id = session.get("project_id")
618
+ connection_type = session.get("connection_type", "unknown")
619
+ logger.debug(f"terminal_manager: Session {session.get('channel_name')}: project_id={project_id}, type={connection_type}")
620
+ if project_id:
621
+ project_ids.add(project_id)
622
+
623
+ logger.info(f"terminal_manager: Found {len(project_ids)} unique project IDs: {list(project_ids)}")
624
+
625
+ # Send terminal_list for each project, plus one without project_id for general sessions
626
+ if not project_ids:
627
+ # No specific projects, send general terminal_list
628
+ logger.info("terminal_manager: 📋 Dispatching general terminal_list (no specific projects)")
629
+ await self._command_registry.dispatch("terminal_list", {}, None)
630
+ logger.info("terminal_manager: ✅ General terminal_list dispatch completed")
631
+ else:
632
+ # Send terminal_list for each project
633
+ for project_id in project_ids:
634
+ logger.info(f"terminal_manager: 📋 Dispatching terminal_list for project {project_id}")
635
+ await self._command_registry.dispatch("terminal_list", {"project_id": project_id}, None)
636
+ logger.info(f"terminal_manager: ✅ Project {project_id} terminal_list dispatch completed")
637
+
638
+ # Also send general terminal_list for dashboard connections
639
+ logger.info("terminal_manager: 📋 Dispatching general terminal_list for dashboard connections")
640
+ await self._command_registry.dispatch("terminal_list", {}, None)
641
+ logger.info("terminal_manager: ✅ General terminal_list for dashboard dispatch completed")
642
+
643
+ logger.info("terminal_manager: 🎉 All initial data sent successfully")
644
+
645
+ except Exception as exc:
646
+ logger.exception("terminal_manager: ❌ Error sending initial data to clients: %s", exc)
647
+
648
+ async def _initialize_project_states_for_new_sessions(self, newly_added_sessions: List[str], all_sessions: Dict[str, Dict]):
649
+ """Initialize project states for new sessions that have project_folder_path."""
650
+ logger.info("terminal_manager: 🌳 Initializing project states for new sessions")
651
+
652
+ try:
653
+ # Import here to avoid circular imports
654
+ from .handlers.project_state_handlers import _get_or_create_project_state_manager
655
+
656
+ # Get or create the project state manager
657
+ manager = _get_or_create_project_state_manager(self._context, self._control_channel)
658
+
659
+ for session_name in newly_added_sessions:
660
+ session = all_sessions.get(session_name)
661
+ if not session:
662
+ continue
663
+
664
+ project_id = session.get("project_id")
665
+ project_folder_path = session.get("project_folder_path")
666
+ if project_id is None or not project_folder_path:
667
+ logger.debug(f"terminal_manager: 🌳 Session {session_name} has no project_id or project_folder_path, skipping")
668
+ continue
669
+
670
+ logger.info(f"terminal_manager: 🌳 Initializing project state for session {session_name} with folder: {project_folder_path}")
671
+
672
+ try:
673
+ # Initialize project state
674
+ project_state = await manager.initialize_project_state(session_name, project_folder_path)
675
+
676
+ # Send initial project state to the client
677
+ initial_state_payload = {
678
+ "event": "project_state_initialized",
679
+ "project_id": project_state.client_session_id, # Add missing project_id field
680
+ "project_folder_path": project_state.project_folder_path,
681
+ "is_git_repo": project_state.is_git_repo,
682
+ "git_branch": project_state.git_branch,
683
+ "git_status_summary": project_state.git_status_summary,
684
+ "git_detailed_status": asdict(project_state.git_detailed_status) if project_state.git_detailed_status and hasattr(project_state.git_detailed_status, '__dataclass_fields__') else None, # Add missing git_detailed_status field
685
+ "open_tabs": [manager._serialize_tab_info(tab) for tab in project_state.open_tabs.values()], # Fix to use .values() for dict
686
+ "active_tab": manager._serialize_tab_info(project_state.active_tab) if project_state.active_tab else None,
687
+ "items": [manager._serialize_file_item(item) for item in project_state.items],
688
+ "timestamp": time.time(),
689
+ "client_sessions": [session_name] # Target this specific session
690
+ }
691
+
692
+ await self._control_channel.send(initial_state_payload)
693
+ logger.info(f"terminal_manager: ✅ Project state initialized and sent for session {session_name}")
694
+
695
+ except Exception as exc:
696
+ logger.error(f"terminal_manager: ❌ Failed to initialize project state for session {session_name}: {exc}")
697
+
698
+ except Exception as exc:
699
+ logger.exception("terminal_manager: Error initializing project states for new sessions: %s", exc)
700
+
701
+ async def _send_targeted_terminal_list(self, message: Dict[str, Any], target_sessions: List[str]) -> None:
702
+ """Send terminal_list command to specific client sessions.
703
+
704
+ Args:
705
+ message: The terminal_list command message
706
+ target_sessions: List of client session channel names to target
707
+ """
708
+ try:
709
+ # Get the terminal list from session manager
710
+ session_manager = self._session_manager
711
+ if not session_manager:
712
+ logger.error("terminal_manager: Session manager not available for targeted terminal_list")
713
+ return
714
+
715
+ requested_project_id = message.get("project_id")
716
+ if requested_project_id == "all":
717
+ sessions = session_manager.list_sessions(project_id="all")
718
+ else:
719
+ sessions = session_manager.list_sessions(project_id=requested_project_id)
720
+
721
+ # Build the response payload
722
+ response = {
723
+ "event": "terminal_list",
724
+ "sessions": sessions,
725
+ "project_id": requested_project_id,
726
+ "client_sessions": target_sessions
727
+ }
136
728
 
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)
729
+ logger.debug("terminal_manager: Sending targeted terminal_list: %s", response)
730
+ await self._control_channel.send(response)
731
+ except Exception as exc:
732
+ logger.exception("terminal_manager: Error sending targeted terminal_list: %s", exc)
141
733
 
142
734
  # ------------------------------------------------------------------
143
735
  # Extension API
@@ -175,7 +767,52 @@ class TerminalManager:
175
767
  payload = {"event": "error", "message": message}
176
768
  if reply_channel:
177
769
  payload["reply_channel"] = reply_channel
178
- await self._control_channel.send(payload)
770
+ await self._send_session_aware(payload)
771
+
772
+ async def _send_session_aware(self, payload: dict, project_id: str = None) -> None:
773
+ """Send a message with client session awareness.
774
+
775
+ Args:
776
+ payload: The message payload to send
777
+ project_id: Optional project filter for targeting specific sessions
778
+ """
779
+ event_type = payload.get("event", "unknown")
780
+
781
+ # Check if there are any interested clients
782
+ if not self._client_session_manager.has_interested_clients():
783
+ logger.info("terminal_manager: No interested clients for %s event, skipping send", event_type)
784
+ return
785
+
786
+ # Get target sessions
787
+ target_sessions = self._client_session_manager.get_target_sessions(project_id)
788
+ if not target_sessions:
789
+ logger.info("terminal_manager: No target sessions found for %s event (project_id=%s), skipping send", event_type, project_id)
790
+ return
791
+
792
+ # Add session targeting information
793
+ enhanced_payload = dict(payload)
794
+ enhanced_payload["client_sessions"] = target_sessions
795
+
796
+ # Add backward compatibility reply_channel (first session)
797
+ reply_channel = self._client_session_manager.get_reply_channel_for_compatibility()
798
+ if reply_channel and "reply_channel" not in enhanced_payload:
799
+ enhanced_payload["reply_channel"] = reply_channel
800
+
801
+ # Log all event dispatches at INFO level, with data size for terminal_data
802
+ if event_type == "terminal_data":
803
+ data_size = len(payload.get("data", ""))
804
+ terminal_id = payload.get("channel", "unknown")
805
+ logger.info("terminal_manager: Dispatching %s event (terminal_id=%s, data_size=%d bytes) to %d client sessions",
806
+ event_type, terminal_id, data_size, len(target_sessions))
807
+ # else:
808
+ # logger.info("terminal_manager: Dispatching %s event to %d client sessions",
809
+ # event_type, len(target_sessions))
810
+
811
+ try:
812
+ await self._control_channel.send(enhanced_payload)
813
+ except ConnectionClosedError as exc:
814
+ logger.warning("terminal_manager: Connection closed (%s); skipping %s event", exc, event_type)
815
+ return
179
816
 
180
817
  async def _send_terminal_list(self) -> None:
181
818
  """Send terminal list for reconnection reconciliation."""
@@ -187,9 +824,35 @@ class TerminalManager:
187
824
  "event": "terminal_list",
188
825
  "sessions": sessions,
189
826
  }
190
- await self._control_channel.send(payload)
827
+ await self._send_session_aware(payload)
191
828
  except Exception as exc:
192
829
  logger.warning("Failed to send terminal list: %s", exc)
830
+
831
+ async def _request_client_sessions(self) -> None:
832
+ """Request current client sessions from server."""
833
+ try:
834
+ payload = {
835
+ "event": "request_client_sessions"
836
+ }
837
+ # This is a special case - always send regardless of current client sessions
838
+ # because we're trying to get the client sessions list
839
+ await self._control_channel.send(payload)
840
+ logger.info("Requested client sessions from server")
841
+ except Exception as exc:
842
+ logger.warning("Failed to request client sessions: %s", exc)
843
+
844
+ async def _initial_connection_setup(self) -> None:
845
+ """Handle initial connection setup sequence."""
846
+ try:
847
+ # Send empty terminal list
848
+ await self._send_terminal_list()
849
+ logger.info("Initial terminal list sent to server")
850
+
851
+ # Request current client sessions
852
+ await self._request_client_sessions()
853
+ logger.info("Initial client session request sent")
854
+ except Exception as exc:
855
+ logger.error("Failed to handle initial connection setup: %s", exc)
193
856
 
194
857
  async def _handle_reconnection(self) -> None:
195
858
  """Handle the async reconnection sequence."""
@@ -201,5 +864,9 @@ class TerminalManager:
201
864
  # Then send updated terminal list to server
202
865
  await self._send_terminal_list()
203
866
  logger.info("Terminal list sent to server after reconnection")
867
+
868
+ # Request current client sessions
869
+ await self._request_client_sessions()
870
+ logger.info("Client session request sent after reconnection")
204
871
  except Exception as exc:
205
- logger.error("Failed to handle reconnection: %s", exc)
872
+ logger.error("Failed to handle reconnection: %s", exc)