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