portacode 0.3.16.dev10__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.
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 +928 -42
  5. portacode/connection/handlers/__init__.py +34 -5
  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 -948
  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 +389 -0
  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 +256 -17
  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.16.dev10.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.16.dev10.dist-info/METADATA +0 -238
  89. portacode-0.3.16.dev10.dist-info/RECORD +0 -29
  90. portacode-0.3.16.dev10.dist-info/top_level.txt +0 -1
  91. {portacode-0.3.16.dev10.dist-info → portacode-1.4.11.dev1.dist-info}/entry_points.txt +0 -0
  92. {portacode-0.3.16.dev10.dist-info → portacode-1.4.11.dev1.dist-info/licenses}/LICENSE +0 -0
@@ -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:
@@ -18,8 +18,11 @@ import json
18
18
  import logging
19
19
  import os
20
20
  import time
21
+ from dataclasses import asdict
21
22
  from typing import Any, Dict, Optional, List
22
23
 
24
+ from websockets.exceptions import ConnectionClosedError
25
+
23
26
  from .multiplex import Multiplexer, Channel
24
27
  from .handlers import (
25
28
  CommandRegistry,
@@ -28,12 +31,34 @@ from .handlers import (
28
31
  TerminalStopHandler,
29
32
  TerminalListHandler,
30
33
  SystemInfoHandler,
34
+ FileReadHandler,
31
35
  DirectoryListHandler,
36
+ FileInfoHandler,
37
+ FileDeleteHandler,
38
+ FileSearchHandler,
39
+ FileRenameHandler,
40
+ ContentRequestHandler,
41
+ FileApplyDiffHandler,
42
+ FilePreviewDiffHandler,
32
43
  ProjectStateFolderExpandHandler,
33
44
  ProjectStateFolderCollapseHandler,
34
45
  ProjectStateFileOpenHandler,
35
- ProjectStateFileCloseHandler,
36
- ProjectStateSetActiveFileHandler,
46
+ ProjectStateTabCloseHandler,
47
+ ProjectStateSetActiveTabHandler,
48
+ ProjectStateDiffOpenHandler,
49
+ ProjectStateDiffContentHandler,
50
+ ProjectStateGitStageHandler,
51
+ ProjectStateGitUnstageHandler,
52
+ ProjectStateGitRevertHandler,
53
+ ProjectStateGitCommitHandler,
54
+ UpdatePortacodeHandler,
55
+ ConfigureProxmoxInfraHandler,
56
+ RevertProxmoxInfraHandler,
57
+ )
58
+ from .handlers.project_aware_file_handlers import (
59
+ ProjectAwareFileWriteHandler,
60
+ ProjectAwareFileCreateHandler,
61
+ ProjectAwareFolderCreateHandler,
37
62
  )
38
63
  from .handlers.session import SessionManager
39
64
 
@@ -62,14 +87,165 @@ class ClientSessionManager:
62
87
 
63
88
  new_sessions = set(self._client_sessions.keys())
64
89
  newly_added_sessions = list(new_sessions - old_sessions)
90
+ disconnected_sessions = list(old_sessions - new_sessions)
65
91
 
66
- logger.info(f"Updated client sessions: {len(self._client_sessions)} sessions, {len(newly_added_sessions)} newly added")
92
+ logger.info(f"Updated client sessions: {len(self._client_sessions)} sessions, {len(newly_added_sessions)} newly added, {len(disconnected_sessions)} disconnected")
67
93
  if newly_added_sessions:
68
94
  logger.info(f"Newly added sessions: {newly_added_sessions}")
95
+ if disconnected_sessions:
96
+ logger.info(f"Disconnected sessions: {disconnected_sessions}")
97
+ # NOTE: Not automatically cleaning up project states for disconnected sessions
98
+ # to handle temporary disconnections gracefully. Project states will be cleaned
99
+ # up only when explicitly notified by the server of permanent disconnection.
100
+ logger.info("Project states preserved for potential reconnection of these sessions")
101
+
102
+ # Handle project state management based on session changes
103
+ if newly_added_sessions or disconnected_sessions:
104
+ # Schedule project state management to run asynchronously
105
+ import asyncio
106
+ try:
107
+ loop = asyncio.get_event_loop()
108
+ if loop.is_running():
109
+ # Schedule both cleanup and initialization
110
+ loop.create_task(self._manage_project_states_for_session_changes(newly_added_sessions, sessions))
111
+ else:
112
+ logger.debug("No event loop running, skipping project state management")
113
+ except Exception as e:
114
+ logger.debug("Could not schedule project state management: %s", e)
69
115
 
70
116
  self._write_debug_file()
71
117
  return newly_added_sessions
72
118
 
119
+ async def cleanup_client_session_explicitly(self, client_session_id: str):
120
+ """Explicitly clean up resources for a client session when notified by server."""
121
+ logger.info("Explicitly cleaning up resources for client session: %s", client_session_id)
122
+
123
+ # Import here to avoid circular imports
124
+ from .handlers.project_state_handlers import _get_or_create_project_state_manager
125
+
126
+ # Get the project state manager from context if it exists
127
+ if hasattr(self, '_terminal_manager') and self._terminal_manager:
128
+ context = getattr(self._terminal_manager, '_context', {})
129
+ if context:
130
+ # Get or create the project state manager
131
+ control_channel = getattr(self._terminal_manager, '_control_channel', None)
132
+ if control_channel:
133
+ project_manager = _get_or_create_project_state_manager(context, control_channel)
134
+ logger.info("Cleaning up project state for client session: %s", client_session_id)
135
+ await project_manager.cleanup_projects_by_client_session(client_session_id)
136
+ else:
137
+ logger.warning("No control channel available for project state cleanup")
138
+ else:
139
+ logger.warning("No context available for project state cleanup")
140
+ else:
141
+ logger.warning("No terminal manager available for project state cleanup")
142
+
143
+ async def _manage_project_states_for_session_changes(self, newly_added_sessions: List[str], all_sessions: List[Dict]):
144
+ """Comprehensive project state management for session changes."""
145
+ try:
146
+ # Import here to avoid circular imports
147
+ from .handlers.project_state_handlers import _get_or_create_project_state_manager
148
+
149
+ if not hasattr(self, '_terminal_manager') or not self._terminal_manager:
150
+ logger.warning("No terminal manager available for project state management")
151
+ return
152
+
153
+ context = getattr(self._terminal_manager, '_context', {})
154
+ if not context:
155
+ logger.warning("No context available for project state management")
156
+ return
157
+
158
+ control_channel = getattr(self._terminal_manager, '_control_channel', None)
159
+ if not control_channel:
160
+ logger.warning("No control channel available for project state management")
161
+ return
162
+
163
+ project_manager = _get_or_create_project_state_manager(context, control_channel)
164
+
165
+ # Convert sessions list to dict for easier lookup
166
+ sessions_dict = {session.get('channel_name'): session for session in all_sessions if session.get('channel_name')}
167
+
168
+ # First, clean up project states for sessions that are no longer project sessions or don't exist
169
+ current_project_sessions = set()
170
+ for session in all_sessions:
171
+ channel_name = session.get('channel_name')
172
+ project_id = session.get('project_id')
173
+ project_folder_path = session.get('project_folder_path')
174
+
175
+ if channel_name and project_id is not None and project_folder_path:
176
+ current_project_sessions.add(channel_name)
177
+ logger.debug(f"Active project session: {channel_name} -> {project_folder_path} (project_id: {project_id})")
178
+
179
+ # Clean up project states that don't match current project sessions
180
+ existing_project_states = list(project_manager.projects.keys())
181
+ for session_id in existing_project_states:
182
+ if session_id not in current_project_sessions:
183
+ logger.info(f"Cleaning up project state for session {session_id} (no longer a project session)")
184
+ await project_manager.cleanup_project(session_id)
185
+
186
+ # Initialize project states for new project sessions
187
+ for session_name in newly_added_sessions:
188
+ session = sessions_dict.get(session_name)
189
+ if not session:
190
+ continue
191
+
192
+ project_id = session.get('project_id')
193
+ project_folder_path = session.get('project_folder_path')
194
+
195
+ if project_id is not None and project_folder_path:
196
+ if session_name in project_manager.projects:
197
+ logger.info("Project state already exists for session %s, skipping re-init", session_name)
198
+ continue
199
+ logger.info(f"Initializing project state for new project session {session_name}: {project_folder_path}")
200
+
201
+ try:
202
+ # Initialize project state (this includes migration logic)
203
+ project_state = await project_manager.initialize_project_state(session_name, project_folder_path)
204
+ logger.info(f"Successfully initialized project state for {session_name}")
205
+
206
+ # Send initial project state to the client
207
+ # (implementation can be added here if needed)
208
+
209
+ except Exception as e:
210
+ logger.error(f"Failed to initialize project state for {session_name}: {e}")
211
+ except Exception as e:
212
+ logger.error("Error managing project states for session changes: %s", e)
213
+
214
+ async def _cleanup_orphaned_project_states(self):
215
+ """Clean up project states that don't match any current client session."""
216
+ try:
217
+ # Import here to avoid circular imports
218
+ from .handlers.project_state_handlers import _get_or_create_project_state_manager
219
+
220
+ # Get current client session IDs
221
+ current_sessions = list(self._client_sessions.keys())
222
+
223
+ if hasattr(self, '_terminal_manager') and self._terminal_manager:
224
+ context = getattr(self._terminal_manager, '_context', {})
225
+ if context:
226
+ control_channel = getattr(self._terminal_manager, '_control_channel', None)
227
+ if control_channel:
228
+ project_manager = _get_or_create_project_state_manager(context, control_channel)
229
+ await project_manager.cleanup_orphaned_project_states(current_sessions)
230
+ else:
231
+ logger.warning("No control channel available for orphaned project state cleanup")
232
+ else:
233
+ logger.warning("No context available for orphaned project state cleanup")
234
+ else:
235
+ logger.warning("No terminal manager available for orphaned project state cleanup")
236
+
237
+ except Exception as e:
238
+ logger.error("Error cleaning up orphaned project states: %s", e)
239
+
240
+ def _cleanup_disconnected_sessions(self, disconnected_sessions: List[str]):
241
+ """Legacy method - now just logs disconnections without cleanup."""
242
+ logger.info("Sessions disconnected (but preserving project states): %s", disconnected_sessions)
243
+ # Project states are preserved to handle reconnections gracefully
244
+
245
+ def set_terminal_manager(self, terminal_manager):
246
+ """Set reference to terminal manager for cleanup purposes."""
247
+ self._terminal_manager = terminal_manager
248
+
73
249
  def get_sessions(self) -> Dict[str, Dict]:
74
250
  """Get all current client sessions."""
75
251
  return self._client_sessions.copy()
@@ -191,6 +367,7 @@ class TerminalManager:
191
367
  self.debug = debug
192
368
  self._session_manager = None # Initialize as None first
193
369
  self._client_session_manager = ClientSessionManager() # Initialize client session manager
370
+ self._client_session_manager.set_terminal_manager(self) # Set reference for cleanup
194
371
  self._set_mux(mux, is_initial=True)
195
372
 
196
373
  # ------------------------------------------------------------------
@@ -234,6 +411,7 @@ class TerminalManager:
234
411
  "session_manager": self._session_manager,
235
412
  "client_session_manager": self._client_session_manager,
236
413
  "mux": mux,
414
+ "use_content_caching": True, # Enable content caching optimization
237
415
  "debug": self.debug,
238
416
  }
