gemcode 0.4.26__py3-none-any.whl → 0.4.27__py3-none-any.whl

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.
@@ -2,11 +2,17 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import atexit
5
6
  import socket
7
+ import subprocess
8
+ import sys
9
+ import threading
10
+ import time
6
11
  from http.client import HTTPConnection
12
+ from pathlib import Path
7
13
  from typing import Any
8
14
  from urllib.error import URLError
9
- from urllib.parse import unquote
15
+ from urllib.parse import quote, unquote
10
16
  from urllib.request import Request, urlopen
11
17
 
12
18
  # Common dev-server ports (GemCode web UI uses 3002; API 3001)
@@ -22,8 +28,13 @@ COMMON_DEV_PORTS = (
22
28
  5000,
23
29
  4321,
24
30
  8888,
31
+ 5500,
25
32
  )
26
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
+
27
38
  _STRIP_RESPONSE_HEADERS = frozenset(
28
39
  {
29
40
  "transfer-encoding",
@@ -36,6 +47,9 @@ _STRIP_RESPONSE_HEADERS = frozenset(
36
47
  }
37
48
  )
38
49
 
50
+ _server_lock = threading.Lock()
51
+ _managed_servers: dict[int, subprocess.Popen[bytes]] = {}
52
+
39
53
 
40
54
  def _port_open(host: str, port: int, *, timeout: float = 0.2) -> bool:
41
55
  try:
@@ -109,15 +123,111 @@ def _inject_base_href(html: str, base_path: str) -> str:
109
123
  return base_tag + html
110
124
 
111
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
+
112
217
  def handle_preview_proxy(
113
218
  port: int,
114
219
  sub_path: str = "/",
115
220
  *,
116
221
  query: str = "",
222
+ workspace_root: str | Path | None = None,
117
223
  ) -> tuple[int, bytes, dict[str, str]]:
118
224
  """
119
225
  Reverse-proxy HTTP from 127.0.0.1:{port} (on this process / tenant pod).
120
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
+
121
231
  Returns (status, body, response_headers).
122
232
  """
123
233
  if not isinstance(port, int) or port < 1 or port > 65535:
@@ -151,28 +261,68 @@ def handle_preview_proxy(
151
261
  qs = (query or "").lstrip("?")
152
262
  request_path = path if not qs else f"{path}?{qs}"
153
263
 
154
- try:
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]]]:
155
281
  conn = HTTPConnection("127.0.0.1", port, timeout=20.0)
156
282
  conn.request(
157
283
  "GET",
158
- request_path,
284
+ upstream_request,
159
285
  headers={"User-Agent": "GemCode-Web-Preview/1.0", "Accept": "*/*"},
160
286
  )
161
287
  resp = conn.getresponse()
162
288
  body = resp.read()
163
289
  ctype = (resp.getheader("Content-Type") or "").lower()
164
- headers: dict[str, str] = {}
165
- for key, value in resp.getheaders():
166
- if key.lower() in _STRIP_RESPONSE_HEADERS:
167
- continue
168
- headers[key] = value
290
+ headers_list = resp.getheaders()
169
291
  status = int(resp.status)
170
292
  conn.close()
171
- except OSError as exc:
172
- msg = f'{{"error":"Preview upstream unreachable: {type(exc).__name__}: {exc}"}}'
173
- return 502, msg.encode(), {"Content-Type": "application/json; charset=utf-8"}
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
174
324
 
175
- proxy_base = f"/api/preview/proxy/{port}/"
325
+ proxy_base = _proxy_base_for_path(port, path)
176
326
  if "text/html" in ctype:
177
327
  try:
178
328
  text = body.decode("utf-8", errors="replace")
gemcode/web/server.py CHANGED
@@ -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.26
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
@@ -130,23 +130,23 @@ gemcode/web/customize_api.py,sha256=Et1HY49lleZlCBywsBg9n61KFReqTB0wGQrDmhrNEf4,
130
130
  gemcode/web/files_api.py,sha256=sdlSd3gj3TF_qrH2xYy70CTWMXAIzqouWe6uDkMrroo,7724
131
131
  gemcode/web/fleet_api.py,sha256=4lR7lWqpMojaAL7_c6rjjA5hMFnnUgpI1T561RvM-3w,19276
132
132
  gemcode/web/hitl_bridge.py,sha256=08rzUmVDiY6eAsMXZ-oE-XLPK5gk-wAGIFxDY2QlobA,5465
