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.
Files changed (202) hide show
  1. {gemcode-0.4.20/src/gemcode.egg-info → gemcode-0.4.22}/PKG-INFO +2 -1
  2. {gemcode-0.4.20 → gemcode-0.4.22}/README.md +1 -0
  3. {gemcode-0.4.20 → gemcode-0.4.22}/pyproject.toml +1 -1
  4. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/repl_commands.py +3 -0
  5. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/repl_slash.py +61 -0
  6. gemcode-0.4.22/src/gemcode/web/files_api.py +280 -0
  7. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/web/hitl_bridge.py +8 -4
  8. gemcode-0.4.22/src/gemcode/web/project_root.py +60 -0
  9. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/web/server.py +49 -4
  10. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/web/sse_adapter.py +10 -7
  11. {gemcode-0.4.20 → gemcode-0.4.22/src/gemcode.egg-info}/PKG-INFO +2 -1
  12. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode.egg-info/SOURCES.txt +3 -0
  13. gemcode-0.4.22/tests/test_files_api.py +54 -0
  14. gemcode-0.4.22/tests/test_hosted_tenant.py +52 -0
  15. gemcode-0.4.20/src/gemcode/web/project_root.py +0 -21
  16. {gemcode-0.4.20 → gemcode-0.4.22}/LICENSE +0 -0
  17. {gemcode-0.4.20 → gemcode-0.4.22}/MANIFEST.in +0 -0
  18. {gemcode-0.4.20 → gemcode-0.4.22}/setup.cfg +0 -0
  19. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/__init__.py +0 -0
  20. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/__main__.py +0 -0
  21. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/a2a_bridge.py +0 -0
  22. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/agent.py +0 -0
  23. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/agent_habits.py +0 -0
  24. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/agent_intelligence.py +0 -0
  25. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/agent_mesh.py +0 -0
  26. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/agent_triggers.py +0 -0
  27. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/audit.py +0 -0
  28. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/autocompact.py +0 -0
  29. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/automations.py +0 -0
  30. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/autotune.py +0 -0
  31. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/callbacks.py +0 -0
  32. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/capability_routing.py +0 -0
  33. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/checkpoints.py +0 -0
  34. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/cli.py +0 -0
  35. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/codebase_awareness.py +0 -0
  36. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/compaction.py +0 -0
  37. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/computer_use/__init__.py +0 -0
  38. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/computer_use/browser_computer.py +0 -0
  39. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/config.py +0 -0
  40. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/context_budget.py +0 -0
  41. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/context_warning.py +0 -0
  42. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/credentials.py +0 -0
  43. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/curated_memory.py +0 -0
  44. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/delegation_learning.py +0 -0
  45. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/dynamic_policy.py +0 -0
  46. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/evals/harness.py +0 -0
  47. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/event_bus.py +0 -0
  48. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/fleet_reports.py +0 -0
  49. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/hitl_session.py +0 -0
  50. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/hooks.py +0 -0
  51. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/ide_protocol.py +0 -0
  52. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/ide_stdio.py +0 -0
  53. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/intent_classifier.py +0 -0
  54. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/interactions.py +0 -0
  55. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/invoke.py +0 -0
  56. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/kaira_client.py +0 -0
  57. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/kaira_daemon.py +0 -0
  58. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/kaira_ipc.py +0 -0
  59. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/kaira_job_store.py +0 -0
  60. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/learning.py +0 -0
  61. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/limits.py +0 -0
  62. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/live_audio_engine.py +0 -0
  63. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/logging_config.py +0 -0
  64. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/mcp_loader.py +0 -0
  65. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/memory/__init__.py +0 -0
  66. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/memory/embedding_memory_service.py +0 -0
  67. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/memory/file_memory_service.py +0 -0
  68. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/modality_tools.py +0 -0
  69. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/model_errors.py +0 -0
  70. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/model_routing.py +0 -0
  71. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/multimodal_input.py +0 -0
  72. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/openapi_loader.py +0 -0
  73. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/org.py +0 -0
  74. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/output_styles.py +0 -0
  75. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/paths.py +0 -0
  76. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/permissions.py +0 -0
  77. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/plugins/__init__.py +0 -0
  78. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/plugins/terminal_hooks_plugin.py +0 -0
  79. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/plugins/tool_recovery_plugin.py +0 -0
  80. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/policy_profile.py +0 -0
  81. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/pricing.py +0 -0
  82. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/prompt_suggestions.py +0 -0
  83. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/query/__init__.py +0 -0
  84. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/query/config.py +0 -0
  85. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/query/deps.py +0 -0
  86. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/query/engine.py +0 -0
  87. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/query/stop_hooks.py +0 -0
  88. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/query/token_budget.py +0 -0
  89. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/query/transitions.py +0 -0
  90. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/query_sanitizer.py +0 -0
  91. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/refine.py +0 -0
  92. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/review_agent.py +0 -0
  93. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/rules.py +0 -0
  94. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/self_healing.py +0 -0
  95. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/session_runtime.py +0 -0
  96. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/session_store.py +0 -0
  97. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/session_summariser.py +0 -0
  98. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/skills.py +0 -0
  99. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/slash_commands.py +0 -0
  100. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/thinking.py +0 -0
  101. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tool_prompt_manifest.py +0 -0
  102. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tool_registry.py +0 -0
  103. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tool_result_store.py +0 -0
  104. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tool_synthesis.py +0 -0
  105. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/__init__.py +0 -0
  106. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/automations_tools.py +0 -0
  107. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/bash.py +0 -0
  108. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/browser.py +0 -0
  109. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/compress_memory.py +0 -0
  110. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/curated_memory.py +0 -0
  111. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/edit.py +0 -0
  112. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/filesystem.py +0 -0
  113. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/notebook.py +0 -0
  114. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/notes.py +0 -0
  115. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/org_tools.py +0 -0
  116. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/repo_map.py +0 -0
  117. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/search.py +0 -0
  118. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/shell.py +0 -0
  119. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/shell_gate.py +0 -0
  120. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/skills.py +0 -0
  121. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/subtask.py +0 -0
  122. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/tasks.py +0 -0
  123. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/think.py +0 -0
  124. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/todo.py +0 -0
  125. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/user_choice.py +0 -0
  126. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/veomem_tools.py +0 -0
  127. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/web.py +0 -0
  128. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools/web_search.py +0 -0
  129. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tools_inspector.py +0 -0
  130. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/trust.py +0 -0
  131. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tui/input_handler.py +0 -0
  132. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tui/scrollback.py +0 -0
  133. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tui/spinner.py +0 -0
  134. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tui/welcome_banner.py +0 -0
  135. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/tui/welcome_rich.py +0 -0
  136. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/veomem_bridge.py +0 -0
  137. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/version.py +0 -0
  138. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/vertex.py +0 -0
  139. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/wal.py +0 -0
  140. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/web/__init__.py +0 -0
  141. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/web/chat_skills.py +0 -0
  142. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/web/customize_api.py +0 -0
  143. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/web/fleet_api.py +0 -0
  144. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/web/preview_api.py +0 -0
  145. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/web/runtime_api.py +0 -0
  146. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/web/serve_bind.py +0 -0
  147. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/web/serve_state.py +0 -0
  148. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/web/sessions_api.py +0 -0
  149. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/web/terminal_api.py +0 -0
  150. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/web/terminal_repl.py +0 -0
  151. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/web/web_config_api.py +0 -0
  152. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/web/web_sse_compat.py +0 -0
  153. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/web/workspace_panel_api.py +0 -0
  154. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode/workspace_hints.py +0 -0
  155. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode.egg-info/dependency_links.txt +0 -0
  156. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode.egg-info/entry_points.txt +0 -0
  157. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode.egg-info/requires.txt +0 -0
  158. {gemcode-0.4.20 → gemcode-0.4.22}/src/gemcode.egg-info/top_level.txt +0 -0
  159. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_add_dir.py +0 -0
  160. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_agent_habits.py +0 -0
  161. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_agent_instruction.py +0 -0
  162. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_agent_mesh.py +0 -0
  163. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_autocompact.py +0 -0
  164. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_automations.py +0 -0
  165. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_capability_routing.py +0 -0
  166. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_checkpoint_diff_command.py +0 -0
  167. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_cli_init.py +0 -0
  168. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_compress_memory_tool.py +0 -0
  169. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_computer_use_permissions.py +0 -0
  170. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_context_budget.py +0 -0
  171. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_context_warning.py +0 -0
  172. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_credentials.py +0 -0
  173. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_eval_harness_layout.py +0 -0
  174. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_event_bus.py +0 -0
  175. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_fleet_reports.py +0 -0
  176. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_ide_stdio_attachments.py +0 -0
  177. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_interactive_permission_ask.py +0 -0
  178. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_kaira_ipc_paths.py +0 -0
  179. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_kaira_scheduler.py +0 -0
  180. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_modality_tools.py +0 -0
  181. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_model_error_retry.py +0 -0
  182. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_model_errors.py +0 -0
  183. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_model_routing.py +0 -0
  184. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_multimodal_input.py +0 -0
  185. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_output_styles_and_rules.py +0 -0
  186. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_paths.py +0 -0
  187. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_permissions.py +0 -0
  188. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_prompt_suggestions.py +0 -0
  189. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_repl_commands.py +0 -0
  190. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_repl_slash.py +0 -0
  191. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_session_runtime_cache.py +0 -0
  192. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_skills.py +0 -0
  193. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_slash_commands.py +0 -0
  194. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_slash_completion_registry.py +0 -0
  195. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_thinking_config.py +0 -0
  196. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_token_budget.py +0 -0
  197. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_tool_context_circulation.py +0 -0
  198. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_tools.py +0 -0
  199. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_tools_inspector.py +0 -0
  200. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_web_server.py +0 -0
  201. {gemcode-0.4.20 → gemcode-0.4.22}/tests/test_web_sse_adapter.py +0 -0
  202. {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.20
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
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gemcode"
7
- version = "0.4.20"
7
+ version = "0.4.22"
8
8
  description = "Local-first coding agent on Google Gemini + ADK"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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
- _APPROVAL_DIR = Path.home() / ".gemcode" / "web_approvals"
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 _APPROVAL_DIR / f"{safe}.json"
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
- _APPROVAL_DIR.mkdir(parents=True, exist_ok=True)
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
- _APPROVAL_DIR.mkdir(parents=True, exist_ok=True)
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
- body = json.dumps(
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
- ).encode()
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-2.5-flash"
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
- project_root = req_root.strip()
1067
- else:
1068
- project_root = os.environ.get("GEMCODE_WEB_PROJECT_ROOT") or os.getcwd()
1069
- root_path = Path(project_root).expanduser()
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: {project_root}")
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.20
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