239
417
 
@@ -251,6 +429,14 @@ class TerminalManager:
251
429
  pass
252
430
  self._ctl_task = asyncio.create_task(self._control_loop())
253
431
 
432
+ # Start periodic system info sender
433
+ if getattr(self, "_system_info_task", None):
434
+ try:
435
+ self._system_info_task.cancel()
436
+ except Exception:
437
+ pass
438
+ self._system_info_task = asyncio.create_task(self._periodic_system_info())
439
+
254
440
  # For initial connections, request client sessions after control loop starts
255
441
  if is_initial:
256
442
  asyncio.create_task(self._initial_connection_setup())
@@ -262,13 +448,35 @@ class TerminalManager:
262
448
  self._command_registry.register(TerminalStopHandler)
263
449
  self._command_registry.register(TerminalListHandler)
264
450
  self._command_registry.register(SystemInfoHandler)
451
+ # File operation handlers
452
+ self._command_registry.register(FileReadHandler)
453
+ self._command_registry.register(ProjectAwareFileWriteHandler) # Use project-aware version
265
454
  self._command_registry.register(DirectoryListHandler)
455
+ self._command_registry.register(FileInfoHandler)
456
+ self._command_registry.register(FileDeleteHandler)
457
+ self._command_registry.register(ProjectAwareFileCreateHandler) # Use project-aware version
458
+ self._command_registry.register(ProjectAwareFolderCreateHandler) # Use project-aware version
459
+ self._command_registry.register(FileRenameHandler)
460
+ self._command_registry.register(FileSearchHandler)
461
+ self._command_registry.register(ContentRequestHandler)
462
+ self._command_registry.register(FileApplyDiffHandler)
463
+ self._command_registry.register(FilePreviewDiffHandler)
266
464
  # Project state handlers
