gemcode 0.4.20__tar.gz → 0.4.22__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.20/src/gemcode.egg-info → gemcode-0.4.22}/PKG-INFO +2 -1
- {gemcode-0.4.20 → gemcode-0.4.22}/README.md +1 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/pyproject.toml +1 -1
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/repl_commands.py +3 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/repl_slash.py +61 -0
- gemcode-0.4.22/src/gemcode/web/files_api.py +280 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/web/hitl_bridge.py +8 -4
- gemcode-0.4.22/src/gemcode/web/project_root.py +60 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/web/server.py +49 -4
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/web/sse_adapter.py +10 -7
- {gemcode-0.4.20 → gemcode-0.4.22/src/gemcode.egg-info}/PKG-INFO +2 -1
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode.egg-info/SOURCES.txt +3 -0
- gemcode-0.4.22/tests/test_files_api.py +54 -0
- gemcode-0.4.22/tests/test_hosted_tenant.py +52 -0
- gemcode-0.4.20/src/gemcode/web/project_root.py +0 -21
- {gemcode-0.4.20 → gemcode-0.4.22}/LICENSE +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/MANIFEST.in +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/setup.cfg +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/__init__.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/__main__.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/a2a_bridge.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/agent.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/agent_habits.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/agent_intelligence.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/agent_mesh.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/agent_triggers.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/audit.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/autocompact.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/automations.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/autotune.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/callbacks.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/capability_routing.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/checkpoints.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/cli.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/codebase_awareness.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/compaction.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/computer_use/__init__.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/computer_use/browser_computer.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/config.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/context_budget.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/context_warning.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/credentials.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/curated_memory.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/delegation_learning.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/dynamic_policy.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/evals/harness.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/event_bus.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/fleet_reports.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/hitl_session.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/hooks.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/ide_protocol.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/ide_stdio.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/intent_classifier.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/interactions.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/invoke.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/kaira_client.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/kaira_daemon.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/kaira_ipc.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/kaira_job_store.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/learning.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/limits.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/live_audio_engine.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/logging_config.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/mcp_loader.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/memory/__init__.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/memory/embedding_memory_service.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/memory/file_memory_service.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/modality_tools.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/model_errors.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/model_routing.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/multimodal_input.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/openapi_loader.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/org.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/output_styles.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/paths.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/permissions.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/plugins/__init__.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/plugins/terminal_hooks_plugin.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/plugins/tool_recovery_plugin.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/policy_profile.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/pricing.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/prompt_suggestions.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/query/__init__.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/query/config.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/query/deps.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/query/engine.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/query/stop_hooks.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/query/token_budget.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/query/transitions.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/query_sanitizer.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/refine.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/review_agent.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/rules.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/self_healing.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/session_runtime.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/session_store.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/session_summariser.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/skills.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/slash_commands.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/thinking.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tool_prompt_manifest.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tool_registry.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tool_result_store.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tool_synthesis.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/__init__.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/automations_tools.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/bash.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/browser.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/compress_memory.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/curated_memory.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/edit.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/filesystem.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/notebook.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/notes.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/org_tools.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/repo_map.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/search.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/shell.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/shell_gate.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/skills.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/subtask.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/tasks.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/think.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/todo.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/user_choice.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/veomem_tools.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/web.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/web_search.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools_inspector.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/trust.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tui/input_handler.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tui/scrollback.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tui/spinner.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tui/welcome_banner.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tui/welcome_rich.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/veomem_bridge.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/version.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/vertex.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/wal.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/web/__init__.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/web/chat_skills.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/web/customize_api.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/web/fleet_api.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/web/preview_api.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/web/runtime_api.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/web/serve_bind.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/web/serve_state.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/web/sessions_api.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/web/terminal_api.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/web/terminal_repl.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/web/web_config_api.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/web/web_sse_compat.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/web/workspace_panel_api.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/workspace_hints.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode.egg-info/dependency_links.txt +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode.egg-info/entry_points.txt +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode.egg-info/requires.txt +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode.egg-info/top_level.txt +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_add_dir.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_agent_habits.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_agent_instruction.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_agent_mesh.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_autocompact.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_automations.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_capability_routing.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_checkpoint_diff_command.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_cli_init.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_compress_memory_tool.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_computer_use_permissions.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_context_budget.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_context_warning.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_credentials.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_eval_harness_layout.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_event_bus.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_fleet_reports.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_ide_stdio_attachments.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_interactive_permission_ask.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_kaira_ipc_paths.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_kaira_scheduler.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_modality_tools.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_model_error_retry.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_model_errors.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_model_routing.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_multimodal_input.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_output_styles_and_rules.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_paths.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_permissions.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_prompt_suggestions.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_repl_commands.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_repl_slash.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_session_runtime_cache.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_skills.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_slash_commands.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_slash_completion_registry.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_thinking_config.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_token_budget.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_tool_context_circulation.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_tools.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_tools_inspector.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_web_server.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_web_sse_adapter.py +0 -0
- {gemcode-0.4.20 → gemcode-0.4.22}/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.22
|
|
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,8 @@ 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"),
|
|
255
|
+
("workspace", "Workspace root + hosted file API routes"),
|
|
254
256
|
("bus", "Runtime bus — send/publish lightweight messages over IPC"),
|
|
255
257
|
("inbox", "Bus inbox filters for this UI (to/topics)"),
|
|
256
258
|
("fleet", "Fleet inbox — /fleet show | digest (habits / mesh reports)"),
|
|
@@ -413,6 +415,7 @@ def slash_help_lines() -> list[str]:
|
|
|
413
415
|
" /live-audio How to run gemcode live-audio (mic → Gemini Live)",
|
|
414
416
|
" /doctor Environment sanity check",
|
|
415
417
|
" /serve [start|status|stop|url] Start HTTP API for web UI (gemcode serve on :3001)",
|
|
418
|
+
" /hosted [status] Hosted tenant lock (GEMCODE_HOSTED_TENANT_ROOT)",
|
|
416
419
|
" /version Print GemCode version hint",
|
|
417
420
|
" /exit Exit the REPL",
|
|
418
421
|
"",
|
|
@@ -2650,6 +2650,67 @@ async def process_repl_slash(
|
|
|
2650
2650
|
out()
|
|
2651
2651
|
return ReplSlashResult(skip_model_turn=True)
|
|
2652
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.")
|
|
2683
|
+
out()
|
|
2684
|
+
return ReplSlashResult(skip_model_turn=True)
|
|
2685
|
+
|
|
2686
|
+
# ── /workspace (hosted tenant + file API) ───────────────────────────────
|
|
2687
|
+
if name == "workspace":
|
|
2688
|
+
from gemcode.web.project_root import hosted_tenant_root
|
|
2689
|
+
|
|
2690
|
+
args_w = (sc.args or "").strip().lower()
|
|
2691
|
+
if args_w in ("help", "?"):
|
|
2692
|
+
out("Workspace / hosted tenant:")
|
|
2693
|
+
out(" /workspace Show active workspace root and hosted lock")
|
|
2694
|
+
out(" /workspace files File API routes on gemcode serve")
|
|
2695
|
+
out("")
|
|
2696
|
+
out("Hosted tenants use /api/files, /api/files/read, /api/files/write on gemcode serve.")
|
|
2697
|
+
out("See docs/hosted.md")
|
|
2698
|
+
out()
|
|
2699
|
+
return ReplSlashResult(skip_model_turn=True)
|
|
2700
|
+
|
|
2701
|
+
locked = hosted_tenant_root()
|
|
2702
|
+
out(f" project_root: {cfg.project_root.resolve()}")
|
|
2703
|
+
if locked is not None:
|
|
2704
|
+
out(f" hosted lock: {locked}")
|
|
2705
|
+
else:
|
|
2706
|
+
out(" hosted lock: (not set — local mode)")
|
|
2707
|
+
if args_w == "files":
|
|
2708
|
+
out(" GET /api/files — file tree")
|
|
2709
|
+
out(" GET /api/files/read — read file")
|
|
2710
|
+
out(" POST /api/files/write — write file")
|
|
2711
|
+
out()
|
|
2712
|
+
return ReplSlashResult(skip_model_turn=True)
|
|
2713
|
+
|
|
2653
2714
|
# ── /runtime ────────────────────────────────────────────────────────────
|
|
2654
2715
|
if name == "runtime":
|
|
2655
2716
|
from gemcode.kaira_ipc import fleet_manager_ipc_path_for_workspace
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Workspace file browser API for web UIs (local and hosted tenants)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import mimetypes
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from gemcode.web.project_root import HostedTenantPathError, resolve_web_project_root
|
|
14
|
+
|
|
15
|
+
ALWAYS_HIDDEN = {
|
|
16
|
+
".git",
|
|
17
|
+
"node_modules",
|
|
18
|
+
".next",
|
|
19
|
+
".turbo",
|
|
20
|
+
".cache",
|
|
21
|
+
"__pycache__",
|
|
22
|
+
".DS_Store",
|
|
23
|
+
"Thumbs.db",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
MAX_READ_BYTES = 10 * 1024 * 1024
|
|
27
|
+
MAX_WRITE_BYTES = 5 * 1024 * 1024
|
|
28
|
+
MAX_TREE_DEPTH = 8
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _resolve_root(raw_path: str | None) -> Path:
|
|
32
|
+
return resolve_web_project_root(raw_path)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _resolve_under_root(root: Path, relative: str) -> Path:
|
|
36
|
+
rel = (relative or "").strip().lstrip("/")
|
|
37
|
+
candidate = (root / rel).resolve()
|
|
38
|
+
try:
|
|
39
|
+
candidate.relative_to(root.resolve())
|
|
40
|
+
except ValueError as exc:
|
|
41
|
+
raise HostedTenantPathError(f"path is outside workspace: {candidate}") from exc
|
|
42
|
+
return candidate
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _git_status_map(root: Path) -> dict[str, str]:
|
|
46
|
+
try:
|
|
47
|
+
proc = subprocess.run(
|
|
48
|
+
["git", "status", "--porcelain", "-u"],
|
|
49
|
+
cwd=str(root),
|
|
50
|
+
capture_output=True,
|
|
51
|
+
text=True,
|
|
52
|
+
timeout=5,
|
|
53
|
+
check=False,
|
|
54
|
+
)
|
|
55
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
56
|
+
return {}
|
|
57
|
+
out: dict[str, str] = {}
|
|
58
|
+
for line in (proc.stdout or "").splitlines():
|
|
59
|
+
if not line:
|
|
60
|
+
continue
|
|
61
|
+
xy = line[:2].strip()
|
|
62
|
+
file_path = line[3:].strip().strip('"')
|
|
63
|
+
if not xy or not file_path:
|
|
64
|
+
continue
|
|
65
|
+
status = xy[0] if xy[0] not in (" ", "?") else xy[1] if xy[1] != " " else "?"
|
|
66
|
+
out[file_path] = "?" if status == "?" else status
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _ignore_patterns(root: Path) -> list[str]:
|
|
71
|
+
p = root / ".gitignore"
|
|
72
|
+
if not p.is_file():
|
|
73
|
+
return []
|
|
74
|
+
try:
|
|
75
|
+
lines = p.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
76
|
+
except OSError:
|
|
77
|
+
return []
|
|
78
|
+
return [ln.strip() for ln in lines if ln.strip() and not ln.strip().startswith("#")]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _matches_ignore(name: str, patterns: list[str]) -> bool:
|
|
82
|
+
for pattern in patterns:
|
|
83
|
+
p = pattern[1:] if pattern.startswith("/") else pattern
|
|
84
|
+
if p == name:
|
|
85
|
+
return True
|
|
86
|
+
if p.endswith("/") and p[:-1] == name:
|
|
87
|
+
return True
|
|
88
|
+
if "*" in p:
|
|
89
|
+
import re
|
|
90
|
+
|
|
91
|
+
rx = "^" + re.escape(p).replace(r"\*", "[^/]*") + "$"
|
|
92
|
+
if re.match(rx, name):
|
|
93
|
+
return True
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _build_tree(
|
|
98
|
+
dir_path: Path,
|
|
99
|
+
root: Path,
|
|
100
|
+
*,
|
|
101
|
+
git_map: dict[str, str],
|
|
102
|
+
ignore_patterns: list[str],
|
|
103
|
+
show_ignored: bool,
|
|
104
|
+
depth: int = 0,
|
|
105
|
+
) -> list[dict[str, Any]]:
|
|
106
|
+
if depth > MAX_TREE_DEPTH:
|
|
107
|
+
return []
|
|
108
|
+
try:
|
|
109
|
+
entries = sorted(dir_path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
|
|
110
|
+
except OSError:
|
|
111
|
+
return []
|
|
112
|
+
nodes: list[dict[str, Any]] = []
|
|
113
|
+
for entry in entries:
|
|
114
|
+
name = entry.name
|
|
115
|
+
if name in ALWAYS_HIDDEN:
|
|
116
|
+
continue
|
|
117
|
+
if not show_ignored and _matches_ignore(name, ignore_patterns):
|
|
118
|
+
continue
|
|
119
|
+
if not show_ignored and name.startswith(".") and name != ".env.example":
|
|
120
|
+
continue
|
|
121
|
+
rel = entry.relative_to(root).as_posix()
|
|
122
|
+
git_status = git_map.get(rel)
|
|
123
|
+
if entry.is_dir():
|
|
124
|
+
children = _build_tree(
|
|
125
|
+
entry,
|
|
126
|
+
root,
|
|
127
|
+
git_map=git_map,
|
|
128
|
+
ignore_patterns=ignore_patterns,
|
|
129
|
+
show_ignored=show_ignored,
|
|
130
|
+
depth=depth + 1,
|
|
131
|
+
)
|
|
132
|
+
nodes.append(
|
|
133
|
+
{
|
|
134
|
+
"name": name,
|
|
135
|
+
"path": rel,
|
|
136
|
+
"type": "directory",
|
|
137
|
+
"children": children,
|
|
138
|
+
"gitStatus": git_status,
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
elif entry.is_file():
|
|
142
|
+
nodes.append(
|
|
143
|
+
{
|
|
144
|
+
"name": name,
|
|
145
|
+
"path": rel,
|
|
146
|
+
"type": "file",
|
|
147
|
+
"gitStatus": git_status,
|
|
148
|
+
}
|
|
149
|
+
)
|
|
150
|
+
return nodes
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def handle_files_tree_get(
|
|
154
|
+
raw_path: str | None,
|
|
155
|
+
*,
|
|
156
|
+
cwd: str = "",
|
|
157
|
+
show_ignored: bool = False,
|
|
158
|
+
) -> tuple[int, dict[str, Any]]:
|
|
159
|
+
try:
|
|
160
|
+
root = _resolve_root(raw_path)
|
|
161
|
+
except HostedTenantPathError as exc:
|
|
162
|
+
return 403, {"error": str(exc)}
|
|
163
|
+
if not root.is_dir():
|
|
164
|
+
return 400, {"error": "workspace is not a directory"}
|
|
165
|
+
try:
|
|
166
|
+
resolved_cwd = _resolve_under_root(root, cwd)
|
|
167
|
+
except HostedTenantPathError as exc:
|
|
168
|
+
return 403, {"error": str(exc)}
|
|
169
|
+
if not resolved_cwd.is_dir():
|
|
170
|
+
return 400, {"error": "Invalid cwd"}
|
|
171
|
+
|
|
172
|
+
git_map = _git_status_map(root)
|
|
173
|
+
ignore_patterns = _ignore_patterns(root)
|
|
174
|
+
tree = _build_tree(
|
|
175
|
+
resolved_cwd,
|
|
176
|
+
root,
|
|
177
|
+
git_map=git_map,
|
|
178
|
+
ignore_patterns=ignore_patterns,
|
|
179
|
+
show_ignored=show_ignored,
|
|
180
|
+
)
|
|
181
|
+
rel = resolved_cwd.relative_to(root)
|
|
182
|
+
segments = [] if str(rel) == "." else str(rel).split("/")
|
|
183
|
+
breadcrumbs = [root.name or str(root), *segments]
|
|
184
|
+
breadcrumb_rels = [""] + [
|
|
185
|
+
"/".join(segments[: i + 1]) for i in range(len(segments))
|
|
186
|
+
]
|
|
187
|
+
return 200, {
|
|
188
|
+
"tree": tree,
|
|
189
|
+
"root": str(root),
|
|
190
|
+
"cwd": cwd,
|
|
191
|
+
"breadcrumbs": breadcrumbs,
|
|
192
|
+
"breadcrumbRels": breadcrumb_rels,
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def handle_files_read_get(
|
|
197
|
+
raw_path: str | None,
|
|
198
|
+
file_path: str,
|
|
199
|
+
) -> tuple[int, dict[str, Any]]:
|
|
200
|
+
if not file_path.strip():
|
|
201
|
+
return 400, {"error": "path parameter required"}
|
|
202
|
+
try:
|
|
203
|
+
root = _resolve_root(raw_path)
|
|
204
|
+
resolved = _resolve_under_root(root, file_path)
|
|
205
|
+
except HostedTenantPathError as exc:
|
|
206
|
+
return 403, {"error": str(exc)}
|
|
207
|
+
if not resolved.is_file():
|
|
208
|
+
return 404 if not resolved.exists() else 400, {"error": "path is not a file"}
|
|
209
|
+
try:
|
|
210
|
+
size = resolved.stat().st_size
|
|
211
|
+
except OSError as exc:
|
|
212
|
+
return 404, {"error": str(exc)}
|
|
213
|
+
if size > MAX_READ_BYTES:
|
|
214
|
+
return 413, {"error": f"File exceeds maximum readable size ({MAX_READ_BYTES // 1024 // 1024} MB)"}
|
|
215
|
+
|
|
216
|
+
ext = resolved.suffix.lower().lstrip(".")
|
|
217
|
+
mime, _ = mimetypes.guess_type(str(resolved))
|
|
218
|
+
|
|
219
|
+
if ext in ("docx", "doc", "pdf"):
|
|
220
|
+
data = base64.b64encode(resolved.read_bytes()).decode("ascii")
|
|
221
|
+
kind = "pdf" if ext == "pdf" else "docx"
|
|
222
|
+
return 200, {
|
|
223
|
+
"content": data,
|
|
224
|
+
"isImage": False,
|
|
225
|
+
"kind": kind,
|
|
226
|
+
"mimeType": mime or "application/octet-stream",
|
|
227
|
+
"size": size,
|
|
228
|
+
"modified": resolved.stat().st_mtime,
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if ext in ("png", "jpg", "jpeg", "gif", "webp", "bmp", "ico"):
|
|
232
|
+
mime = mime or f"image/{ext}"
|
|
233
|
+
data = base64.b64encode(resolved.read_bytes()).decode("ascii")
|
|
234
|
+
return 200, {
|
|
235
|
+
"content": f"data:{mime};base64,{data}",
|
|
236
|
+
"isImage": True,
|
|
237
|
+
"size": size,
|
|
238
|
+
"modified": resolved.stat().st_mtime,
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
probe = resolved.read_bytes()[:512]
|
|
243
|
+
if b"\x00" in probe:
|
|
244
|
+
return 415, {"error": "Binary file cannot be read as text"}
|
|
245
|
+
content = resolved.read_text(encoding="utf-8")
|
|
246
|
+
except OSError as exc:
|
|
247
|
+
return 404, {"error": str(exc)}
|
|
248
|
+
except UnicodeDecodeError:
|
|
249
|
+
return 415, {"error": "Binary file cannot be read as text"}
|
|
250
|
+
|
|
251
|
+
return 200, {
|
|
252
|
+
"content": content,
|
|
253
|
+
"isImage": ext == "svg",
|
|
254
|
+
"size": size,
|
|
255
|
+
"modified": resolved.stat().st_mtime,
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def handle_files_write_post(data: dict[str, Any], raw_path: str | None) -> tuple[int, dict[str, Any]]:
|
|
260
|
+
file_path = str(data.get("path") or "").strip()
|
|
261
|
+
if not file_path:
|
|
262
|
+
return 400, {"ok": False, "error": "path is required"}
|
|
263
|
+
content = data.get("content")
|
|
264
|
+
if content is None:
|
|
265
|
+
content = ""
|
|
266
|
+
if not isinstance(content, str):
|
|
267
|
+
return 400, {"ok": False, "error": "content must be a string"}
|
|
268
|
+
if len(content.encode("utf-8")) > MAX_WRITE_BYTES:
|
|
269
|
+
return 413, {"ok": False, "error": "content too large"}
|
|
270
|
+
try:
|
|
271
|
+
root = _resolve_root(raw_path)
|
|
272
|
+
resolved = _resolve_under_root(root, file_path)
|
|
273
|
+
except HostedTenantPathError as exc:
|
|
274
|
+
return 403, {"ok": False, "error": str(exc)}
|
|
275
|
+
try:
|
|
276
|
+
resolved.parent.mkdir(parents=True, exist_ok=True)
|
|
277
|
+
resolved.write_text(content, encoding="utf-8")
|
|
278
|
+
except OSError as exc:
|
|
279
|
+
return 500, {"ok": False, "error": str(exc)}
|
|
280
|
+
return 200, {"ok": True, "path": file_path}
|
|
@@ -10,7 +10,11 @@ import uuid
|
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
from typing import Any, Callable
|
|
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"
|
|
14
18
|
_DEFAULT_TIMEOUT_S = float(os.environ.get("GEMCODE_WEB_HITL_TIMEOUT_S", "3600"))
|
|
15
19
|
_POLL_INTERVAL_S = 0.25
|
|
16
20
|
# approval_ids currently blocked in wait_for_web_approval (same process)
|
|
@@ -28,7 +32,7 @@ def register_pending_approval(approval_id: str) -> None:
|
|
|
28
32
|
|
|
29
33
|
def _approval_path(approval_id: str) -> Path:
|
|
30
34
|
safe = "".join(c if c.isalnum() or c in "-_:" else "_" for c in approval_id)
|
|
31
|
-
return
|
|
35
|
+
return _approval_dir() / f"{safe}.json"
|
|
32
36
|
|
|
33
37
|
|
|
34
38
|
def _read_approval_file(path: Path) -> bool | None:
|
|
@@ -54,7 +58,7 @@ def resolve_web_approval(approval_id: str, *, confirmed: bool) -> dict[str, Any]
|
|
|
54
58
|
"""Record the user's approve/deny decision (called from HTTP handler)."""
|
|
55
59
|
if not approval_id.strip():
|
|
56
60
|
return {"ok": False, "error": "approval_id is required"}
|
|
57
|
-
|
|
61
|
+
_approval_dir().mkdir(parents=True, exist_ok=True)
|
|
58
62
|
path = _approval_path(approval_id)
|
|
59
63
|
payload = {"confirmed": bool(confirmed), "resolved_ms": int(time.time() * 1000)}
|
|
60
64
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
@@ -75,7 +79,7 @@ async def wait_for_web_approval(
|
|
|
75
79
|
heartbeat_s: float = 15.0,
|
|
76
80
|
) -> bool:
|
|
77
81
|
"""Block until the user approves/denies or timeout (deny on timeout)."""
|
|
78
|
-
|
|
82
|
+
_approval_dir().mkdir(parents=True, exist_ok=True)
|
|
79
83
|
path = _approval_path(approval_id)
|
|
80
84
|
|
|
81
85
|
# User may have approved before we started waiting (preflight UI is immediate).
|
|
@@ -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)
|
|
@@ -18,6 +18,7 @@ from pathlib import Path
|
|
|
18
18
|
from typing import Any
|
|
19
19
|
from urllib.parse import parse_qs, urlparse
|
|
20
20
|
|
|
21
|
+
from gemcode.web.project_root import HostedTenantPathError
|
|
21
22
|
from gemcode.web.serve_bind import bind_http_server, format_connection_lines
|
|
22
23
|
from gemcode.web.serve_state import (
|
|
23
24
|
clear_serve_state,
|
|
@@ -182,8 +183,10 @@ def _build_handler(project_root: str) -> type[BaseHTTPRequestHandler]:
|
|
|
182
183
|
except Exception:
|
|
183
184
|
pass
|
|
184
185
|
has_key = bool(os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY"))
|
|
185
|
-
|
|
186
|
-
|
|
186
|
+
from gemcode.web.project_root import hosted_tenant_root
|
|
187
|
+
|
|
188
|
+
locked = hosted_tenant_root()
|
|
189
|
+
health: dict[str, Any] = {
|
|
187
190
|
"status": "ok",
|
|
188
191
|
"service": "gemcode-serve",
|
|
189
192
|
"gemcode": True,
|
|
@@ -193,8 +196,11 @@ def _build_handler(project_root: str) -> type[BaseHTTPRequestHandler]:
|
|
|
193
196
|
"cwd": root,
|
|
194
197
|
"port": self.server.server_address[1],
|
|
195
198
|
"url": serve_base_url(self.server.server_address[0], self.server.server_address[1]),
|
|
196
|
-
|
|
197
|
-
|
|
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()
|
|
198
204
|
self.send_response(200)
|
|
199
205
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
200
206
|
self.send_header("Content-Length", str(len(body)))
|
|
@@ -244,6 +250,38 @@ def _build_handler(project_root: str) -> type[BaseHTTPRequestHandler]:
|
|
|
244
250
|
return
|
|
245
251
|
|
|
246
252
|
parsed = urlparse(self.path)
|
|
253
|
+
if parsed.path in ("/api/files", "/api/files/read"):
|
|
254
|
+
params = parse_qs(parsed.query)
|
|
255
|
+
raw_path = (params.get("path") or [root])[0]
|
|
256
|
+
try:
|
|
257
|
+
from gemcode.web.files_api import handle_files_read_get, handle_files_tree_get
|
|
258
|
+
|
|
259
|
+
if parsed.path == "/api/files/read":
|
|
260
|
+
file_path = (params.get("path") or [""])[0]
|
|
261
|
+
ws = (params.get("root") or [root])[0]
|
|
262
|
+
status, payload = handle_files_read_get(ws, file_path)
|
|
263
|
+
else:
|
|
264
|
+
cwd = (params.get("cwd") or [""])[0]
|
|
265
|
+
show_ignored = (params.get("showIgnored") or ["false"])[0].lower() == "true"
|
|
266
|
+
ws = (params.get("root") or [root])[0]
|
|
267
|
+
status, payload = handle_files_tree_get(
|
|
268
|
+
ws,
|
|
269
|
+
cwd=cwd,
|
|
270
|
+
show_ignored=show_ignored,
|
|
271
|
+
)
|
|
272
|
+
except HostedTenantPathError as exc:
|
|
273
|
+
status, payload = 403, {"error": str(exc)}
|
|
274
|
+
except Exception as exc:
|
|
275
|
+
status, payload = 500, {"error": f"{type(exc).__name__}: {exc}"}
|
|
276
|
+
body = json.dumps(payload, default=str).encode()
|
|
277
|
+
self.send_response(status)
|
|
278
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
279
|
+
self.send_header("Content-Length", str(len(body)))
|
|
280
|
+
self._cors()
|
|
281
|
+
self.end_headers()
|
|
282
|
+
self.wfile.write(body)
|
|
283
|
+
return
|
|
284
|
+
|
|
247
285
|
fleet_get = {
|
|
248
286
|
"/api/org": "org",
|
|
249
287
|
"/api/habits": "habits",
|
|
@@ -347,6 +385,7 @@ def _build_handler(project_root: str) -> type[BaseHTTPRequestHandler]:
|
|
|
347
385
|
"/api/settings/credentials": ("gemcode.web.customize_api", "handle_credentials_post"),
|
|
348
386
|
"/api/runtime": ("gemcode.web.runtime_api", "handle_runtime_post"),
|
|
349
387
|
"/api/terminal": ("gemcode.web.terminal_api", "handle_terminal_post"),
|
|
388
|
+
"/api/files/write": ("gemcode.web.files_api", "handle_files_write_post"),
|
|
350
389
|
}
|
|
351
390
|
|
|
352
391
|
if self.path in post_routes:
|
|
@@ -368,9 +407,15 @@ def _build_handler(project_root: str) -> type[BaseHTTPRequestHandler]:
|
|
|
368
407
|
elif self.path == "/api/sessions":
|
|
369
408
|
raw_path = str(data.get("path") or root).strip()
|
|
370
409
|
status, payload = handler(raw_path, data)
|
|
410
|
+
elif self.path == "/api/files/write":
|
|
411
|
+
raw_path = str(data.get("root") or data.get("path") or root).strip()
|
|
412
|
+
status, payload = handler(data, raw_path)
|
|
371
413
|
else:
|
|
372
414
|
raw_path = str(data.get("path") or root).strip()
|
|
373
415
|
status, payload = handler(data, raw_path)
|
|
416
|
+
except HostedTenantPathError as exc:
|
|
417
|
+
payload = {"ok": False, "error": str(exc)}
|
|
418
|
+
status = 403
|
|
374
419
|
except Exception as exc:
|
|
375
420
|
payload = {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
|
|
376
421
|
status = 500
|
|
@@ -1048,7 +1048,7 @@ def _apply_web_request_options(cfg: GemCodeConfig, req: dict[str, Any], workspac
|
|
|
1048
1048
|
async def run_adapter(req: dict[str, Any]) -> None:
|
|
1049
1049
|
messages = req.get("messages")
|
|
1050
1050
|
requested_model = req.get("model")
|
|
1051
|
-
model = requested_model or os.environ.get("GEMCODE_MODEL") or "gemini-
|
|
1051
|
+
model = requested_model or os.environ.get("GEMCODE_MODEL") or "gemini-3.1-pro-preview"
|
|
1052
1052
|
|
|
1053
1053
|
if not isinstance(messages, list):
|
|
1054
1054
|
raise ValueError("messages must be a list")
|
|
@@ -1061,14 +1061,17 @@ async def run_adapter(req: dict[str, Any]) -> None:
|
|
|
1061
1061
|
if not prompt.strip():
|
|
1062
1062
|
raise ValueError("No user message in request")
|
|
1063
1063
|
|
|
1064
|
+
from gemcode.web.project_root import HostedTenantPathError, resolve_sse_project_root
|
|
1065
|
+
|
|
1064
1066
|
req_root = req.get("project_root")
|
|
1065
|
-
if isinstance(req_root, str) and req_root.strip()
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1067
|
+
raw_root = req_root.strip() if isinstance(req_root, str) and req_root.strip() else None
|
|
1068
|
+
try:
|
|
1069
|
+
root_path = resolve_sse_project_root(raw_root)
|
|
1070
|
+
except HostedTenantPathError as exc:
|
|
1071
|
+
raise ValueError(str(exc)) from exc
|
|
1070
1072
|
if not root_path.is_dir():
|
|
1071
|
-
raise ValueError(f"project_root is not a directory: {
|
|
1073
|
+
raise ValueError(f"project_root is not a directory: {root_path}")
|
|
1074
|
+
project_root = str(root_path)
|
|
1072
1075
|
from gemcode.org import resolve_fleet_root
|
|
1073
1076
|
|
|
1074
1077
|
fleet_root = resolve_fleet_root(root_path.resolve())
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gemcode
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.22
|
|
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
|
|
|
@@ -135,6 +135,7 @@ src/gemcode/tui/welcome_rich.py
|
|
|
135
135
|
src/gemcode/web/__init__.py
|
|
136
136
|
src/gemcode/web/chat_skills.py
|
|
137
137
|
src/gemcode/web/customize_api.py
|
|
138
|
+
src/gemcode/web/files_api.py
|
|
138
139
|
src/gemcode/web/fleet_api.py
|
|
139
140
|
src/gemcode/web/hitl_bridge.py
|
|
140
141
|
src/gemcode/web/preview_api.py
|
|
@@ -166,7 +167,9 @@ tests/test_context_warning.py
|
|
|
166
167
|
tests/test_credentials.py
|
|
167
168
|
tests/test_eval_harness_layout.py
|
|
168
169
|
tests/test_event_bus.py
|
|
170
|
+
tests/test_files_api.py
|
|
169
171
|
tests/test_fleet_reports.py
|
|
172
|
+
tests/test_hosted_tenant.py
|
|
170
173
|
tests/test_ide_stdio_attachments.py
|
|
171
174
|
tests/test_interactive_permission_ask.py
|
|
172
175
|
tests/test_kaira_ipc_paths.py
|