gemcode 0.4.26__tar.gz → 0.4.28__tar.gz
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.
- {gemcode-0.4.26/src/gemcode.egg-info → gemcode-0.4.28}/PKG-INFO +1 -1
- {gemcode-0.4.26 → gemcode-0.4.28}/pyproject.toml +1 -1
- gemcode-0.4.28/src/gemcode/web/preview_api.py +363 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/web/server.py +12 -2
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/web/sse_adapter.py +6 -1
- {gemcode-0.4.26 → gemcode-0.4.28/src/gemcode.egg-info}/PKG-INFO +1 -1
- gemcode-0.4.28/tests/test_preview_proxy.py +65 -0
- gemcode-0.4.26/src/gemcode/web/preview_api.py +0 -213
- gemcode-0.4.26/tests/test_preview_proxy.py +0 -21
- {gemcode-0.4.26 → gemcode-0.4.28}/LICENSE +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/MANIFEST.in +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/README.md +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/setup.cfg +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/__init__.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/__main__.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/a2a_bridge.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/agent.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/agent_habits.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/agent_intelligence.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/agent_mesh.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/agent_triggers.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/audit.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/autocompact.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/automations.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/autotune.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/callbacks.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/capability_routing.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/checkpoints.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/cli.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/codebase_awareness.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/compaction.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/computer_use/__init__.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/computer_use/browser_computer.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/config.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/context_budget.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/context_warning.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/credentials.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/curated_memory.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/delegation_learning.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/dynamic_policy.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/evals/harness.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/event_bus.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/fleet_reports.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/habit_chains.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/habit_runs.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/hitl_session.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/hooks.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/ide_protocol.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/ide_stdio.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/intent_classifier.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/interactions.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/invoke.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/kaira_client.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/kaira_daemon.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/kaira_ipc.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/kaira_job_store.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/learning.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/limits.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/live_audio_engine.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/logging_config.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/mcp_loader.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/memory/__init__.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/memory/embedding_memory_service.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/memory/file_memory_service.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/modality_tools.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/model_errors.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/model_routing.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/multimodal_input.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/openapi_loader.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/org.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/output_styles.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/paths.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/permissions.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/plugins/__init__.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/plugins/terminal_hooks_plugin.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/plugins/tool_recovery_plugin.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/policy_profile.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/pricing.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/prompt_suggestions.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/query/__init__.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/query/config.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/query/deps.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/query/engine.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/query/stop_hooks.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/query/token_budget.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/query/transitions.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/query_sanitizer.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/refine.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/repl_commands.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/repl_slash.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/review_agent.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/rules.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/self_healing.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/session_runtime.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/session_store.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/session_summariser.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/skills.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/slash_commands.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/thinking.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/tool_prompt_manifest.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/tool_registry.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/tool_result_store.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/tool_synthesis.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/tools/__init__.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/tools/automations_tools.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/tools/bash.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/tools/browser.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/tools/compress_memory.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/tools/curated_memory.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/tools/edit.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/tools/filesystem.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/tools/notebook.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/tools/notes.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/tools/org_tools.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/tools/repo_map.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/tools/search.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/tools/shell.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/tools/shell_gate.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/tools/skills.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/tools/subtask.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/tools/tasks.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/tools/think.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/tools/todo.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/tools/user_choice.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/tools/veomem_tools.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/tools/web.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/tools/web_search.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/tools_inspector.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/trust.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/tui/input_handler.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/tui/scrollback.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/tui/spinner.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/tui/welcome_banner.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/tui/welcome_rich.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/veomem_bridge.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/version.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/vertex.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/wal.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/web/__init__.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/web/chat_skills.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/web/customize_api.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/web/files_api.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/web/fleet_api.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/web/hitl_bridge.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/web/project_root.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/web/runtime_api.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/web/serve_bind.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/web/serve_state.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/web/sessions_api.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/web/terminal_api.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/web/terminal_repl.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/web/ui_chat_store.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/web/web_config_api.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/web/web_sse_compat.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/web/workspace_panel_api.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode/workspace_hints.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode.egg-info/SOURCES.txt +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode.egg-info/dependency_links.txt +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode.egg-info/entry_points.txt +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode.egg-info/requires.txt +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/src/gemcode.egg-info/top_level.txt +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_add_dir.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_agent_habits.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_agent_instruction.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_agent_mesh.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_autocompact.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_automations.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_capability_routing.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_checkpoint_diff_command.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_cli_init.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_compress_memory_tool.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_computer_use_permissions.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_context_budget.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_context_warning.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_credentials.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_eval_harness_layout.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_event_bus.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_files_api.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_fleet_reports.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_habit_chains.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_habit_runs.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_hitl_bridge_cross_process.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_hosted_tenant.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_ide_stdio_attachments.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_interactive_permission_ask.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_kaira_ipc_paths.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_kaira_scheduler.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_modality_tools.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_model_error_retry.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_model_errors.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_model_routing.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_multimodal_input.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_output_styles_and_rules.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_paths.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_permissions.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_prompt_suggestions.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_repl_commands.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_repl_slash.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_session_runtime_cache.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_skills.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_slash_commands.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_slash_completion_registry.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_sse_confirmation_batch.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_thinking_config.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_token_budget.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_tool_context_circulation.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_tools.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_tools_inspector.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_web_permissions_hitl.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_web_server.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_web_sse_adapter.py +0 -0
- {gemcode-0.4.26 → gemcode-0.4.28}/tests/test_workspace_hints.py +0 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""Detect local (or tenant-pod) dev servers and reverse-proxy them for the web UI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import atexit
|
|
6
|
+
import socket
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from http.client import HTTPConnection
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.error import URLError
|
|
15
|
+
from urllib.parse import quote, unquote
|
|
16
|
+
from urllib.request import Request, urlopen
|
|
17
|
+
|
|
18
|
+
# Common dev-server ports (GemCode web UI uses 3002; API 3001)
|
|
19
|
+
COMMON_DEV_PORTS = (
|
|
20
|
+
3000,
|
|
21
|
+
3003,
|
|
22
|
+
3004,
|
|
23
|
+
5173,
|
|
24
|
+
5174,
|
|
25
|
+
8080,
|
|
26
|
+
8000,
|
|
27
|
+
4200,
|
|
28
|
+
5000,
|
|
29
|
+
4321,
|
|
30
|
+
8888,
|
|
31
|
+
5500,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# When nothing listens, auto-start ``python -m http.server`` for static HTML previews.
|
|
35
|
+
# Skip framework ports (Vite/Next) so we do not mask a missing ``npm run dev``.
|
|
36
|
+
STATIC_AUTOSTART_PORTS = frozenset({8000, 8080, 5500, 8888, 5000, 4321})
|
|
37
|
+
|
|
38
|
+
_STRIP_RESPONSE_HEADERS = frozenset(
|
|
39
|
+
{
|
|
40
|
+
"transfer-encoding",
|
|
41
|
+
"connection",
|
|
42
|
+
"content-length",
|
|
43
|
+
"content-encoding",
|
|
44
|
+
"x-frame-options",
|
|
45
|
+
"content-security-policy",
|
|
46
|
+
"content-security-policy-report-only",
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
_server_lock = threading.Lock()
|
|
51
|
+
_managed_servers: dict[int, subprocess.Popen[bytes]] = {}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _port_open(host: str, port: int, *, timeout: float = 0.2) -> bool:
|
|
55
|
+
try:
|
|
56
|
+
with socket.create_connection((host, port), timeout=timeout):
|
|
57
|
+
return True
|
|
58
|
+
except OSError:
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _probe_http(url: str, *, timeout: float = 1.5) -> dict[str, Any] | None:
|
|
63
|
+
try:
|
|
64
|
+
req = Request(url, headers={"User-Agent": "GemCode-Web-Preview/1.0"})
|
|
65
|
+
with urlopen(req, timeout=timeout) as resp:
|
|
66
|
+
ctype = (resp.headers.get("Content-Type") or "").lower()
|
|
67
|
+
return {
|
|
68
|
+
"ok": True,
|
|
69
|
+
"status": resp.status,
|
|
70
|
+
"content_type": ctype,
|
|
71
|
+
}
|
|
72
|
+
except (URLError, OSError, TimeoutError, ValueError):
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def scan_local_preview_ports(
|
|
77
|
+
*,
|
|
78
|
+
host: str = "127.0.0.1",
|
|
79
|
+
extra_ports: list[int] | None = None,
|
|
80
|
+
) -> list[dict[str, Any]]:
|
|
81
|
+
"""Return listening HTTP-ish ports on localhost (tenant pod when hosted)."""
|
|
82
|
+
ports: list[int] = list(COMMON_DEV_PORTS)
|
|
83
|
+
if extra_ports:
|
|
84
|
+
for p in extra_ports:
|
|
85
|
+
if isinstance(p, int) and 1 < p < 65536 and p not in ports:
|
|
86
|
+
ports.append(p)
|
|
87
|
+
|
|
88
|
+
found: list[dict[str, Any]] = []
|
|
89
|
+
for port in ports:
|
|
90
|
+
if not _port_open(host, port):
|
|
91
|
+
continue
|
|
92
|
+
url = f"http://{host}:{port}"
|
|
93
|
+
probe = _probe_http(url)
|
|
94
|
+
found.append(
|
|
95
|
+
{
|
|
96
|
+
"port": port,
|
|
97
|
+
"url": url,
|
|
98
|
+
"reachable": probe is not None,
|
|
99
|
+
"content_type": (probe or {}).get("content_type"),
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
return found
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def handle_preview_get(kind: str) -> tuple[int, dict[str, Any]]:
|
|
106
|
+
k = (kind or "ports").strip().lower()
|
|
107
|
+
if k == "ports":
|
|
108
|
+
ports = scan_local_preview_ports()
|
|
109
|
+
return 200, {"ok": True, "ports": ports, "host": "127.0.0.1"}
|
|
110
|
+
return 400, {"ok": False, "error": f"Unknown preview kind: {kind}"}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _inject_base_href(html: str, base_path: str) -> str:
|
|
114
|
+
base_tag = f'<base href="{base_path}">'
|
|
115
|
+
if "<base " in html.lower():
|
|
116
|
+
return html
|
|
117
|
+
lower = html.lower()
|
|
118
|
+
idx = lower.find("<head")
|
|
119
|
+
if idx >= 0:
|
|
120
|
+
gt = html.find(">", idx)
|
|
121
|
+
if gt >= 0:
|
|
122
|
+
return html[: gt + 1] + base_tag + html[gt + 1 :]
|
|
123
|
+
return base_tag + html
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _proxy_base_for_path(port: int, request_path: str) -> str:
|
|
127
|
+
"""``/api/preview/proxy/{port}/…/`` directory for relative assets."""
|
|
128
|
+
path = request_path.split("?", 1)[0] or "/"
|
|
129
|
+
if path.endswith("/"):
|
|
130
|
+
dir_path = path
|
|
131
|
+
elif "/" in path:
|
|
132
|
+
dir_path = path.rsplit("/", 1)[0] + "/"
|
|
133
|
+
else:
|
|
134
|
+
dir_path = "/"
|
|
135
|
+
if not dir_path.startswith("/"):
|
|
136
|
+
dir_path = "/" + dir_path
|
|
137
|
+
# Encode each segment so spaces in folder names work in <base href>
|
|
138
|
+
parts = [quote(seg, safe="") for seg in dir_path.split("/") if seg]
|
|
139
|
+
encoded = "/" + "/".join(parts) + ("/" if parts or dir_path.endswith("/") else "")
|
|
140
|
+
if encoded == "//" or encoded == "":
|
|
141
|
+
encoded = "/"
|
|
142
|
+
elif not encoded.endswith("/"):
|
|
143
|
+
encoded += "/"
|
|
144
|
+
return f"/api/preview/proxy/{port}{encoded}"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _cleanup_managed_servers() -> None:
|
|
148
|
+
with _server_lock:
|
|
149
|
+
for proc in _managed_servers.values():
|
|
150
|
+
try:
|
|
151
|
+
proc.terminate()
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
_managed_servers.clear()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
atexit.register(_cleanup_managed_servers)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def ensure_workspace_static_server(port: int, root: Path) -> tuple[bool, str | None]:
|
|
161
|
+
"""
|
|
162
|
+
Ensure something accepts TCP on ``127.0.0.1:port``.
|
|
163
|
+
|
|
164
|
+
If the port is free and eligible, start ``python -m http.server`` in ``root``.
|
|
165
|
+
Returns (ok, note).
|
|
166
|
+
"""
|
|
167
|
+
if _port_open("127.0.0.1", port, timeout=0.35):
|
|
168
|
+
return True, None
|
|
169
|
+
if port not in STATIC_AUTOSTART_PORTS:
|
|
170
|
+
return False, (
|
|
171
|
+
f"No server on :{port}. Start your app in the workspace "
|
|
172
|
+
f"(e.g. npm run dev) or use a static port like 8000."
|
|
173
|
+
)
|
|
174
|
+
root = root.expanduser().resolve()
|
|
175
|
+
if not root.is_dir():
|
|
176
|
+
return False, f"Workspace root is not a directory: {root}"
|
|
177
|
+
|
|
178
|
+
with _server_lock:
|
|
179
|
+
if _port_open("127.0.0.1", port, timeout=0.35):
|
|
180
|
+
return True, None
|
|
181
|
+
existing = _managed_servers.get(port)
|
|
182
|
+
if existing is not None and existing.poll() is None:
|
|
183
|
+
for _ in range(25):
|
|
184
|
+
if _port_open("127.0.0.1", port, timeout=0.2):
|
|
185
|
+
return True, "managed"
|
|
186
|
+
if existing.poll() is not None:
|
|
187
|
+
break
|
|
188
|
+
time.sleep(0.08)
|
|
189
|
+
try:
|
|
190
|
+
proc = subprocess.Popen(
|
|
191
|
+
[
|
|
192
|
+
sys.executable,
|
|
193
|
+
"-m",
|
|
194
|
+
"http.server",
|
|
195
|
+
str(port),
|
|
196
|
+
"--bind",
|
|
197
|
+
"127.0.0.1",
|
|
198
|
+
],
|
|
199
|
+
cwd=str(root),
|
|
200
|
+
stdout=subprocess.DEVNULL,
|
|
201
|
+
stderr=subprocess.DEVNULL,
|
|
202
|
+
start_new_session=True,
|
|
203
|
+
)
|
|
204
|
+
except OSError as exc:
|
|
205
|
+
return False, f"Could not start static preview server: {exc}"
|
|
206
|
+
_managed_servers[port] = proc
|
|
207
|
+
for _ in range(30):
|
|
208
|
+
if _port_open("127.0.0.1", port, timeout=0.2):
|
|
209
|
+
return True, "started"
|
|
210
|
+
if proc.poll() is not None:
|
|
211
|
+
_managed_servers.pop(port, None)
|
|
212
|
+
return False, "Static preview server exited immediately (port may be busy)."
|
|
213
|
+
time.sleep(0.08)
|
|
214
|
+
return False, "Timed out waiting for static preview server."
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def handle_preview_proxy(
|
|
218
|
+
port: int,
|
|
219
|
+
sub_path: str = "/",
|
|
220
|
+
*,
|
|
221
|
+
query: str = "",
|
|
222
|
+
workspace_root: str | Path | None = None,
|
|
223
|
+
) -> tuple[int, bytes, dict[str, str]]:
|
|
224
|
+
"""
|
|
225
|
+
Reverse-proxy HTTP from 127.0.0.1:{port} (on this process / tenant pod).
|
|
226
|
+
|
|
227
|
+
If nothing is listening and ``port`` is a static preview port, auto-start
|
|
228
|
+
``python -m http.server`` in the workspace so HTML apps work without a
|
|
229
|
+
manual ``bash`` step (hosted GKE).
|
|
230
|
+
|
|
231
|
+
Returns (status, body, response_headers).
|
|
232
|
+
"""
|
|
233
|
+
if not isinstance(port, int) or port < 1 or port > 65535:
|
|
234
|
+
return 400, b'{"error":"Invalid port"}', {"Content-Type": "application/json; charset=utf-8"}
|
|
235
|
+
|
|
236
|
+
# Block GemCode's own API/UI ports from being framed as "app preview"
|
|
237
|
+
if port in (3001, 3002):
|
|
238
|
+
return (
|
|
239
|
+
400,
|
|
240
|
+
b'{"error":"Port reserved for GemCode API/UI"}',
|
|
241
|
+
{"Content-Type": "application/json; charset=utf-8"},
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
raw = sub_path or "/"
|
|
245
|
+
if not raw.startswith("/"):
|
|
246
|
+
raw = "/" + raw
|
|
247
|
+
# Collapse .. segments
|
|
248
|
+
parts: list[str] = []
|
|
249
|
+
for seg in raw.split("/"):
|
|
250
|
+
if seg in ("", "."):
|
|
251
|
+
continue
|
|
252
|
+
if seg == "..":
|
|
253
|
+
if parts:
|
|
254
|
+
parts.pop()
|
|
255
|
+
continue
|
|
256
|
+
parts.append(seg)
|
|
257
|
+
path = "/" + "/".join(parts)
|
|
258
|
+
if raw.endswith("/") and path != "/":
|
|
259
|
+
path += "/"
|
|
260
|
+
|
|
261
|
+
qs = (query or "").lstrip("?")
|
|
262
|
+
request_path = path if not qs else f"{path}?{qs}"
|
|
263
|
+
|
|
264
|
+
# Encode path for the upstream request (spaces in folder names).
|
|
265
|
+
upstream_path = "/" + "/".join(quote(seg, safe="") for seg in parts)
|
|
266
|
+
if path.endswith("/") and upstream_path != "/":
|
|
267
|
+
upstream_path += "/"
|
|
268
|
+
if not qs:
|
|
269
|
+
upstream_request = upstream_path
|
|
270
|
+
else:
|
|
271
|
+
upstream_request = f"{upstream_path}?{qs}"
|
|
272
|
+
|
|
273
|
+
root: Path | None = None
|
|
274
|
+
if workspace_root:
|
|
275
|
+
try:
|
|
276
|
+
root = Path(workspace_root).expanduser().resolve()
|
|
277
|
+
except OSError:
|
|
278
|
+
root = None
|
|
279
|
+
|
|
280
|
+
def _fetch() -> tuple[int, bytes, str, list[tuple[str, str]]]:
|
|
281
|
+
conn = HTTPConnection("127.0.0.1", port, timeout=20.0)
|
|
282
|
+
conn.request(
|
|
283
|
+
"GET",
|
|
284
|
+
upstream_request,
|
|
285
|
+
headers={"User-Agent": "GemCode-Web-Preview/1.0", "Accept": "*/*"},
|
|
286
|
+
)
|
|
287
|
+
resp = conn.getresponse()
|
|
288
|
+
body = resp.read()
|
|
289
|
+
ctype = (resp.getheader("Content-Type") or "").lower()
|
|
290
|
+
headers_list = resp.getheaders()
|
|
291
|
+
status = int(resp.status)
|
|
292
|
+
conn.close()
|
|
293
|
+
return status, body, ctype, headers_list
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
status, body, ctype, headers_list = _fetch()
|
|
297
|
+
except OSError as first_exc:
|
|
298
|
+
note: str | None = None
|
|
299
|
+
if root is not None:
|
|
300
|
+
ok, note = ensure_workspace_static_server(port, root)
|
|
301
|
+
if ok:
|
|
302
|
+
try:
|
|
303
|
+
time.sleep(0.05)
|
|
304
|
+
status, body, ctype, headers_list = _fetch()
|
|
305
|
+
except OSError as second_exc:
|
|
306
|
+
msg = (
|
|
307
|
+
f'{{"error":"Preview upstream unreachable after starting static server: '
|
|
308
|
+
f'{type(second_exc).__name__}: {second_exc}"}}'
|
|
309
|
+
)
|
|
310
|
+
return 502, msg.encode(), {"Content-Type": "application/json; charset=utf-8"}
|
|
311
|
+
else:
|
|
312
|
+
detail = note or str(first_exc)
|
|
313
|
+
msg = f'{{"error":"Preview upstream unreachable: {detail}"}}'
|
|
314
|
+
return 502, msg.encode(), {"Content-Type": "application/json; charset=utf-8"}
|
|
315
|
+
else:
|
|
316
|
+
msg = f'{{"error":"Preview upstream unreachable: {type(first_exc).__name__}: {first_exc}"}}'
|
|
317
|
+
return 502, msg.encode(), {"Content-Type": "application/json; charset=utf-8"}
|
|
318
|
+
|
|
319
|
+
headers: dict[str, str] = {}
|
|
320
|
+
for key, value in headers_list:
|
|
321
|
+
if key.lower() in _STRIP_RESPONSE_HEADERS:
|
|
322
|
+
continue
|
|
323
|
+
headers[key] = value
|
|
324
|
+
|
|
325
|
+
proxy_base = _proxy_base_for_path(port, path)
|
|
326
|
+
if "text/html" in ctype:
|
|
327
|
+
try:
|
|
328
|
+
text = body.decode("utf-8", errors="replace")
|
|
329
|
+
text = _inject_base_href(text, proxy_base)
|
|
330
|
+
body = text.encode("utf-8")
|
|
331
|
+
headers["Content-Type"] = "text/html; charset=utf-8"
|
|
332
|
+
except Exception:
|
|
333
|
+
pass
|
|
334
|
+
|
|
335
|
+
headers["Cache-Control"] = "no-store"
|
|
336
|
+
headers["X-Frame-Options"] = "SAMEORIGIN"
|
|
337
|
+
headers["Content-Security-Policy"] = (
|
|
338
|
+
"default-src * 'unsafe-inline' 'unsafe-eval' data: blob:; "
|
|
339
|
+
"script-src * 'unsafe-inline' 'unsafe-eval'; style-src * 'unsafe-inline'; "
|
|
340
|
+
"frame-ancestors 'self';"
|
|
341
|
+
)
|
|
342
|
+
return status, body, headers
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def parse_preview_proxy_path(raw_path: str) -> tuple[int, str] | None:
|
|
346
|
+
"""Parse `/api/preview/proxy/{port}` or `/api/preview/proxy/{port}/...` → (port, sub_path)."""
|
|
347
|
+
prefix = "/api/preview/proxy/"
|
|
348
|
+
if not raw_path.startswith(prefix):
|
|
349
|
+
return None
|
|
350
|
+
rest = raw_path[len(prefix) :]
|
|
351
|
+
if not rest:
|
|
352
|
+
return None
|
|
353
|
+
parts = rest.split("/", 1)
|
|
354
|
+
try:
|
|
355
|
+
port = int(parts[0])
|
|
356
|
+
except ValueError:
|
|
357
|
+
return None
|
|
358
|
+
sub = "/"
|
|
359
|
+
if len(parts) > 1 and parts[1]:
|
|
360
|
+
sub = "/" + unquote(parts[1])
|
|
361
|
+
elif rest.endswith("/") or raw_path.endswith("/"):
|
|
362
|
+
sub = "/"
|
|
363
|
+
return port, sub
|
|
@@ -239,8 +239,18 @@ def _build_handler(project_root: str) -> type[BaseHTTPRequestHandler]:
|
|
|
239
239
|
params = parse_qs(parsed.query)
|
|
240
240
|
raw_path = (params.get("path") or [""])[0]
|
|
241
241
|
try:
|
|
242
|
+
from gemcode.web.project_root import hosted_tenant_root, _is_within_root
|
|
243
|
+
|
|
242
244
|
resolved = Path(raw_path).expanduser().resolve()
|
|
243
|
-
|
|
245
|
+
tenant = hosted_tenant_root()
|
|
246
|
+
if tenant is not None and not (
|
|
247
|
+
resolved == tenant.resolve() or _is_within_root(tenant, resolved)
|
|
248
|
+
):
|
|
249
|
+
payload = {
|
|
250
|
+
"valid": False,
|
|
251
|
+
"error": f"Folder must be inside tenant workspace ({tenant})",
|
|
252
|
+
}
|
|
253
|
+
elif resolved.is_dir():
|
|
244
254
|
payload = {"valid": True, "resolved": str(resolved)}
|
|
245
255
|
else:
|
|
246
256
|
payload = {"valid": False, "error": "Folder not found"}
|
|
@@ -318,7 +328,7 @@ def _build_handler(project_root: str) -> type[BaseHTTPRequestHandler]:
|
|
|
318
328
|
else:
|
|
319
329
|
port, sub_path = parsed_proxy
|
|
320
330
|
status, body, hdrs = handle_preview_proxy(
|
|
321
|
-
port, sub_path, query=parsed.query or ""
|
|
331
|
+
port, sub_path, query=parsed.query or "", workspace_root=root
|
|
322
332
|
)
|
|
323
333
|
except Exception as exc:
|
|
324
334
|
status = 500
|
|
@@ -512,10 +512,15 @@ def _inject_web_code_context(cfg: GemCodeConfig, prompt: str, req: dict[str, Any
|
|
|
512
512
|
else:
|
|
513
513
|
lines.append(
|
|
514
514
|
"**Web UI permissions — interactive.** Shell and mutating tools pause for an "
|
|
515
|
-
"**inline Yes/No card in the chat
|
|
515
|
+
"**inline Yes/No card in the chat**. "
|
|
516
516
|
"Do **not** invent Approve/Deny dialog text or tell the user to open a popup — "
|
|
517
517
|
"wait silently; after they tap Yes or No the turn continues."
|
|
518
518
|
)
|
|
519
|
+
lines.append(
|
|
520
|
+
"**Web preview:** For static HTML/CSS/JS, prefer `http://localhost:8000/...` links. "
|
|
521
|
+
"GemCode will auto-serve the workspace on common static ports (8000/8080/…) when "
|
|
522
|
+
"nothing is listening. For Vite/Next (3000/5173), start the real dev server with bash first."
|
|
523
|
+
)
|
|
519
524
|
|
|
520
525
|
if mode == "agents":
|
|
521
526
|
lines.extend(
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Tests for preview proxy path parsing and static autostart."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from gemcode.web import preview_api
|
|
8
|
+
from gemcode.web.preview_api import parse_preview_proxy_path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_parse_preview_proxy_root() -> None:
|
|
12
|
+
assert parse_preview_proxy_path("/api/preview/proxy/8000") == (8000, "/")
|
|
13
|
+
assert parse_preview_proxy_path("/api/preview/proxy/8000/") == (8000, "/")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_parse_preview_proxy_subpath() -> None:
|
|
17
|
+
assert parse_preview_proxy_path("/api/preview/proxy/5173/assets/app.js") == (
|
|
18
|
+
5173,
|
|
19
|
+
"/assets/app.js",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_parse_preview_proxy_space_path() -> None:
|
|
24
|
+
assert parse_preview_proxy_path("/api/preview/proxy/8000/final%20todo/index.html") == (
|
|
25
|
+
8000,
|
|
26
|
+
"/final todo/index.html",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_parse_preview_proxy_invalid() -> None:
|
|
31
|
+
assert parse_preview_proxy_path("/api/preview") is None
|
|
32
|
+
assert parse_preview_proxy_path("/api/preview/proxy/") is None
|
|
33
|
+
assert parse_preview_proxy_path("/api/preview/proxy/abc") is None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_proxy_base_for_nested_html() -> None:
|
|
37
|
+
assert preview_api._proxy_base_for_path(8000, "/final todo/index.html") == (
|
|
38
|
+
"/api/preview/proxy/8000/final%20todo/"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_static_autostart_serves_workspace(tmp_path: Path) -> None:
|
|
43
|
+
(tmp_path / "hello.html").write_text("<html><body>hi</body></html>", encoding="utf-8")
|
|
44
|
+
port = 18765
|
|
45
|
+
original = preview_api.STATIC_AUTOSTART_PORTS
|
|
46
|
+
preview_api.STATIC_AUTOSTART_PORTS = frozenset(set(original) | {port})
|
|
47
|
+
try:
|
|
48
|
+
status, body, headers = preview_api.handle_preview_proxy(
|
|
49
|
+
port,
|
|
50
|
+
"/hello.html",
|
|
51
|
+
workspace_root=tmp_path,
|
|
52
|
+
)
|
|
53
|
+
assert status == 200
|
|
54
|
+
assert b"hi" in body
|
|
55
|
+
assert "text/html" in headers.get("Content-Type", "")
|
|
56
|
+
finally:
|
|
57
|
+
preview_api.STATIC_AUTOSTART_PORTS = original
|
|
58
|
+
with preview_api._server_lock:
|
|
59
|
+
proc = preview_api._managed_servers.pop(port, None)
|
|
60
|
+
if proc is not None and proc.poll() is None:
|
|
61
|
+
proc.terminate()
|
|
62
|
+
try:
|
|
63
|
+
proc.wait(timeout=2)
|
|
64
|
+
except Exception:
|
|
65
|
+
proc.kill()
|