267
465
  self._command_registry.register(ProjectStateFolderExpandHandler)
268
466
  self._command_registry.register(ProjectStateFolderCollapseHandler)
269
467
  self._command_registry.register(ProjectStateFileOpenHandler)
270
- self._command_registry.register(ProjectStateFileCloseHandler)
271
- self._command_registry.register(ProjectStateSetActiveFileHandler)
468
+ self._command_registry.register(ProjectStateTabCloseHandler)
469
+ self._command_registry.register(ProjectStateSetActiveTabHandler)
470
+ self._command_registry.register(ProjectStateDiffOpenHandler)
471
+ self._command_registry.register(ProjectStateDiffContentHandler)
472
+ self._command_registry.register(ProjectStateGitStageHandler)
473
+ self._command_registry.register(ProjectStateGitUnstageHandler)
474
+ self._command_registry.register(ProjectStateGitRevertHandler)
475
+ self._command_registry.register(ProjectStateGitCommitHandler)
476
+ # System management handlers
477
+ self._command_registry.register(ConfigureProxmoxInfraHandler)
478
+ self._command_registry.register(RevertProxmoxInfraHandler)
479
+ self._command_registry.register(UpdatePortacodeHandler)
272
480
 
273
481
  # ---------------------------------------------------------------------
274
482
  # Control loop – receives commands from gateway
