gemcode 0.4.19__tar.gz → 0.4.21__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.19/src/gemcode.egg-info → gemcode-0.4.21}/PKG-INFO +2 -1
- {gemcode-0.4.19 → gemcode-0.4.21}/README.md +1 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/pyproject.toml +1 -1
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/repl_commands.py +2 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/repl_slash.py +54 -4
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/web/hitl_bridge.py +22 -6
- gemcode-0.4.21/src/gemcode/web/project_root.py +60 -0
- gemcode-0.4.21/src/gemcode/web/serve_bind.py +106 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/web/serve_state.py +36 -2
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/web/server.py +88 -11
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/web/sessions_api.py +12 -5
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/web/sse_adapter.py +49 -13
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/web/terminal_api.py +21 -10
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/web/workspace_panel_api.py +6 -0
- {gemcode-0.4.19 → gemcode-0.4.21/src/gemcode.egg-info}/PKG-INFO +2 -1
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode.egg-info/SOURCES.txt +2 -0
- gemcode-0.4.21/tests/test_hosted_tenant.py +52 -0
- gemcode-0.4.21/tests/test_web_server.py +69 -0
- gemcode-0.4.19/src/gemcode/web/project_root.py +0 -21
- gemcode-0.4.19/tests/test_web_server.py +0 -30
- {gemcode-0.4.19 → gemcode-0.4.21}/LICENSE +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/MANIFEST.in +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/setup.cfg +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/__init__.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/__main__.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/a2a_bridge.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/agent.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/agent_habits.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/agent_intelligence.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/agent_mesh.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/agent_triggers.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/audit.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/autocompact.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/automations.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/autotune.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/callbacks.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/capability_routing.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/checkpoints.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/cli.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/codebase_awareness.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/compaction.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/computer_use/__init__.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/computer_use/browser_computer.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/config.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/context_budget.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/context_warning.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/credentials.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/curated_memory.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/delegation_learning.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/dynamic_policy.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/evals/harness.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/event_bus.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/fleet_reports.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/hitl_session.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/hooks.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/ide_protocol.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/ide_stdio.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/intent_classifier.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/interactions.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/invoke.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/kaira_client.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/kaira_daemon.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/kaira_ipc.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/kaira_job_store.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/learning.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/limits.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/live_audio_engine.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/logging_config.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/mcp_loader.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/memory/__init__.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/memory/embedding_memory_service.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/memory/file_memory_service.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/modality_tools.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/model_errors.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/model_routing.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/multimodal_input.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/openapi_loader.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/org.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/output_styles.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/paths.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/permissions.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/plugins/__init__.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/plugins/terminal_hooks_plugin.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/plugins/tool_recovery_plugin.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/policy_profile.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/pricing.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/prompt_suggestions.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/query/__init__.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/query/config.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/query/deps.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/query/engine.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/query/stop_hooks.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/query/token_budget.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/query/transitions.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/query_sanitizer.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/refine.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/review_agent.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/rules.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/self_healing.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/session_runtime.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/session_store.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/session_summariser.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/skills.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/slash_commands.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/thinking.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/tool_prompt_manifest.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/tool_registry.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/tool_result_store.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/tool_synthesis.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/tools/__init__.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/tools/automations_tools.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/tools/bash.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/tools/browser.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/tools/compress_memory.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/tools/curated_memory.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/tools/edit.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/tools/filesystem.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/tools/notebook.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/tools/notes.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/tools/org_tools.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/tools/repo_map.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/tools/search.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/tools/shell.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/tools/shell_gate.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/tools/skills.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/tools/subtask.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/tools/tasks.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/tools/think.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/tools/todo.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/tools/user_choice.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/tools/veomem_tools.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/tools/web.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/tools/web_search.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/tools_inspector.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/trust.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/tui/input_handler.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/tui/scrollback.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/tui/spinner.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/tui/welcome_banner.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/tui/welcome_rich.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/veomem_bridge.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/version.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/vertex.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/wal.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/web/__init__.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/web/chat_skills.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/web/customize_api.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/web/fleet_api.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/web/preview_api.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/web/runtime_api.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/web/terminal_repl.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/web/web_config_api.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/web/web_sse_compat.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode/workspace_hints.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode.egg-info/dependency_links.txt +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode.egg-info/entry_points.txt +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode.egg-info/requires.txt +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/src/gemcode.egg-info/top_level.txt +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_add_dir.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_agent_habits.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_agent_instruction.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_agent_mesh.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_autocompact.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_automations.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_capability_routing.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_checkpoint_diff_command.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_cli_init.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_compress_memory_tool.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_computer_use_permissions.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_context_budget.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_context_warning.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_credentials.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_eval_harness_layout.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_event_bus.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_fleet_reports.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_ide_stdio_attachments.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_interactive_permission_ask.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_kaira_ipc_paths.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_kaira_scheduler.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_modality_tools.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_model_error_retry.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_model_errors.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_model_routing.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_multimodal_input.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_output_styles_and_rules.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_paths.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_permissions.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_prompt_suggestions.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_repl_commands.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_repl_slash.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_session_runtime_cache.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_skills.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_slash_commands.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_slash_completion_registry.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_thinking_config.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_token_budget.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_tool_context_circulation.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_tools.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_tools_inspector.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_web_sse_adapter.py +0 -0
- {gemcode-0.4.19 → gemcode-0.4.21}/tests/test_workspace_hints.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gemcode
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.21
|
|
4
4
|
Summary: Local-first coding agent on Google Gemini + ADK
|
|
5
5
|
Author: GemCode Contributors
|
|
6
6
|
License: Apache License
|
|
@@ -221,6 +221,7 @@ All state lives under `.gemcode/` in the project root. No external services requ
|
|
|
221
221
|
| Agent Mesh | In-process multi-agent orchestration (automatic) |
|
|
222
222
|
| GemCode Runtime | Optional always-on background scheduler (`gemcode runtime`; alias `gemcode kaira`) |
|
|
223
223
|
| HTTP API (`gemcode serve`) | Built-in web/custom UI backend on `http://127.0.0.1:3001` — chat SSE, sessions, panel, HITL |
|
|
224
|
+
| Hosted multi-tenant | One `gemcode serve` per user with `GEMCODE_HOSTED_TENANT_ROOT` — see [`../docs/hosted.md`](../docs/hosted.md) |
|
|
224
225
|
| A2A server | Cross-machine agent communication via Google A2A protocol |
|
|
225
226
|
| Live audio (experimental) | Microphone-driven Gemini Live sessions |
|
|
226
227
|
|
|
@@ -28,6 +28,7 @@ All state lives under `.gemcode/` in the project root. No external services requ
|
|
|
28
28
|
| Agent Mesh | In-process multi-agent orchestration (automatic) |
|
|
29
29
|
| GemCode Runtime | Optional always-on background scheduler (`gemcode runtime`; alias `gemcode kaira`) |
|
|
30
30
|
| HTTP API (`gemcode serve`) | Built-in web/custom UI backend on `http://127.0.0.1:3001` — chat SSE, sessions, panel, HITL |
|
|
31
|
+
| Hosted multi-tenant | One `gemcode serve` per user with `GEMCODE_HOSTED_TENANT_ROOT` — see [`../docs/hosted.md`](../docs/hosted.md) |
|
|
31
32
|
| A2A server | Cross-machine agent communication via Google A2A protocol |
|
|
32
33
|
| Live audio (experimental) | Microphone-driven Gemini Live sessions |
|
|
33
34
|
|
|
@@ -251,6 +251,7 @@ SLASH_COMMANDS: list[tuple[str, str]] = [
|
|
|
251
251
|
("kaira", "Background jobs — gemcode runtime (alias: gemcode kaira)"),
|
|
252
252
|
("runtime", "Fleet socket status · gemcode runtime · attach/connect"),
|
|
253
253
|
("serve", "HTTP API for web/custom UIs — gemcode serve on :3001"),
|
|
254
|
+
("hosted", "Hosted multi-tenant lock — GEMCODE_HOSTED_TENANT_ROOT status"),
|
|
254
255
|
("bus", "Runtime bus — send/publish lightweight messages over IPC"),
|
|
255
256
|
("inbox", "Bus inbox filters for this UI (to/topics)"),
|
|
256
257
|
("fleet", "Fleet inbox — /fleet show | digest (habits / mesh reports)"),
|
|
@@ -413,6 +414,7 @@ def slash_help_lines() -> list[str]:
|
|
|
413
414
|
" /live-audio How to run gemcode live-audio (mic → Gemini Live)",
|
|
414
415
|
" /doctor Environment sanity check",
|
|
415
416
|
" /serve [start|status|stop|url] Start HTTP API for web UI (gemcode serve on :3001)",
|
|
417
|
+
" /hosted [status] Hosted tenant lock (GEMCODE_HOSTED_TENANT_ROOT)",
|
|
416
418
|
" /version Print GemCode version hint",
|
|
417
419
|
" /exit Exit the REPL",
|
|
418
420
|
"",
|
|
@@ -2584,7 +2584,13 @@ async def process_repl_slash(
|
|
|
2584
2584
|
|
|
2585
2585
|
if first in ("status", "ps"):
|
|
2586
2586
|
running, info = is_serve_running(cfg.project_root, host=host, port=port)
|
|
2587
|
-
if
|
|
2587
|
+
if info and info.get("project_mismatch"):
|
|
2588
|
+
out("[serve] port in use by another GemCode API (different project)")
|
|
2589
|
+
out(f" url: {info.get('url') or serve_base_url(host, port)}")
|
|
2590
|
+
out(f" this project: {info.get('expected_root') or cfg.project_root}")
|
|
2591
|
+
out(f" server root: {info.get('actual_root') or '?'}")
|
|
2592
|
+
out(" Stop the other server or run `gemcode serve -C ...` for the matching folder.")
|
|
2593
|
+
elif running and info:
|
|
2588
2594
|
out("[serve] running")
|
|
2589
2595
|
out(f" url: {info.get('url') or serve_base_url(host, port)}")
|
|
2590
2596
|
out(f" project: {info.get('project_root') or cfg.project_root}")
|
|
@@ -2617,19 +2623,63 @@ async def process_repl_slash(
|
|
|
2617
2623
|
session_id=session_id,
|
|
2618
2624
|
)
|
|
2619
2625
|
if not result.get("ok"):
|
|
2620
|
-
|
|
2626
|
+
if result.get("project_mismatch"):
|
|
2627
|
+
out("[serve] port 3001 is used by GemCode for a different project:")
|
|
2628
|
+
out(f" expected: {result.get('expected_root')}")
|
|
2629
|
+
out(f" actual: {result.get('actual_root')}")
|
|
2630
|
+
out(" Stop that server first, or start this project's API on another port.")
|
|
2631
|
+
else:
|
|
2632
|
+
out(f"[serve] failed: {result.get('error') or 'unknown error'}")
|
|
2621
2633
|
if result.get("log"):
|
|
2622
2634
|
out(f" log: {result.get('log')}")
|
|
2623
2635
|
elif result.get("already_running"):
|
|
2624
2636
|
out(f"[serve] already running at {result.get('url')}")
|
|
2625
2637
|
else:
|
|
2626
|
-
|
|
2638
|
+
if result.get("port_fallback"):
|
|
2639
|
+
pref = result.get("preferred_port") or DEFAULT_SERVE_PORT
|
|
2640
|
+
out(f"[serve] port {pref} was busy — started at {result.get('url')}")
|
|
2641
|
+
out(f" Set NEXT_PUBLIC_API_URL={result.get('url')} in gemcode-web-ui/.env.local")
|
|
2642
|
+
else:
|
|
2643
|
+
out(f"[serve] started at {result.get('url')}")
|
|
2627
2644
|
if result.get("warming"):
|
|
2628
2645
|
out("[serve] warming up — retry /serve status in a moment")
|
|
2629
2646
|
if result.get("log"):
|
|
2630
2647
|
out(f" log: {result.get('log')}")
|
|
2631
2648
|
out(f"[serve] session_id: {session_id}")
|
|
2632
|
-
out("
|
|
2649
|
+
out("Web UI: npm run dev in gemcode-web-ui → http://localhost:3002")
|
|
2650
|
+
out()
|
|
2651
|
+
return ReplSlashResult(skip_model_turn=True)
|
|
2652
|
+
|
|
2653
|
+
# ── /hosted (multi-tenant serve lock) ───────────────────────────────────
|
|
2654
|
+
if name == "hosted":
|
|
2655
|
+
from gemcode.web.project_root import hosted_tenant_root
|
|
2656
|
+
|
|
2657
|
+
args_h = (sc.args or "").strip().lower()
|
|
2658
|
+
first_h = args_h.split()[0] if args_h else "status"
|
|
2659
|
+
|
|
2660
|
+
if first_h in ("help", "?"):
|
|
2661
|
+
out("Hosted multi-tenant mode (one workspace per gemcode serve process):")
|
|
2662
|
+
out(" /hosted Show lock status")
|
|
2663
|
+
out(" /hosted status Same")
|
|
2664
|
+
out("")
|
|
2665
|
+
out("Set GEMCODE_HOSTED_TENANT_ROOT to the tenant workspace path.")
|
|
2666
|
+
out("Web API then rejects client path/project_root outside that directory.")
|
|
2667
|
+
out("HITL approvals use {workspace}/.gemcode/web_approvals.")
|
|
2668
|
+
out("")
|
|
2669
|
+
out("Deployment guide: docs/hosted.md and deploy/gcp/ in the repo.")
|
|
2670
|
+
out()
|
|
2671
|
+
return ReplSlashResult(skip_model_turn=True)
|
|
2672
|
+
|
|
2673
|
+
locked = hosted_tenant_root()
|
|
2674
|
+
if locked is not None:
|
|
2675
|
+
out("[hosted] active — tenant workspace locked")
|
|
2676
|
+
out(f" GEMCODE_HOSTED_TENANT_ROOT={locked}")
|
|
2677
|
+
out(f" project_root: {cfg.project_root}")
|
|
2678
|
+
out(" Client path params must stay inside the tenant root (HTTP 403 otherwise).")
|
|
2679
|
+
else:
|
|
2680
|
+
out("[hosted] not active (local mode)")
|
|
2681
|
+
out(" Client path / project_root query params are honored.")
|
|
2682
|
+
out(" Set GEMCODE_HOSTED_TENANT_ROOT in containers for multi-tenant hosting.")
|
|
2633
2683
|
out()
|
|
2634
2684
|
return ReplSlashResult(skip_model_turn=True)
|
|
2635
2685
|
|
|
@@ -4,21 +4,35 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import json
|
|
7
|
+
import os
|
|
7
8
|
import time
|
|
8
9
|
import uuid
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
from typing import Any, Callable
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
def _approval_dir() -> Path:
|
|
14
|
+
tenant = os.environ.get("GEMCODE_HOSTED_TENANT_ROOT", "").strip()
|
|
15
|
+
if tenant:
|
|
16
|
+
return Path(tenant).expanduser().resolve() / ".gemcode" / "web_approvals"
|
|
17
|
+
return Path.home() / ".gemcode" / "web_approvals"
|
|
18
|
+
_DEFAULT_TIMEOUT_S = float(os.environ.get("GEMCODE_WEB_HITL_TIMEOUT_S", "3600"))
|
|
14
19
|
_POLL_INTERVAL_S = 0.25
|
|
15
20
|
# approval_ids currently blocked in wait_for_web_approval (same process)
|
|
16
21
|
_active_waiters: set[str] = set()
|
|
22
|
+
# emitted to the UI but wait_for_web_approval not started yet (preflight)
|
|
23
|
+
_pending_approval_ids: set[str] = set()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def register_pending_approval(approval_id: str) -> None:
|
|
27
|
+
"""Mark an approval as expected so early UI clicks are not reported as late."""
|
|
28
|
+
aid = (approval_id or "").strip()
|
|
29
|
+
if aid:
|
|
30
|
+
_pending_approval_ids.add(aid)
|
|
17
31
|
|
|
18
32
|
|
|
19
33
|
def _approval_path(approval_id: str) -> Path:
|
|
20
34
|
safe = "".join(c if c.isalnum() or c in "-_:" else "_" for c in approval_id)
|
|
21
|
-
return
|
|
35
|
+
return _approval_dir() / f"{safe}.json"
|
|
22
36
|
|
|
23
37
|
|
|
24
38
|
def _read_approval_file(path: Path) -> bool | None:
|
|
@@ -44,11 +58,11 @@ def resolve_web_approval(approval_id: str, *, confirmed: bool) -> dict[str, Any]
|
|
|
44
58
|
"""Record the user's approve/deny decision (called from HTTP handler)."""
|
|
45
59
|
if not approval_id.strip():
|
|
46
60
|
return {"ok": False, "error": "approval_id is required"}
|
|
47
|
-
|
|
61
|
+
_approval_dir().mkdir(parents=True, exist_ok=True)
|
|
48
62
|
path = _approval_path(approval_id)
|
|
49
63
|
payload = {"confirmed": bool(confirmed), "resolved_ms": int(time.time() * 1000)}
|
|
50
64
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
51
|
-
late = approval_id not in _active_waiters
|
|
65
|
+
late = approval_id not in _active_waiters and approval_id not in _pending_approval_ids
|
|
52
66
|
return {
|
|
53
67
|
"ok": True,
|
|
54
68
|
"approval_id": approval_id,
|
|
@@ -65,7 +79,7 @@ async def wait_for_web_approval(
|
|
|
65
79
|
heartbeat_s: float = 15.0,
|
|
66
80
|
) -> bool:
|
|
67
81
|
"""Block until the user approves/denies or timeout (deny on timeout)."""
|
|
68
|
-
|
|
82
|
+
_approval_dir().mkdir(parents=True, exist_ok=True)
|
|
69
83
|
path = _approval_path(approval_id)
|
|
70
84
|
|
|
71
85
|
# User may have approved before we started waiting (preflight UI is immediate).
|
|
@@ -73,6 +87,7 @@ async def wait_for_web_approval(
|
|
|
73
87
|
if existing is not None:
|
|
74
88
|
return existing
|
|
75
89
|
|
|
90
|
+
_pending_approval_ids.discard(approval_id)
|
|
76
91
|
_active_waiters.add(approval_id)
|
|
77
92
|
try:
|
|
78
93
|
deadline = time.monotonic() + max(1.0, timeout_s)
|
|
@@ -93,6 +108,7 @@ async def wait_for_web_approval(
|
|
|
93
108
|
return False
|
|
94
109
|
finally:
|
|
95
110
|
_active_waiters.discard(approval_id)
|
|
111
|
+
_pending_approval_ids.discard(approval_id)
|
|
96
112
|
|
|
97
113
|
|
|
98
114
|
def new_approval_id(session_id: str, fc_id: str | None) -> str:
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Shared workspace path resolution for GemCode web HTTP handlers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class HostedTenantPathError(ValueError):
|
|
10
|
+
"""Client requested a path outside the locked tenant workspace."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def hosted_tenant_root() -> Path | None:
|
|
14
|
+
"""When set, web handlers are locked to this directory (multi-tenant hosting)."""
|
|
15
|
+
raw = os.environ.get("GEMCODE_HOSTED_TENANT_ROOT", "").strip()
|
|
16
|
+
if not raw:
|
|
17
|
+
return None
|
|
18
|
+
return Path(raw).expanduser().resolve()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _is_within_root(root: Path, candidate: Path) -> bool:
|
|
22
|
+
try:
|
|
23
|
+
candidate.resolve().relative_to(root.resolve())
|
|
24
|
+
return True
|
|
25
|
+
except ValueError:
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def resolve_web_project_root(raw_path: str | None) -> Path:
|
|
30
|
+
"""
|
|
31
|
+
Resolve the active project/workspace root for a web API request.
|
|
32
|
+
|
|
33
|
+
Prefers an explicit ``path`` query/body param, then ``GEMCODE_WEB_PROJECT_ROOT``,
|
|
34
|
+
then the API process working directory.
|
|
35
|
+
|
|
36
|
+
When ``GEMCODE_HOSTED_TENANT_ROOT`` is set (hosted multi-tenant mode), client
|
|
37
|
+
paths must stay inside that root; otherwise the locked root is always used.
|
|
38
|
+
"""
|
|
39
|
+
locked = hosted_tenant_root()
|
|
40
|
+
if locked is not None:
|
|
41
|
+
if raw_path and str(raw_path).strip():
|
|
42
|
+
candidate = Path(str(raw_path).strip()).expanduser().resolve()
|
|
43
|
+
if not _is_within_root(locked, candidate):
|
|
44
|
+
raise HostedTenantPathError(
|
|
45
|
+
f"path is outside tenant workspace: {candidate} (root={locked})"
|
|
46
|
+
)
|
|
47
|
+
return candidate
|
|
48
|
+
return locked
|
|
49
|
+
|
|
50
|
+
if raw_path and str(raw_path).strip():
|
|
51
|
+
return Path(str(raw_path).strip()).expanduser().resolve()
|
|
52
|
+
env = os.environ.get("GEMCODE_WEB_PROJECT_ROOT", "").strip()
|
|
53
|
+
if env:
|
|
54
|
+
return Path(env).expanduser().resolve()
|
|
55
|
+
return Path.cwd().resolve()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def resolve_sse_project_root(raw_path: str | None) -> Path:
|
|
59
|
+
"""Same rules as :func:`resolve_web_project_root` for SSE chat subprocess."""
|
|
60
|
+
return resolve_web_project_root(raw_path)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Bind helpers for ``gemcode serve`` (port scan + user-facing connection hints)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import errno
|
|
6
|
+
import os
|
|
7
|
+
import socket
|
|
8
|
+
from http.server import ThreadingHTTPServer
|
|
9
|
+
from typing import Callable, TypeVar
|
|
10
|
+
|
|
11
|
+
from gemcode.web.serve_state import DEFAULT_SERVE_PORT, serve_base_url
|
|
12
|
+
|
|
13
|
+
# Next.js dev server default — skip when auto-picking an API port.
|
|
14
|
+
UI_RESERVED_PORTS: frozenset[int] = frozenset({3002})
|
|
15
|
+
|
|
16
|
+
HandlerFactory = TypeVar("HandlerFactory", bound=Callable[..., type])
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def iter_candidate_ports(
|
|
20
|
+
preferred: int,
|
|
21
|
+
*,
|
|
22
|
+
max_attempts: int | None = None,
|
|
23
|
+
skip: frozenset[int] = UI_RESERVED_PORTS,
|
|
24
|
+
) -> list[int]:
|
|
25
|
+
"""Ports to try, starting at *preferred*, skipping reserved UI ports."""
|
|
26
|
+
attempts = max_attempts
|
|
27
|
+
if attempts is None:
|
|
28
|
+
attempts = int(os.environ.get("GEMCODE_WEB_PORT_SCAN", "30"))
|
|
29
|
+
ports: list[int] = []
|
|
30
|
+
port = preferred
|
|
31
|
+
while len(ports) < attempts:
|
|
32
|
+
while port in skip:
|
|
33
|
+
port += 1
|
|
34
|
+
ports.append(port)
|
|
35
|
+
port += 1
|
|
36
|
+
return ports
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def bind_http_server(
|
|
40
|
+
host: str,
|
|
41
|
+
handler_factory: HandlerFactory,
|
|
42
|
+
preferred_port: int,
|
|
43
|
+
*,
|
|
44
|
+
max_attempts: int | None = None,
|
|
45
|
+
) -> tuple[ThreadingHTTPServer, int]:
|
|
46
|
+
"""Bind ``ThreadingHTTPServer``, advancing to the next port if busy."""
|
|
47
|
+
last_err: OSError | None = None
|
|
48
|
+
for port in iter_candidate_ports(preferred_port, max_attempts=max_attempts):
|
|
49
|
+
try:
|
|
50
|
+
httpd = ThreadingHTTPServer((host, port), handler_factory)
|
|
51
|
+
return httpd, port
|
|
52
|
+
except OSError as exc:
|
|
53
|
+
if exc.errno != errno.EADDRINUSE:
|
|
54
|
+
raise
|
|
55
|
+
last_err = exc
|
|
56
|
+
raise OSError(
|
|
57
|
+
errno.EADDRINUSE,
|
|
58
|
+
f"No free port near {preferred_port} after {len(iter_candidate_ports(preferred_port, max_attempts=max_attempts))} attempts",
|
|
59
|
+
) from last_err
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def port_is_free(host: str, port: int) -> bool:
|
|
63
|
+
try:
|
|
64
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
65
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
66
|
+
sock.bind((host, port))
|
|
67
|
+
return True
|
|
68
|
+
except OSError:
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def find_available_port(
|
|
73
|
+
host: str,
|
|
74
|
+
preferred_port: int,
|
|
75
|
+
*,
|
|
76
|
+
max_attempts: int | None = None,
|
|
77
|
+
) -> int:
|
|
78
|
+
"""Return the first bindable port without starting a server."""
|
|
79
|
+
for port in iter_candidate_ports(preferred_port, max_attempts=max_attempts):
|
|
80
|
+
if port_is_free(host, port):
|
|
81
|
+
return port
|
|
82
|
+
raise OSError(
|
|
83
|
+
errno.EADDRINUSE,
|
|
84
|
+
f"No free port near {preferred_port}",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def format_connection_lines(
|
|
89
|
+
host: str,
|
|
90
|
+
actual_port: int,
|
|
91
|
+
*,
|
|
92
|
+
preferred_port: int = DEFAULT_SERVE_PORT,
|
|
93
|
+
) -> list[str]:
|
|
94
|
+
"""Human-readable instructions for API + web UI wiring."""
|
|
95
|
+
url = serve_base_url(host, actual_port)
|
|
96
|
+
lines: list[str] = []
|
|
97
|
+
if actual_port != preferred_port:
|
|
98
|
+
lines.append(
|
|
99
|
+
f"Port {preferred_port} is busy — GemCode API is on {url} instead."
|
|
100
|
+
)
|
|
101
|
+
else:
|
|
102
|
+
lines.append(f"GemCode API listening on {url}")
|
|
103
|
+
lines.append("Web UI: run `npm run dev` in gemcode-web-ui → http://localhost:3002")
|
|
104
|
+
lines.append(f"Point the UI at this API (gemcode-web-ui/.env.local):")
|
|
105
|
+
lines.append(f" NEXT_PUBLIC_API_URL={url}")
|
|
106
|
+
return lines
|
|
@@ -90,6 +90,22 @@ def is_serve_running(
|
|
|
90
90
|
|
|
91
91
|
health = probe_health(url)
|
|
92
92
|
if health and health.get("status") == "ok":
|
|
93
|
+
expected = str(project_root.expanduser().resolve())
|
|
94
|
+
raw_root = health.get("project_root") or health.get("cwd")
|
|
95
|
+
if raw_root:
|
|
96
|
+
try:
|
|
97
|
+
actual = str(Path(str(raw_root)).expanduser().resolve())
|
|
98
|
+
if actual != expected:
|
|
99
|
+
return False, {
|
|
100
|
+
"url": url,
|
|
101
|
+
"health": health,
|
|
102
|
+
"external": True,
|
|
103
|
+
"project_mismatch": True,
|
|
104
|
+
"expected_root": expected,
|
|
105
|
+
"actual_root": actual,
|
|
106
|
+
}
|
|
107
|
+
except (OSError, ValueError):
|
|
108
|
+
pass
|
|
93
109
|
return True, {"url": url, "health": health, "external": True}
|
|
94
110
|
return False, state
|
|
95
111
|
|
|
@@ -103,9 +119,19 @@ def start_background_serve(
|
|
|
103
119
|
) -> dict[str, Any]:
|
|
104
120
|
root = project_root.expanduser().resolve()
|
|
105
121
|
running, info = is_serve_running(root, host=host, port=port)
|
|
122
|
+
if info and info.get("project_mismatch"):
|
|
123
|
+
return {"ok": False, "project_mismatch": True, **info}
|
|
106
124
|
if running:
|
|
107
125
|
return {"ok": True, "already_running": True, **(info or {})}
|
|
108
126
|
|
|
127
|
+
preferred_port = port
|
|
128
|
+
try:
|
|
129
|
+
from gemcode.web.serve_bind import find_available_port
|
|
130
|
+
|
|
131
|
+
port = find_available_port(host, preferred_port)
|
|
132
|
+
except OSError as exc:
|
|
133
|
+
return {"ok": False, "error": str(exc), "preferred_port": preferred_port}
|
|
134
|
+
|
|
109
135
|
log_path = root / ".gemcode" / "web-serve.log"
|
|
110
136
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
111
137
|
log_fh = open(log_path, "a", encoding="utf-8")
|
|
@@ -147,7 +173,11 @@ def start_background_serve(
|
|
|
147
173
|
"log": str(log_path),
|
|
148
174
|
}
|
|
149
175
|
write_serve_state(root, payload)
|
|
150
|
-
|
|
176
|
+
result = {"ok": True, "started": True, **payload, "health": health}
|
|
177
|
+
if port != preferred_port:
|
|
178
|
+
result["preferred_port"] = preferred_port
|
|
179
|
+
result["port_fallback"] = True
|
|
180
|
+
return result
|
|
151
181
|
if pid_alive(proc.pid):
|
|
152
182
|
payload = {
|
|
153
183
|
"pid": proc.pid,
|
|
@@ -160,7 +190,11 @@ def start_background_serve(
|
|
|
160
190
|
"warming": True,
|
|
161
191
|
}
|
|
162
192
|
write_serve_state(root, payload)
|
|
163
|
-
|
|
193
|
+
result = {"ok": True, "started": True, **payload}
|
|
194
|
+
if port != preferred_port:
|
|
195
|
+
result["preferred_port"] = preferred_port
|
|
196
|
+
result["port_fallback"] = True
|
|
197
|
+
return result
|
|
164
198
|
return {
|
|
165
199
|
"ok": False,
|
|
166
200
|
"error": f"Server failed to start — see {log_path}",
|
|
@@ -6,16 +6,20 @@ import argparse
|
|
|
6
6
|
import json
|
|
7
7
|
import os
|
|
8
8
|
import platform
|
|
9
|
+
import queue
|
|
9
10
|
import re
|
|
10
11
|
import signal
|
|
11
12
|
import subprocess
|
|
12
13
|
import sys
|
|
13
14
|
import threading
|
|
14
|
-
|
|
15
|
+
import time
|
|
16
|
+
from http.server import BaseHTTPRequestHandler
|
|
15
17
|
from pathlib import Path
|
|
16
18
|
from typing import Any
|
|
17
19
|
from urllib.parse import parse_qs, urlparse
|
|
18
20
|
|
|
21
|
+
from gemcode.web.project_root import HostedTenantPathError
|
|
22
|
+
from gemcode.web.serve_bind import bind_http_server, format_connection_lines
|
|
19
23
|
from gemcode.web.serve_state import (
|
|
20
24
|
clear_serve_state,
|
|
21
25
|
serve_base_url,
|
|
@@ -25,6 +29,10 @@ from gemcode.web.serve_state import (
|
|
|
25
29
|
PYTHON = sys.executable
|
|
26
30
|
SERVER_VERSION = "GemCodeServe/1.0"
|
|
27
31
|
|
|
32
|
+
# SSE / long-turn limits (0 = no server-side turn cap).
|
|
33
|
+
_SSE_KEEPALIVE_S = float(os.environ.get("GEMCODE_WEB_SSE_KEEPALIVE_S", "20"))
|
|
34
|
+
_TURN_TIMEOUT_S = float(os.environ.get("GEMCODE_WEB_TURN_TIMEOUT_S", "0"))
|
|
35
|
+
|
|
28
36
|
|
|
29
37
|
def _mock_allowed() -> bool:
|
|
30
38
|
return os.environ.get("GEMCODE_WEB_ALLOW_MOCK", "").lower() in (
|
|
@@ -175,8 +183,10 @@ def _build_handler(project_root: str) -> type[BaseHTTPRequestHandler]:
|
|
|
175
183
|
except Exception:
|
|
176
184
|
pass
|
|
177
185
|
has_key = bool(os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY"))
|
|
178
|
-
|
|
179
|
-
|
|
186
|
+
from gemcode.web.project_root import hosted_tenant_root
|
|
187
|
+
|
|
188
|
+
locked = hosted_tenant_root()
|
|
189
|
+
health: dict[str, Any] = {
|
|
180
190
|
"status": "ok",
|
|
181
191
|
"service": "gemcode-serve",
|
|
182
192
|
"gemcode": True,
|
|
@@ -184,8 +194,13 @@ def _build_handler(project_root: str) -> type[BaseHTTPRequestHandler]:
|
|
|
184
194
|
"mock_mode": _mock_mode_active(),
|
|
185
195
|
"project_root": root,
|
|
186
196
|
"cwd": root,
|
|
187
|
-
|
|
188
|
-
|
|
197
|
+
"port": self.server.server_address[1],
|
|
198
|
+
"url": serve_base_url(self.server.server_address[0], self.server.server_address[1]),
|
|
199
|
+
}
|
|
200
|
+
if locked is not None:
|
|
201
|
+
health["hosted_mode"] = True
|
|
202
|
+
health["hosted_tenant_root"] = str(locked)
|
|
203
|
+
body = json.dumps(health).encode()
|
|
189
204
|
self.send_response(200)
|
|
190
205
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
191
206
|
self.send_header("Content-Length", str(len(body)))
|
|
@@ -362,6 +377,9 @@ def _build_handler(project_root: str) -> type[BaseHTTPRequestHandler]:
|
|
|
362
377
|
else:
|
|
363
378
|
raw_path = str(data.get("path") or root).strip()
|
|
364
379
|
status, payload = handler(data, raw_path)
|
|
380
|
+
except HostedTenantPathError as exc:
|
|
381
|
+
payload = {"ok": False, "error": str(exc)}
|
|
382
|
+
status = 403
|
|
365
383
|
except Exception as exc:
|
|
366
384
|
payload = {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
|
|
367
385
|
status = 500
|
|
@@ -436,18 +454,67 @@ def _build_handler(project_root: str) -> type[BaseHTTPRequestHandler]:
|
|
|
436
454
|
|
|
437
455
|
self.send_response(200)
|
|
438
456
|
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
|
439
|
-
self.send_header("Cache-Control", "no-cache")
|
|
457
|
+
self.send_header("Cache-Control", "no-cache, no-transform")
|
|
440
458
|
self.send_header("Connection", "keep-alive")
|
|
459
|
+
self.send_header("X-Accel-Buffering", "no")
|
|
441
460
|
self._cors()
|
|
442
461
|
self.end_headers()
|
|
443
462
|
|
|
463
|
+
try:
|
|
464
|
+
self.connection.settimeout(None)
|
|
465
|
+
except Exception:
|
|
466
|
+
pass
|
|
467
|
+
|
|
468
|
+
out_q: queue.Queue[bytes | None] = queue.Queue()
|
|
469
|
+
|
|
470
|
+
def _stdout_reader() -> None:
|
|
471
|
+
try:
|
|
472
|
+
while proc.stdout is not None:
|
|
473
|
+
chunk = proc.stdout.read(4096)
|
|
474
|
+
if not chunk:
|
|
475
|
+
break
|
|
476
|
+
out_q.put(chunk)
|
|
477
|
+
except Exception:
|
|
478
|
+
pass
|
|
479
|
+
finally:
|
|
480
|
+
out_q.put(None)
|
|
481
|
+
|
|
482
|
+
threading.Thread(target=_stdout_reader, daemon=True).start()
|
|
483
|
+
|
|
444
484
|
done_re = re.compile(rb'"type"\s*:\s*"done"')
|
|
445
485
|
saw_done = False
|
|
446
486
|
carry = b""
|
|
487
|
+
last_activity = time.monotonic()
|
|
488
|
+
turn_deadline = (
|
|
489
|
+
time.monotonic() + _TURN_TIMEOUT_S if _TURN_TIMEOUT_S > 0 else None
|
|
490
|
+
)
|
|
447
491
|
try:
|
|
448
492
|
while True:
|
|
449
|
-
|
|
450
|
-
|
|
493
|
+
if turn_deadline is not None and time.monotonic() > turn_deadline:
|
|
494
|
+
proc.kill()
|
|
495
|
+
if not saw_done:
|
|
496
|
+
payload = json.dumps(
|
|
497
|
+
{
|
|
498
|
+
"type": "error",
|
|
499
|
+
"error": (
|
|
500
|
+
f"Turn exceeded server limit ({int(_TURN_TIMEOUT_S)}s). "
|
|
501
|
+
"Set GEMCODE_WEB_TURN_TIMEOUT_S=0 for no cap."
|
|
502
|
+
),
|
|
503
|
+
}
|
|
504
|
+
)
|
|
505
|
+
self.wfile.write(f"data: {payload}\n\n".encode())
|
|
506
|
+
self.wfile.write(b'data: {"type": "done"}\n\n')
|
|
507
|
+
self.wfile.flush()
|
|
508
|
+
break
|
|
509
|
+
try:
|
|
510
|
+
chunk = out_q.get(timeout=5.0)
|
|
511
|
+
except queue.Empty:
|
|
512
|
+
if time.monotonic() - last_activity >= _SSE_KEEPALIVE_S:
|
|
513
|
+
self.wfile.write(b": keepalive\n\n")
|
|
514
|
+
self.wfile.flush()
|
|
515
|
+
last_activity = time.monotonic()
|
|
516
|
+
continue
|
|
517
|
+
if chunk is None:
|
|
451
518
|
break
|
|
452
519
|
window = carry + chunk
|
|
453
520
|
if not saw_done and done_re.search(window):
|
|
@@ -455,11 +522,15 @@ def _build_handler(project_root: str) -> type[BaseHTTPRequestHandler]:
|
|
|
455
522
|
carry = chunk[-64:]
|
|
456
523
|
self.wfile.write(chunk)
|
|
457
524
|
self.wfile.flush()
|
|
525
|
+
last_activity = time.monotonic()
|
|
458
526
|
except (BrokenPipeError, ConnectionResetError):
|
|
459
527
|
proc.kill()
|
|
460
528
|
finally:
|
|
461
529
|
try:
|
|
462
|
-
|
|
530
|
+
if _TURN_TIMEOUT_S > 0:
|
|
531
|
+
proc.wait(timeout=max(30.0, _TURN_TIMEOUT_S))
|
|
532
|
+
else:
|
|
533
|
+
proc.wait()
|
|
463
534
|
except subprocess.TimeoutExpired:
|
|
464
535
|
proc.kill()
|
|
465
536
|
proc.wait(timeout=5)
|
|
@@ -548,7 +619,8 @@ def run_server(
|
|
|
548
619
|
print(f"[serve] Mesh scheduler bootstrap failed: {exc}", flush=True)
|
|
549
620
|
|
|
550
621
|
handler = _build_handler(str(root))
|
|
551
|
-
|
|
622
|
+
preferred_port = bind_port
|
|
623
|
+
httpd, bind_port = bind_http_server(host, handler, preferred_port)
|
|
552
624
|
url = serve_base_url(host, bind_port)
|
|
553
625
|
|
|
554
626
|
def _shutdown(*_args: Any) -> None:
|
|
@@ -578,7 +650,12 @@ def run_server(
|
|
|
578
650
|
print(f"Project root: {root}", flush=True)
|
|
579
651
|
if session_id:
|
|
580
652
|
print(f"CLI session id: {session_id}", flush=True)
|
|
581
|
-
|
|
653
|
+
for line in format_connection_lines(host, bind_port, preferred_port=preferred_port):
|
|
654
|
+
print(line, flush=True)
|
|
655
|
+
if _TURN_TIMEOUT_S <= 0:
|
|
656
|
+
print("[serve] Long turns: no server-side turn timeout (GEMCODE_WEB_TURN_TIMEOUT_S=0)", flush=True)
|
|
657
|
+
else:
|
|
658
|
+
print(f"[serve] Long turns: server cap {int(_TURN_TIMEOUT_S)}s (set GEMCODE_WEB_TURN_TIMEOUT_S=0 to disable)", flush=True)
|
|
582
659
|
if _mock_mode_active():
|
|
583
660
|
print("[serve] WARNING: mock mode active (GEMCODE_WEB_ALLOW_MOCK=1)", flush=True)
|
|
584
661
|
else:
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import uuid
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
from typing import Any
|
|
7
8
|
|
|
@@ -25,22 +26,28 @@ def handle_sessions_post(project_root: str, data: dict[str, Any]) -> tuple[int,
|
|
|
25
26
|
return 400, {"ok": False, "error": "Invalid project root"}
|
|
26
27
|
|
|
27
28
|
action = str(data.get("action") or "").strip().lower()
|
|
28
|
-
session_id = str(data.get("session_id") or "").strip()
|
|
29
|
-
if not session_id:
|
|
30
|
-
return 400, {"ok": False, "error": "session_id is required"}
|
|
31
29
|
|
|
32
30
|
try:
|
|
33
31
|
from gemcode.session_store import name_session, touch_session
|
|
34
32
|
|
|
33
|
+
if action == "new":
|
|
34
|
+
session_id = str(data.get("session_id") or "").strip() or uuid.uuid4().hex
|
|
35
|
+
touch_session(root, session_id)
|
|
36
|
+
return 200, {"ok": True, "session_id": session_id, "action": "new"}
|
|
37
|
+
|
|
38
|
+
session_id = str(data.get("session_id") or "").strip()
|
|
39
|
+
if not session_id:
|
|
40
|
+
return 400, {"ok": False, "error": "session_id is required"}
|
|
41
|
+
|
|
35
42
|
if action in ("name", "rename"):
|
|
36
43
|
name = str(data.get("name") or "").strip()
|
|
37
44
|
if not name:
|
|
38
45
|
return 400, {"ok": False, "error": "name is required"}
|
|
39
46
|
name_session(root, session_id, name)
|
|
40
47
|
return 200, {"ok": True, "session_id": session_id, "name": name}
|
|
41
|
-
if action
|
|
48
|
+
if action in ("touch", "resume"):
|
|
42
49
|
touch_session(root, session_id)
|
|
43
|
-
return 200, {"ok": True, "session_id": session_id}
|
|
50
|
+
return 200, {"ok": True, "session_id": session_id, "action": action}
|
|
44
51
|
return 400, {"ok": False, "error": f"Unknown action: {action}"}
|
|
45
52
|
except Exception as exc:
|
|
46
53
|
return 500, {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
|