portacode 0.3.19.dev4__py3-none-any.whl → 1.4.11.dev1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of portacode might be problematic. Click here for more details.
- portacode/_version.py +16 -3
- portacode/cli.py +143 -17
- portacode/connection/client.py +149 -10
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +824 -21
- portacode/connection/handlers/__init__.py +28 -1
- portacode/connection/handlers/base.py +78 -16
- portacode/connection/handlers/chunked_content.py +244 -0
- portacode/connection/handlers/diff_handlers.py +603 -0
- portacode/connection/handlers/file_handlers.py +902 -17
- portacode/connection/handlers/project_aware_file_handlers.py +226 -0
- portacode/connection/handlers/project_state/README.md +312 -0
- portacode/connection/handlers/project_state/__init__.py +92 -0
- portacode/connection/handlers/project_state/file_system_watcher.py +179 -0
- portacode/connection/handlers/project_state/git_manager.py +1502 -0
- portacode/connection/handlers/project_state/handlers.py +875 -0
- portacode/connection/handlers/project_state/manager.py +1331 -0
- portacode/connection/handlers/project_state/models.py +108 -0
- portacode/connection/handlers/project_state/utils.py +50 -0
- portacode/connection/handlers/project_state_handlers.py +45 -2185
- portacode/connection/handlers/proxmox_infra.py +361 -0
- portacode/connection/handlers/registry.py +15 -4
- portacode/connection/handlers/session.py +483 -32
- portacode/connection/handlers/system_handlers.py +147 -8
- portacode/connection/handlers/tab_factory.py +53 -46
- portacode/connection/handlers/terminal_handlers.py +21 -8
- portacode/connection/handlers/update_handler.py +61 -0
- portacode/connection/multiplex.py +60 -2
- portacode/connection/terminal.py +214 -24
- portacode/keypair.py +63 -1
- portacode/link_capture/__init__.py +38 -0
- portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
- portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
- portacode/link_capture/bin/elinks +3 -0
- portacode/link_capture/bin/gio-open +3 -0
- portacode/link_capture/bin/gnome-open +3 -0
- portacode/link_capture/bin/gvfs-open +3 -0
- portacode/link_capture/bin/kde-open +3 -0
- portacode/link_capture/bin/kfmclient +3 -0
- portacode/link_capture/bin/link_capture_exec.sh +11 -0
- portacode/link_capture/bin/link_capture_wrapper.py +75 -0
- portacode/link_capture/bin/links +3 -0
- portacode/link_capture/bin/links2 +3 -0
- portacode/link_capture/bin/lynx +3 -0
- portacode/link_capture/bin/mate-open +3 -0
- portacode/link_capture/bin/netsurf +3 -0
- portacode/link_capture/bin/sensible-browser +3 -0
- portacode/link_capture/bin/w3m +3 -0
- portacode/link_capture/bin/x-www-browser +3 -0
- portacode/link_capture/bin/xdg-open +3 -0
- portacode/logging_categories.py +140 -0
- portacode/pairing.py +103 -0
- portacode/static/js/test-ntp-clock.html +63 -0
- portacode/static/js/utils/ntp-clock.js +232 -0
- portacode/utils/NTP_ARCHITECTURE.md +136 -0
- portacode/utils/__init__.py +1 -0
- portacode/utils/diff_apply.py +456 -0
- portacode/utils/diff_renderer.py +371 -0
- portacode/utils/ntp_clock.py +65 -0
- portacode-1.4.11.dev1.dist-info/METADATA +298 -0
- portacode-1.4.11.dev1.dist-info/RECORD +97 -0
- {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info}/WHEEL +1 -1
- portacode-1.4.11.dev1.dist-info/top_level.txt +3 -0
- test_modules/README.md +296 -0
- test_modules/__init__.py +1 -0
- test_modules/test_device_online.py +44 -0
- test_modules/test_file_operations.py +743 -0
- test_modules/test_git_status_ui.py +370 -0
- test_modules/test_login_flow.py +50 -0
- test_modules/test_navigate_testing_folder.py +361 -0
- test_modules/test_play_store_screenshots.py +294 -0
- test_modules/test_terminal_buffer_performance.py +261 -0
- test_modules/test_terminal_interaction.py +80 -0
- test_modules/test_terminal_loading_race_condition.py +95 -0
- test_modules/test_terminal_start.py +56 -0
- testing_framework/.env.example +21 -0
- testing_framework/README.md +334 -0
- testing_framework/__init__.py +17 -0
- testing_framework/cli.py +326 -0
- testing_framework/core/__init__.py +1 -0
- testing_framework/core/base_test.py +336 -0
- testing_framework/core/cli_manager.py +177 -0
- testing_framework/core/hierarchical_runner.py +577 -0
- testing_framework/core/playwright_manager.py +520 -0
- testing_framework/core/runner.py +447 -0
- testing_framework/core/shared_cli_manager.py +234 -0
- testing_framework/core/test_discovery.py +112 -0
- testing_framework/requirements.txt +12 -0
- portacode-0.3.19.dev4.dist-info/METADATA +0 -241
- portacode-0.3.19.dev4.dist-info/RECORD +0 -30
- portacode-0.3.19.dev4.dist-info/top_level.txt +0 -1
- {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info}/entry_points.txt +0 -0
- {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info/licenses}/LICENSE +0 -0
portacode/connection/terminal.py
CHANGED
|
@@ -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,
|
|
@@ -29,16 +32,33 @@ from .handlers import (
|
|
|
29
32
|
TerminalListHandler,
|
|
30
33
|
SystemInfoHandler,
|
|
31
34
|
FileReadHandler,
|
|
32
|
-
FileWriteHandler,
|
|
33
35
|
DirectoryListHandler,
|
|
34
36
|
FileInfoHandler,
|
|
35
37
|
FileDeleteHandler,
|
|
38
|
+
FileSearchHandler,
|
|
39
|
+
FileRenameHandler,
|
|
40
|
+
ContentRequestHandler,
|
|
41
|
+
FileApplyDiffHandler,
|
|
42
|
+
FilePreviewDiffHandler,
|
|
36
43
|
ProjectStateFolderExpandHandler,
|
|
37
44
|
ProjectStateFolderCollapseHandler,
|
|
38
45
|
ProjectStateFileOpenHandler,
|
|
39
46
|
ProjectStateTabCloseHandler,
|
|
40
47
|
ProjectStateSetActiveTabHandler,
|
|
41
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,
|
|
42
62
|
)
|
|
43
63
|
from .handlers.session import SessionManager
|
|
44
64
|
|
|
@@ -74,21 +94,36 @@ class ClientSessionManager:
|
|
|
74
94
|
logger.info(f"Newly added sessions: {newly_added_sessions}")
|
|
75
95
|
if disconnected_sessions:
|
|
76
96
|
logger.info(f"Disconnected sessions: {disconnected_sessions}")
|
|
77
|
-
#
|
|
78
|
-
|
|
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)
|
|
79
115
|
|
|
80
116
|
self._write_debug_file()
|
|
81
117
|
return newly_added_sessions
|
|
82
118
|
|
|
83
|
-
def
|
|
84
|
-
"""
|
|
85
|
-
logger.info("
|
|
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)
|
|
86
122
|
|
|
87
123
|
# Import here to avoid circular imports
|
|
88
124
|
from .handlers.project_state_handlers import _get_or_create_project_state_manager
|
|
89
125
|
|
|
90
126
|
# Get the project state manager from context if it exists
|
|
91
|
-
# We need to get the context from the terminal manager
|
|
92
127
|
if hasattr(self, '_terminal_manager') and self._terminal_manager:
|
|
93
128
|
context = getattr(self._terminal_manager, '_context', {})
|
|
94
129
|
if context:
|
|
@@ -96,17 +131,116 @@ class ClientSessionManager:
|
|
|
96
131
|
control_channel = getattr(self._terminal_manager, '_control_channel', None)
|
|
97
132
|
if control_channel:
|
|
98
133
|
project_manager = _get_or_create_project_state_manager(context, control_channel)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
for client_session_id in disconnected_sessions:
|
|
102
|
-
logger.info("Cleaning up project states for disconnected session: %s", client_session_id)
|
|
103
|
-
project_manager.cleanup_projects_by_client_session(client_session_id)
|
|
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)
|
|
104
136
|
else:
|
|
105
137
|
logger.warning("No control channel available for project state cleanup")
|
|
106
138
|
else:
|
|
107
139
|
logger.warning("No context available for project state cleanup")
|
|
108
140
|
else:
|
|
109
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
|
|
110
244
|
|
|
111
245
|
def set_terminal_manager(self, terminal_manager):
|
|
112
246
|
"""Set reference to terminal manager for cleanup purposes."""
|
|
@@ -277,6 +411,7 @@ class TerminalManager:
|
|
|
277
411
|
"session_manager": self._session_manager,
|
|
278
412
|
"client_session_manager": self._client_session_manager,
|
|
279
413
|
"mux": mux,
|
|
414
|
+
"use_content_caching": True, # Enable content caching optimization
|
|
280
415
|
"debug": self.debug,
|
|
281
416
|
}
|
|
282
417
|
|
|
@@ -294,6 +429,14 @@ class TerminalManager:
|
|
|
294
429
|
pass
|
|
295
430
|
self._ctl_task = asyncio.create_task(self._control_loop())
|
|
296
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
|
+
|
|
297
440
|
# For initial connections, request client sessions after control loop starts
|
|
298
441
|
if is_initial:
|
|
299
442
|
asyncio.create_task(self._initial_connection_setup())
|
|
@@ -307,10 +450,17 @@ class TerminalManager:
|
|
|
307
450
|
self._command_registry.register(SystemInfoHandler)
|
|
308
451
|
# File operation handlers
|
|
309
452
|
self._command_registry.register(FileReadHandler)
|
|
310
|
-
self._command_registry.register(
|
|
453
|
+
self._command_registry.register(ProjectAwareFileWriteHandler) # Use project-aware version
|
|
311
454
|
self._command_registry.register(DirectoryListHandler)
|
|
312
455
|
self._command_registry.register(FileInfoHandler)
|
|
313
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)
|
|
314
464
|
# Project state handlers
|
|
315
465
|
self._command_registry.register(ProjectStateFolderExpandHandler)
|
|
316
466
|
self._command_registry.register(ProjectStateFolderCollapseHandler)
|
|
@@ -318,6 +468,15 @@ class TerminalManager:
|
|
|
318
468
|
self._command_registry.register(ProjectStateTabCloseHandler)
|
|
319
469
|
self._command_registry.register(ProjectStateSetActiveTabHandler)
|
|
320
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)
|
|
321
480
|
|
|
322
481
|
# ---------------------------------------------------------------------
|
|
323
482
|
# Control loop – receives commands from gateway
|
|
@@ -363,9 +522,10 @@ class TerminalManager:
|
|
|
363
522
|
logger.info("terminal_manager: ✅ Updated client sessions (%d sessions)", len(sessions))
|
|
364
523
|
|
|
365
524
|
# Auto-send initial data only to newly added clients
|
|
525
|
+
# Create a background task so it doesn't block the control loop
|
|
366
526
|
if newly_added_sessions:
|
|
367
|
-
logger.info("terminal_manager: 🚀 Triggering auto-send of initial data to newly added clients")
|
|
368
|
-
|
|
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))
|
|
369
529
|
else:
|
|
370
530
|
logger.info("terminal_manager: ℹ️ No new sessions to send data to")
|
|
371
531
|
continue
|
|
@@ -381,6 +541,20 @@ class TerminalManager:
|
|
|
381
541
|
# Continue processing other messages
|
|
382
542
|
continue
|
|
383
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
|
+
|
|
384
558
|
async def _send_initial_data_to_clients(self, newly_added_sessions: List[str] = None):
|
|
385
559
|
"""Send initial system info and terminal list to connected clients.
|
|
386
560
|
|
|
@@ -489,9 +663,10 @@ class TerminalManager:
|
|
|
489
663
|
if not session:
|
|
490
664
|
continue
|
|
491
665
|
|
|
666
|
+
project_id = session.get("project_id")
|
|
492
667
|
project_folder_path = session.get("project_folder_path")
|
|
493
|
-
if not project_folder_path:
|
|
494
|
-
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")
|
|
495
670
|
continue
|
|
496
671
|
|
|
497
672
|
logger.info(f"terminal_manager: 🌳 Initializing project state for session {session_name} with folder: {project_folder_path}")
|
|
@@ -503,11 +678,13 @@ class TerminalManager:
|
|
|
503
678
|
# Send initial project state to the client
|
|
504
679
|
initial_state_payload = {
|
|
505
680
|
"event": "project_state_initialized",
|
|
681
|
+
"project_id": project_state.client_session_id, # Add missing project_id field
|
|
506
682
|
"project_folder_path": project_state.project_folder_path,
|
|
507
683
|
"is_git_repo": project_state.is_git_repo,
|
|
508
684
|
"git_branch": project_state.git_branch,
|
|
509
685
|
"git_status_summary": project_state.git_status_summary,
|
|
510
|
-
"
|
|
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
|
|
511
688
|
"active_tab": manager._serialize_tab_info(project_state.active_tab) if project_state.active_tab else None,
|
|
512
689
|
"items": [manager._serialize_file_item(item) for item in project_state.items],
|
|
513
690
|
"timestamp": time.time(),
|
|
@@ -601,15 +778,17 @@ class TerminalManager:
|
|
|
601
778
|
payload: The message payload to send
|
|
602
779
|
project_id: Optional project filter for targeting specific sessions
|
|
603
780
|
"""
|
|
781
|
+
event_type = payload.get("event", "unknown")
|
|
782
|
+
|
|
604
783
|
# Check if there are any interested clients
|
|
605
784
|
if not self._client_session_manager.has_interested_clients():
|
|
606
|
-
logger.
|
|
785
|
+
logger.info("terminal_manager: No interested clients for %s event, skipping send", event_type)
|
|
607
786
|
return
|
|
608
787
|
|
|
609
788
|
# Get target sessions
|
|
610
789
|
target_sessions = self._client_session_manager.get_target_sessions(project_id)
|
|
611
790
|
if not target_sessions:
|
|
612
|
-
logger.
|
|
791
|
+
logger.info("terminal_manager: No target sessions found for %s event (project_id=%s), skipping send", event_type, project_id)
|
|
613
792
|
return
|
|
614
793
|
|
|
615
794
|
# Add session targeting information
|
|
@@ -621,10 +800,21 @@ class TerminalManager:
|
|
|
621
800
|
if reply_channel and "reply_channel" not in enhanced_payload:
|
|
622
801
|
enhanced_payload["reply_channel"] = reply_channel
|
|
623
802
|
|
|
624
|
-
|
|
625
|
-
|
|
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))
|
|
626
812
|
|
|
627
|
-
|
|
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
|
|
628
818
|
|
|
629
819
|
async def _send_terminal_list(self) -> None:
|
|
630
820
|
"""Send terminal list for reconnection reconciliation."""
|
|
@@ -681,4 +871,4 @@ class TerminalManager:
|
|
|
681
871
|
await self._request_client_sessions()
|
|
682
872
|
logger.info("Client session request sent after reconnection")
|
|
683
873
|
except Exception as exc:
|
|
684
|
-
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
|
|
Binary file
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Simple link capture wrapper that never executes a native browser."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
LINK_CHANNEL_ENV = "PORTACODE_LINK_CHANNEL"
|
|
13
|
+
TERMINAL_ID_ENV = "PORTACODE_TERMINAL_ID"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _find_link_argument(args):
|
|
17
|
+
for arg in args:
|
|
18
|
+
if not isinstance(arg, str):
|
|
19
|
+
continue
|
|
20
|
+
parsed = urlparse(arg)
|
|
21
|
+
if parsed.scheme and parsed.netloc:
|
|
22
|
+
return arg
|
|
23
|
+
if arg.startswith("file://"):
|
|
24
|
+
return arg
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _write_capture_event(cmd_name, args, link):
|
|
29
|
+
channel = os.environ.get(LINK_CHANNEL_ENV)
|
|
30
|
+
terminal_id = os.environ.get(TERMINAL_ID_ENV)
|
|
31
|
+
if not channel or not link:
|
|
32
|
+
return
|
|
33
|
+
payload = {
|
|
34
|
+
"terminal_id": terminal_id,
|
|
35
|
+
"command": cmd_name,
|
|
36
|
+
"args": args,
|
|
37
|
+
"url": link,
|
|
38
|
+
"timestamp": time.time(),
|
|
39
|
+
}
|
|
40
|
+
directory = Path(channel)
|
|
41
|
+
try:
|
|
42
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
except Exception:
|
|
44
|
+
return
|
|
45
|
+
temp_file = directory / f".{uuid.uuid4().hex}.tmp"
|
|
46
|
+
term_label = terminal_id or "unknown"
|
|
47
|
+
base_name = f"{int(time.time() * 1000)}-{term_label}"
|
|
48
|
+
final_file = directory / f"{base_name}.json"
|
|
49
|
+
suffix = 0
|
|
50
|
+
while final_file.exists():
|
|
51
|
+
suffix += 1
|
|
52
|
+
final_file = directory / f"{base_name}-{suffix}.json"
|
|
53
|
+
try:
|
|
54
|
+
temp_file.write_text(json.dumps(payload), encoding="utf-8")
|
|
55
|
+
temp_file.replace(final_file)
|
|
56
|
+
except Exception:
|
|
57
|
+
if temp_file.exists():
|
|
58
|
+
temp_file.unlink(missing_ok=True)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main() -> None:
|
|
62
|
+
if len(sys.argv) < 2:
|
|
63
|
+
sys.stderr.write("link_capture: missing target command name\n")
|
|
64
|
+
sys.exit(1)
|
|
65
|
+
cmd_name = sys.argv[1]
|
|
66
|
+
cmd_args = sys.argv[2:]
|
|
67
|
+
link = _find_link_argument(cmd_args)
|
|
68
|
+
if link:
|
|
69
|
+
_write_capture_event(cmd_name, cmd_args, link)
|
|
70
|
+
# Never run a real browser; capture and exit successfully.
|
|
71
|
+
sys.exit(0)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
main()
|