@@ -314,9 +522,10 @@ class TerminalManager:
314
522
  logger.info("terminal_manager: ✅ Updated client sessions (%d sessions)", len(sessions))
315
523
 
316
524
  # Auto-send initial data only to newly added clients
525
+ # Create a background task so it doesn't block the control loop
317
526
  if newly_added_sessions:
318
- logger.info("terminal_manager: 🚀 Triggering auto-send of initial data to newly added clients")
319
- await self._send_initial_data_to_clients(newly_added_sessions)
527
+ logger.info("terminal_manager: 🚀 Triggering auto-send of initial data to newly added clients (non-blocking)")
528
+ asyncio.create_task(self._send_initial_data_to_clients(newly_added_sessions))
320
529
  else:
321
530
  logger.info("terminal_manager: ℹ️ No new sessions to send data to")
322
531
  continue
@@ -332,6 +541,20 @@ class TerminalManager:
332
541
  # Continue processing other messages
333
542
  continue
334
543
 
544
+ async def _periodic_system_info(self) -> None:
545
+ """Send system_info event every 10 seconds when clients are connected."""
546
+ while True:
547
+ try:
548
+ await asyncio.sleep(10)
549
+ if self._client_session_manager.has_interested_clients():
550
+ from .handlers.system_handlers import SystemInfoHandler
551
+ handler = SystemInfoHandler(self._control_channel, self._context)
552
+ system_info = handler.execute({})
553
+ await self._send_session_aware(system_info)
554
+ except Exception as exc:
555
+ logger.exception("Error in periodic system info: %s", exc)
556
+ continue
557
+
335
558
  async def _send_initial_data_to_clients(self, newly_added_sessions: List[str] = None):
