Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 14 additions & 162 deletions backend/zx_backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import threading
from datetime import datetime

from zx_backend.templates import TEMPLATES_MAP
from zx_backend.templates import TEMPLATES_MAP, AGENT_GUIDES_MAP
from zx_backend.database import (
load_database, save_database, initialize_project_database, get_csv_path,
load_state, save_state, run_state_hook
Expand Down Expand Up @@ -257,6 +257,13 @@ def open_project(payload: ProjectPathPayload):
with open(hook_file, "w") as f:
f.write(content)

# Give AI coding CLIs started in the project the hook contract, without clobbering user edits
for name, content in AGENT_GUIDES_MAP.items():
guide_file = os.path.join(path, name)
if not os.path.exists(guide_file):
with open(guide_file, "w") as f:
f.write(content)

# Install packages in requirements.txt (from both the root and hooks directory)
root_req_file = os.path.join(path, "requirements.txt")
hooks_dir = os.path.join(path, "hooks")
Expand Down Expand Up @@ -473,164 +480,6 @@ def load_hook_endpoint(name: str):
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

# AI Hook Generation API
class AIGeneratePayload(BaseModel):
hook_name: str
prompt: Optional[str] = None
code: str
mode: str # 'generate' or 'autocomplete'
cursor_line: Optional[int] = None
cursor_column: Optional[int] = None

@app.post("/api/hooks/ai/generate")
def ai_generate_hook_endpoint(payload: AIGeneratePayload):
if not active_project_path:
raise HTTPException(status_code=400, detail="No active project opened")

try:
state = load_state(active_project_path)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed loading state: {str(e)}")

api_key = state.get("GEMINI_API_KEY")
if not api_key:
api_key = os.environ.get("GEMINI_API_KEY")

if not api_key:
raise HTTPException(
status_code=400,
detail="GEMINI_API_KEY is not configured. Please specify it in the Shared State."
)

import urllib.request
import json

if payload.mode == "autocomplete":
system_instruction = (
"You are an expert Python autocomplete assistant. Act as a strict Python developer and code assistant "
"for the zX parametric exploration framework.\n"
"Predict the logical continuation or completion of the Python code provided.\n"
"CRITICAL: Return ONLY raw, valid Python code that immediately follows the provided prefix. "
"Do NOT repeat the prefix code, and do NOT include any introductory or explanatory text. "
"Do NOT wrap the response in markdown code blocks (like ```python or ```). Start returning the completion immediately."
)

lines = payload.code.splitlines()
prefix = payload.code
suffix = ""

if payload.cursor_line is not None and payload.cursor_column is not None:
line_idx = payload.cursor_line - 1
col_idx = payload.cursor_column - 1

if 0 <= line_idx < len(lines):
prefix_lines = lines[:line_idx]
curr_line = lines[line_idx]

curr_prefix = curr_line[:col_idx]
curr_suffix = curr_line[col_idx:]

prefix = "\n".join(prefix_lines + [curr_prefix])
suffix = "\n".join([curr_suffix] + lines[line_idx+1:])

prompt = (
f"We are editing the Python hook file '{payload.hook_name}'.\n"
f"Here is the code prefix up to the cursor:\n"
f"---CODE_PREFIX---\n"
f"{prefix}\n"
f"---END_CODE_PREFIX---\n"
)
if suffix.strip():
prompt += (
f"Here is the code suffix after the cursor:\n"
f"---CODE_SUFFIX---\n"
f"{suffix}\n"
f"---END_CODE_SUFFIX---\n"
)
prompt += (
"\nPlease generate the next few lines or logical completion of this code "
"starting exactly at the cursor position. Return ONLY the continuation code."
)
else:
system_instruction = (
"You are an expert Python developer and code assistant for the zX simulation exploration framework.\n"
"Generate clean, valid, and fully-formed Python code based on the user request.\n"
"CRITICAL: Return ONLY raw, valid Python code. Do NOT wrap the code in markdown code blocks "
"(like ```python or ```), and do NOT include any conversational filler, introductory, or explanatory text. "
"Return only the executable python code."
)

prompt = (
f"We are working on the hook file '{payload.hook_name}'.\n"
f"Here is the current content of the file:\n"
f"---CURRENT_CODE---\n"
f"{payload.code}\n"
f"---END_CURRENT_CODE---\n\n"
f"The user has requested the following task:\n"
f"{payload.prompt}\n\n"
f"Please generate the complete code or the specific function modification to satisfy the user request. "
f"Return ONLY valid Python code."
)

# Use gemini-2.5-flash as the state-of-the-art developer model
url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent"
api_payload = {
"contents": [
{
"role": "user",
"parts": [{"text": prompt}]
}
],
"systemInstruction": {
"parts": [{"text": system_instruction}]
},
"generationConfig": {
"temperature": 0.2,
"maxOutputTokens": 1536
}
}

req = urllib.request.Request(
url,
data=json.dumps(api_payload).encode("utf-8"),
headers={"Content-Type": "application/json", "x-goog-api-key": api_key},
method="POST"
)

try:
with urllib.request.urlopen(req, timeout=30) as response:
res_text = response.read().decode("utf-8")
res_data = json.loads(res_text)

candidates = res_data.get("candidates", [])
generated_text = ""
if candidates:
parts = candidates[0].get("content", {}).get("parts", [])
if parts:
generated_text = parts[0].get("text", "")

cleaned_text = generated_text.strip()
if cleaned_text.startswith("```python"):
cleaned_text = cleaned_text[len("```python"):].strip()
elif cleaned_text.startswith("```"):
cleaned_text = cleaned_text[3:].strip()
if cleaned_text.endswith("```"):
cleaned_text = cleaned_text[:-3].strip()

return {"status": "success", "generated_code": cleaned_text}

except Exception as e:
logger.error(f"Gemini API call failed: {e}")
err_msg = str(e)
if hasattr(e, "read"):
try:
err_body = e.read().decode("utf-8")
err_json = json.loads(err_body)
err_msg = err_json.get("error", {}).get("message", err_msg)
except Exception:
pass
raise HTTPException(status_code=500, detail=f"Gemini API Error: {err_msg}")

# Runner Controllers
class RunStartPayload(BaseModel):
row_ids: List[int]
Expand Down Expand Up @@ -1221,16 +1070,19 @@ def git_restore(payload: GitRestorePayload):
raise HTTPException(status_code=500, detail=str(e))

# --- WebSocket Terminal Endpoint ---
# Local AI coding CLIs the hook editor may start; anything else is refused so the query cannot inject commands
AI_CLIS = {"claude", "gemini", "codex"}

@app.websocket("/ws/terminal")
async def websocket_terminal(websocket: WebSocket, token: str = Query(...)):
if token != AUTH_TOKEN:
async def websocket_terminal(websocket: WebSocket, token: str = Query(...), cmd: Optional[str] = Query(None)):
if token != AUTH_TOKEN or (cmd is not None and cmd not in AI_CLIS):
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return

await websocket.accept()

from zx_backend.terminal_session import TerminalSession
session = TerminalSession(websocket)
session = TerminalSession(websocket, cwd=active_project_path, command=cmd)
session.start()

try:
Expand Down
39 changes: 39 additions & 0 deletions backend/zx_backend/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,3 +265,42 @@ def plot(table: DataFrame, state: dict) -> dict:
"state.py": STATE_TEMPLATE,
}


