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