133
- gemcode/web/preview_api.py,sha256=vz5ddD6JYAQ5h6DEa4w95FUKalw-ujvT-jusbVa-8Ak,5828
133
+ gemcode/web/preview_api.py,sha256=mE6izq2TZfrBmiLAJRaZlyKxWYlv6b6SQfh7HoS_E18,10795
134
134
  gemcode/web/project_root.py,sha256=rMKsp2va2z1p9CI-NKzbdJq2hyKXtNzDSLyTk-tV8Ws,1936
135
135
  gemcode/web/runtime_api.py,sha256=BezPpbNnj85aXiL5r7aXGcA3FFubadY7lHtskaNPgMc,12935
136
136
  gemcode/web/serve_bind.py,sha256=Q6odfJuoTw8hIn3Hq8jcLe6EvRB1_wj4klrOBjKdTiE,3097
137
137
  gemcode/web/serve_state.py,sha256=wTHaZ4CGjZYOxRMlKmFUEMpFoBPnci20GLbMjxGdGG0,6265
138
- gemcode/web/server.py,sha256=MovOlagSzWt63AbU55lE-x28D-fQX6DsBxhzKFFU54A,27698
138
+ gemcode/web/server.py,sha256=firEgHj9jbouOc-NYeC4QdXQE6pwH15RhBrAynS4DC8,27719
139
139
  gemcode/web/sessions_api.py,sha256=4qudnYLiqIbUixon1PsV8yfBf6tu6llWLc_ESTFK61g,2008
140
- gemcode/web/sse_adapter.py,sha256=WI0ganr1Dqp7nLjdvN-WNtmMpYhjQIKTohBuIekLv-M,44019
140
+ gemcode/web/sse_adapter.py,sha256=zZA2-EXz3dmmFe98F4IWQYDYgqz378yrSYiEilKo_dE,44283
141
141
  gemcode/web/terminal_api.py,sha256=2FWU0XQ6CVUFW_JJiVukFPdj_sgsYa_y7IQb9brxCZw,3329
142
142
  gemcode/web/terminal_repl.py,sha256=fQt895g0qcr6VBhXfv_5b_bsC5zHT5-MO0ysBdgi2Fg,3886
143
143
  gemcode/web/ui_chat_store.py,sha256=sThUOcWC-svx-e2pt88ZazPrWelN2JbqeDicU7s5iUg,2377
144
144
  gemcode/web/web_config_api.py,sha256=1kmk1vSu5z63loddTNBHVsisiYy4CfyVIF93TYCgEqs,2264
145
145
  gemcode/web/web_sse_compat.py,sha256=9A2s-GI7El7AotJqhO263FrLwppCXXkdydZ5EiOQbao,504
146
146
  gemcode/web/workspace_panel_api.py,sha256=uZS11ovlZtc1IJDnOA776Z6IndC0WovcBbmPMnZyIHs,14122
147
- gemcode-0.4.26.dist-info/licenses/LICENSE,sha256=TD4524qn-W8Z07GTDnag-9jJPFutFZNB0a1WbMHPC54,8388
148
- gemcode-0.4.26.dist-info/METADATA,sha256=K16ysRvmdkDwT4s6ykdDg_gfQ5C3F1h7UVA9IqNGBqg,27378
149
- gemcode-0.4.26.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
150
- gemcode-0.4.26.dist-info/entry_points.txt,sha256=cZdLTLDiHbks7OSUCuxCh66dCWeQdpLR8BozoqfEjV4,45
151
- gemcode-0.4.26.dist-info/top_level.txt,sha256=UYrjULLBY2bcgK6KI6flomJWmsbDXu7n0rvW2SWFrbo,8
152
- gemcode-0.4.26.dist-info/RECORD,,
147
+ gemcode-0.4.27.dist-info/licenses/LICENSE,sha256=TD4524qn-W8Z07GTDnag-9jJPFutFZNB0a1WbMHPC54,8388
148
+ gemcode-0.4.27.dist-info/METADATA,sha256=eWom0RlkM1PHdaeudIplBgMh1v02guvCd4Hmz2xk81A,27378
149
+ gemcode-0.4.27.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
150
+ gemcode-0.4.27.dist-info/entry_points.txt,sha256=cZdLTLDiHbks7OSUCuxCh66dCWeQdpLR8BozoqfEjV4,45
151
+ gemcode-0.4.27.dist-info/top_level.txt,sha256=UYrjULLBY2bcgK6KI6flomJWmsbDXu7n0rvW2SWFrbo,8
152
+ gemcode-0.4.27.dist-info/RECORD,,