AGENTS_GUIDE = """# zX project guide for AI coding assistants

This folder is a zX parametric exploration project. zX runs one row of input
parameters at a time through Python hook scripts in `hooks/`. Edit hooks here;
do not edit zX itself.

## Files
- `hooks/*.py` - the hooks below. Each file defines one function with the same name.
- `zx_database.csv` - one row per run. Columns starting with `_zx_` (status, run dir,
errors, iteration, ...) are owned by zX: read them, never write them.
- `zx_state.json` - shared state dict passed to every hook.
- `runs/run_<row_id>/` - per-row working directory (`run_dir`); `zx_hook.log` holds
that row's hook output. `runs/explore_<n>.log` holds exploration output.

## Hook contract
| File | Signature | Returns |
|------|-----------|---------|
| `initialize.py` | `initialize(table: DataFrame, state: dict)` | `(rows: list[dict], state: dict)` - the initial input rows |
| `state.py` | `state(state: dict, updates: dict)` | the updated state dict (validate `updates` here) |
| `preprocess.py` | `preprocess(row: dict, state: dict, run_dir: Path)` | `None` - write solver inputs into `run_dir` |
| `launch.py` | `launch(row: dict, state: dict, run_dir: Path)` | dict with `status` (`"completed"` or `"submitted"`), `job_id` (when submitted, e.g. Slurm), `stdout`, `stderr`, `returncode` |
| `extract.py` | `extract(row: dict, state: dict, run_dir: Path)` | dict of result columns to merge into the row |
| `explore.py` | `explore(table: DataFrame, state: dict)` | `list[dict]` of new rows; `[]` stops exploration. Runs after a batch completes |
| `plot.py` | `plot(table: DataFrame, state: dict)` | `{name: plotly_figure_dict}` shown on the dashboard |

`table` is the database as a pandas DataFrame. Hooks may mutate `state`.

## Checking your work
- `python -m py_compile hooks/<name>.py` for syntax.
- Call a hook directly with a small DataFrame/dict to test it; avoid launching real
solver jobs unless the user asks.
"""

AGENT_GUIDES_MAP = {
"AGENTS.md": AGENTS_GUIDE,
# Claude Code reads CLAUDE.md; import the shared guide instead of duplicating it
"CLAUDE.md": "@AGENTS.md\n",
}
18 changes: 16 additions & 2 deletions backend/zx_backend/terminal_session.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import os
import pty
import shlex
import asyncio
import struct
import fcntl
Expand All @@ -10,8 +11,10 @@
logger = logging.getLogger("zx_terminal")

class TerminalSession:
def __init__(self, ws: WebSocket):
def __init__(self, ws: WebSocket, cwd: str | None = None, command: str | None = None):
self.ws = ws
self.cwd = cwd
self.command = command
self.master_fd = None
self.pid = None
self.read_task = None
Expand All @@ -24,8 +27,19 @@ def start(self):
# Let's find the shell or fallback
shell = os.environ.get("SHELL", "/bin/sh")
os.environ["TERM"] = "xterm-256color"
if self.cwd:
try:
os.chdir(self.cwd)
except OSError:
pass
try:
os.execv(shell, [shell])
# Login shell so tools installed via the user's profile (e.g. AI CLIs) are on PATH,
# even when the packaged app was launched from Finder with a minimal environment
# A startup command is run by the shell itself (typed-ahead input is dropped by zsh),
# then replaced by an ordinary shell when it exits
if self.command:
os.execv(shell, [shell, "-l", "-i", "-c", f"{self.command}; exec {shlex.quote(shell)} -l"])
os.execv(shell, [shell, "-l"])
except Exception:
try:
os.execv("/bin/bash", ["/bin/bash"])
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,8 @@ export default function App() {
activeProject={activeProject}
apiCall={apiCall}
theme={theme}
authToken={authToken}
port={port}
/>
</div>
)}
Expand Down
Loading
Loading