336
559
  """Send initial system info and terminal list to connected clients.
337
560
 
@@ -440,9 +663,10 @@ class TerminalManager:
440
663
  if not session:
441
664
  continue
442
665
 
666
+ project_id = session.get("project_id")
443
667
  project_folder_path = session.get("project_folder_path")
444
- if not project_folder_path:
445
- logger.debug(f"terminal_manager: 🌳 Session {session_name} has no project_folder_path, skipping")
668
+ if project_id is None or not project_folder_path:
669
+ logger.debug(f"terminal_manager: 🌳 Session {session_name} has no project_id or project_folder_path, skipping")
446
670
  continue
447
671
 
448
672
  logger.info(f"terminal_manager: 🌳 Initializing project state for session {session_name} with folder: {project_folder_path}")
@@ -454,12 +678,14 @@ class TerminalManager:
454
678
  # Send initial project state to the client
455
679
  initial_state_payload = {
456
680
  "event": "project_state_initialized",
681
+ "project_id": project_state.client_session_id, # Add missing project_id field
457
682
  "project_folder_path": project_state.project_folder_path,
458
683
  "is_git_repo": project_state.is_git_repo,
459
684
  "git_branch": project_state.git_branch,
460
685
  "git_status_summary": project_state.git_status_summary,
461
- "open_files": list(project_state.open_files),
462
- "active_file": project_state.active_file,
686
+ "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
687
+ "open_tabs": [manager._serialize_tab_info(tab) for tab in project_state.open_tabs.values()], # Fix to use .values() for dict
688
+ "active_tab": manager._serialize_tab_info(project_state.active_tab) if project_state.active_tab else None,
463
689
  "items": [manager._serialize_file_item(item) for item in project_state.items],
464
690
  "timestamp": time.time(),
465
691
  "client_sessions": [session_name] # Target this specific session
@@ -552,15 +778,17 @@ class TerminalManager:
552
778
  payload: The message payload to send
553
779
  project_id: Optional project filter for targeting specific sessions
554
780
  """
781
+ event_type = payload.get("event", "unknown")
782
+
555
783
  # Check if there are any interested clients
556
784
  if not self._client_session_manager.has_interested_clients():
557
- logger.debug("terminal_manager: No interested clients, skipping message send")
785
+ logger.info("terminal_manager: No interested clients for %s event, skipping send", event_type)
558
786
  return
559
787
 
560
788
  # Get target sessions
561
789
  target_sessions = self._client_session_manager.get_target_sessions(project_id)
562
790
  if not target_sessions:
563
- logger.debug("terminal_manager: No target sessions found, skipping message send")
791
+ logger.info("terminal_manager: No target sessions found for %s event (project_id=%s), skipping send", event_type, project_id)
564
792
  return
565
793
 
566
794
  # Add session targeting information
@@ -572,10 +800,21 @@ class TerminalManager:
572
800
  if reply_channel and "reply_channel" not in enhanced_payload:
573
801
  enhanced_payload["reply_channel"] = reply_channel
574
802
 
575
- logger.debug("terminal_manager: Sending to %d client sessions: %s",
576
- len(target_sessions), target_sessions)
803
+ # Log all event dispatches at INFO level, with data size for terminal_data
804
+ if event_type == "terminal_data":
805
+ data_size = len(payload.get("data", ""))
806
+ terminal_id = payload.get("channel", "unknown")
807
+ logger.info("terminal_manager: Dispatching %s event (terminal_id=%s, data_size=%d bytes) to %d client sessions",
808
+ event_type, terminal_id, data_size, len(target_sessions))
809
+ # else:
810
+ # logger.info("terminal_manager: Dispatching %s event to %d client sessions",
811
+ # event_type, len(target_sessions))
577
812
 
578
- await self._control_channel.send(enhanced_payload)
813
+ try:
814
+ await self._control_channel.send(enhanced_payload)
815
+ except ConnectionClosedError as exc:
816
+ logger.warning("terminal_manager: Connection closed (%s); skipping %s event", exc, event_type)
817
+ return
579
818
 
580
819
  async def _send_terminal_list(self) -> None:
581
820
  """Send terminal list for reconnection reconciliation."""
@@ -632,4 +871,4 @@ class TerminalManager:
632
871
  await self._request_client_sessions()
633
872
  logger.info("Client session request sent after reconnection")
634
873
  except Exception as exc:
635
- logger.error("Failed to handle reconnection: %s", exc)
874
+ logger.error("Failed to handle reconnection: %s", exc)
portacode/keypair.py CHANGED
@@ -98,4 +98,66 @@ def fingerprint_public_key(pem: bytes) -> str:
98
98
  """Return a short fingerprint for display purposes (SHA-256)."""
99
99
  digest = hashes.Hash(hashes.SHA256())
100
100
  digest.update(pem)
101
- return digest.finalize().hex()[:16]
101
+ return digest.finalize().hex()[:16]
102
+
103
+
104
+ class InMemoryKeyPair:
105
+ """Keypair kept purely in memory until explicitly persisted."""
106
+
107
+ def __init__(self, private_pem: bytes, public_pem: bytes, key_dir: Path):
108
+ self._private_pem = private_pem
109
+ self._public_pem = public_pem
110
+ self._key_dir = key_dir
111
+ self._is_new = True
112
+
113
+ @property
114
+ def private_key_pem(self) -> bytes:
115
+ return self._private_pem
116
+
117
+ @property
118
+ def public_key_pem(self) -> bytes:
119
+ return self._public_pem
120
+
121
+ def sign_challenge(self, challenge: str) -> bytes:
122
+ private_key = serialization.load_pem_private_key(self._private_pem, password=None)
123
+ return private_key.sign(
124
+ challenge.encode(),
125
+ padding.PKCS1v15(),
126
+ hashes.SHA256(),
127
+ )
128
+
129
+ def public_key_der_b64(self) -> str:
130
+ pubkey = serialization.load_pem_public_key(self._public_pem)
131
+ der = pubkey.public_bytes(
132
+ encoding=serialization.Encoding.DER,
133
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
134
+ )
135
+ return base64.b64encode(der).decode()
136
+
137
+ def persist(self) -> KeyPair:
138
+ """Write the keypair to disk and return a regular KeyPair."""
139
+ key_dir = self._key_dir
140
+ key_dir.mkdir(parents=True, exist_ok=True)
141
+ priv_path = key_dir / PRIVATE_KEY_FILE
142
+ pub_path = key_dir / PUBLIC_KEY_FILE
143
+ priv_path.write_bytes(self._private_pem)
144
+ pub_path.write_bytes(self._public_pem)
145
+ keypair = KeyPair(priv_path, pub_path)
146
+ keypair._is_new = True # type: ignore[attr-defined]
147
+ keypair._key_dir = key_dir # type: ignore[attr-defined]
148
+ return keypair
149
+
150
+
151
+ def generate_in_memory_keypair() -> InMemoryKeyPair:
152
+ """Generate a new keypair but keep it in memory until pairing succeeds."""
153
+ private_pem, public_pem = _generate_keypair()
154
+ key_dir = get_key_dir()
155
+ return InMemoryKeyPair(private_pem, public_pem, key_dir)
156
+
157
+
158
+ def keypair_files_exist() -> bool:
159
+ """Return True if the persisted keypair already exists on disk."""
160
+ key_dir = get_key_dir()
161
+ priv_path = key_dir / PRIVATE_KEY_FILE
162
+ pub_path = key_dir / PUBLIC_KEY_FILE
163
+ return priv_path.exists() and pub_path.exists()
@@ -0,0 +1,38 @@
1
+ """Helpers for the link capture wrapper scripts."""
2
+
3
+ import shutil
4
+ import tempfile
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ try:
9
+ # Use the stdlib helpers when they are available (Python ≥3.9).
10
+ from importlib.resources import as_file, files
11
+ except ImportError: # pragma: no cover
12
+ # Fall back to the backport for older Python 3.x runtimes (>=3.6).
13
+ from importlib_resources import as_file, files
14
+
15
+ _LINK_CAPTURE_TEMP_DIR: Optional[Path] = None
16
+
17
+
18
+ def prepare_link_capture_bin() -> Optional[Path]:
19
+ """Extract the packaged link capture wrappers into a temporary dir and return it."""
20
+ global _LINK_CAPTURE_TEMP_DIR
21
+ if _LINK_CAPTURE_TEMP_DIR:
22
+ return _LINK_CAPTURE_TEMP_DIR
23
+
24
+ bin_source = files(__package__) / "bin"
25
+ if not bin_source.is_dir():
26
+ return None
27
+
28
+ temp_dir = Path(tempfile.mkdtemp(prefix="portacode-link-capture-"))
29
+ for entry in bin_source.iterdir():
30
+ if not entry.is_file():
31
+ continue
32
+ with as_file(entry) as file_path:
33
+ dest = temp_dir / entry.name
34
+ shutil.copyfile(file_path, dest)
35
+ dest.chmod(dest.stat().st_mode | 0o111)
36
+
37
+ _LINK_CAPTURE_TEMP_DIR = temp_dir
38
+ return _LINK_CAPTURE_TEMP_DIR
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" elinks "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" gio-open "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" gnome-open "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" gvfs-open "$@"