    return {"docs": items}

@app.get("/api/docs/page")
async def docs_page(path: str) -> Dict[str, Any]:
    full = (WORKSPACE_DIR / path).resolve()
    if not str(full).startswith(str(WORKSPACE_DIR.resolve())):
        raise HTTPException(status_code=403, detail="Access denied")
    if not full.exists() or not full.is_file():
        raise HTTPException(status_code=404, detail="File not found")
    if full.suffix.lower() not in {".md", ".markdown"}:
        raise HTTPException(status_code=400, detail="Not a Markdown file")
    try:
        content = full.read_text(encoding="utf-8", errors="replace")
        html = markdown.markdown(content, extensions=['tables', 'fenced_code'])
        return {"path": str(full.relative_to(WORKSPACE_DIR)), "html": html, "raw": content}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

# Traces and datasets
@app.get("/api/traces/list")
async def traces_list(offset: int = 0, limit: int = 100) -> Dict[str, Any]:
    base = WORKSPACE_DIR / "data" / "traces"
    items: List[Dict[str, Any]] = []
    if base.exists():
        for p in sorted(base.rglob("*.jsonl")):
            try:
                sz = p.stat().st_size
                # Quick peek at first line for metadata
                meta = {}
                try:
                    with p.open("r") as f:
                        first = f.readline().strip()
                        if first:
                            meta = json.loads(first)
                except Exception:
                    pass
                items.append({
                    "path": str(p.relative_to(WORKSPACE_DIR)),
                    "size": sz,
                    "meta": meta
                })
            except Exception:
                continue
    total = len(items)
    start = max(0, int(offset))
    end = max(start, min(total, start + int(limit)))
    return {"items": items[start:end], "total": total, "offset": start, "limit": int(limit)}

@app.get("/api/traces/tail")
async def tail_trace(path: str, n: int = 100) -> Dict[str, Any]:
    """Return last N lines of a trace file."""
    full = WORKSPACE_DIR / path
    if not str(full).startswith(str(WORKSPACE_DIR)):
        raise HTTPException(status_code=403, detail="Access denied")
    if not full.exists():
        raise HTTPException(status_code=404, detail="File not found")

    try:
        lines = []
        with full.open("r") as f:
            # Read last N lines efficiently
            f.seek(0, os.SEEK_END)
            block_size = 8192
            file_size = f.tell()
            block_count = file_size // block_size
            blocks = []


                try:
                    with p.open("r") as f:
                        first = f.readline().strip()
                        if first:
                            meta = json.loads(first)
                except Exception:
                    pass
                items.append({
                    "path": str(p.relative_to(WORKSPACE_DIR)),
                    "size": sz,
                    "meta": meta
                })
            except Exception:
                continue
    total = len(items)
    start = max(0, int(offset))
    end = max(start, min(total, start + int(limit)))
    return {"items": items[start:end], "total": total, "offset": start, "limit": int(limit)}

@app.get("/api/traces/tail")
async def tail_trace(path: str, n: int = 100) -> Dict[str, Any]:
    """Return last N lines of a trace file."""
    full = WORKSPACE_DIR / path
    if not str(full).startswith(str(WORKSPACE_DIR)):
        raise HTTPException(status_code=403, detail="Access denied")
    if not full.exists():
        raise HTTPException(status_code=404, detail="File not found")

    try:
        lines = []
        with full.open("r") as f:
            # Read last N lines efficiently
            f.seek(0, os.SEEK_END)
            block_size = 8192
            file_size = f.tell()
            block_count = file_size // block_size
            blocks = []

            for i in range(block_count + 1):
                step = min(block_size, file_size - (block_count - i) * block_size)
                if step <= 0:
                    break
                f.seek(file_size - (i + 1) * block_size)
                blocks.append(f.read(step))

            content = "".join(reversed(blocks))
            lines = content.splitlines()[-n:]

        return {"lines": lines}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.get("/api/datasets/list")
async def datasets_list(offset: int = 0, limit: int = 200) -> Dict[str, Any]:
    roots = [
        WORKSPACE_DIR / "data" / "skills",
        WORKSPACE_DIR / "data" / "sandbox" / "sft",
        WORKSPACE_DIR / "data" / "cards",
    ]
    out: Dict[str, List[Dict[str, Any]]] = {}
    for r in roots:
        key = str(r.relative_to(WORKSPACE_DIR))
        items: List[Dict[str, Any]] = []
        if r.exists():
            for p in sorted(r.rglob("*")):
                if p.is_file():
                    try:
                        sz = p.stat().st_size
                        # For Markdown files, extract title and metadata
                        meta = {}
                        if p.suffix.lower() == '.md':


            block_size = 8192
            file_size = f.tell()
            block_count = file_size // block_size
            blocks = []

            for i in range(block_count + 1):
                step = min(block_size, file_size - (block_count - i) * block_size)
                if step <= 0:
                    break
                f.seek(file_size - (i + 1) * block_size)
                blocks.append(f.read(step))

            content = "".join(reversed(blocks))
            lines = content.splitlines()[-n:]

        return {"lines": lines}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.get("/api/datasets/list")
async def datasets_list(offset: int = 0, limit: int = 200) -> Dict[str, Any]:
    roots = [
        WORKSPACE_DIR / "data" / "skills",
        WORKSPACE_DIR / "data" / "sandbox" / "sft",
        WORKSPACE_DIR / "data" / "cards",
    ]
    out: Dict[str, List[Dict[str, Any]]] = {}
    for r in roots:
        key = str(r.relative_to(WORKSPACE_DIR))
        items: List[Dict[str, Any]] = []
        if r.exists():
            for p in sorted(r.rglob("*")):
                if p.is_file():
                    try:
                        sz = p.stat().st_size
                        # For Markdown files, extract title and metadata
                        meta = {}
                        if p.suffix.lower() == '.md':
                            try:
                                with p.open("r") as f:
                                    content = f.read()
                                    # Extract title from first h1
                                    title_match = re.search(r'^#\s+(.+)$', content, re.M)
                                    if title_match:
                                        meta["title"] = title_match.group(1)
                                    # Look for metadata section
                                    meta_match = re.search(r'---\n(.*?)\n---', content, re.S)
                                    if meta_match:
                                        try:
                                            import yaml
                                            meta.update(yaml.safe_load(meta_match.group(1)))
                                        except Exception:
                                            pass
                            except Exception:
                                pass
                        # For JSONL, peek at first line
                        elif p.suffix.lower() == '.jsonl':
                            try:
                                with p.open("r") as f:
                                    first = f.readline().strip()
                                    if first:
                                        meta["first_record"] = json.loads(first)
                            except Exception:
                                pass
                        items.append({
                            "path": str(p.relative_to(WORKSPACE_DIR)),
                            "size": sz,
                            "meta": meta
                        })
                    except Exception:
                        continue
        total = len(items)
        start = max(0, int(offset))
        end = max(start, min(total, start + int(limit)))
        out[key] = {"items": items[start:end], "total": total, "offset": start, "limit": int(limit)}
    return out

@app.get("/api/datasets/card")
async def get_dataset_card(path: str) -> Dict[str, Any]:
    """Render a dataset card Markdown file with metadata."""
    full = WORKSPACE_DIR / path
    if not str(full).startswith(str(WORKSPACE_DIR)):
        raise HTTPException(status_code=403, detail="Access denied")
    if not full.exists():
        raise HTTPException(status_code=404, detail="File not found")
    if full.suffix.lower() != '.md':
        raise HTTPException(status_code=400, detail="Not a Markdown file")

    try:
        with full.open("r") as f:
            content = f.read()

        # Extract metadata
        meta = {}
        meta_match = re.search(r'---\n(.*?)\n---', content, re.S)
        if meta_match:


                                with p.open("r") as f:
                                    first = f.readline().strip()
                                    if first:
                                        meta["first_record"] = json.loads(first)
                            except Exception:
                                pass
                        items.append({
                            "path": str(p.relative_to(WORKSPACE_DIR)),
                            "size": sz,
                            "meta": meta
                        })
                    except Exception:
                        continue
        total = len(items)
        start = max(0, int(offset))
        end = max(start, min(total, start + int(limit)))
        out[key] = {"items": items[start:end], "total": total, "offset": start, "limit": int(limit)}
    return out

@app.get("/api/datasets/card")
async def get_dataset_card(path: str) -> Dict[str, Any]:
    """Render a dataset card Markdown file with metadata."""
    full = WORKSPACE_DIR / path
    if not str(full).startswith(str(WORKSPACE_DIR)):
        raise HTTPException(status_code=403, detail="Access denied")
    if not full.exists():
        raise HTTPException(status_code=404, detail="File not found")
    if full.suffix.lower() != '.md':
        raise HTTPException(status_code=400, detail="Not a Markdown file")

    try:
        with full.open("r") as f:
            content = f.read()

        # Extract metadata
        meta = {}
        meta_match = re.search(r'---\n(.*?)\n---', content, re.S)
        if meta_match:
            try:
                import yaml
                meta = yaml.safe_load(meta_match.group(1))
                # Remove metadata block from content
                content = content.replace(meta_match.group(0), "", 1)
            except Exception:
                pass

        # Render Markdown to HTML
        html = markdown.markdown(content, extensions=['tables', 'fenced_code'])

        return {
            "path": str(full.relative_to(WORKSPACE_DIR)),
            "meta": meta,
            "html": html,
            "raw": content
        }
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.get("/api/task/logs/stream/{task_id}")
async def stream_task_logs(task_id: str) -> StreamingResponse:
    """Stream task logs as Server-Sent Events."""
    if task_id not in active_tasks:
        raise HTTPException(status_code=404, detail="Task not found")

    async def event_generator() -> AsyncGenerator[str, None]:
        last_stdout = ""
        last_stderr = ""
        while True:
            task = active_tasks.get(task_id)
            if not task:
                break

            stdout = task.get("stdout", "")
            stderr = task.get("stderr", "")

            if stdout != last_stdout:
                yield f"data: {json.dumps({'type': 'stdout', 'data': stdout[len(last_stdout):]})}\n\n"


                import yaml
                meta = yaml.safe_load(meta_match.group(1))
                # Remove metadata block from content
                content = content.replace(meta_match.group(0), "", 1)
            except Exception:
                pass

        # Render Markdown to HTML
        html = markdown.markdown(content, extensions=['tables', 'fenced_code'])

        return {
            "path": str(full.relative_to(WORKSPACE_DIR)),
            "meta": meta,
            "html": html,
            "raw": content
        }
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.get("/api/task/logs/stream/{task_id}")
async def stream_task_logs(task_id: str) -> StreamingResponse:
    """Stream task logs as Server-Sent Events."""
    if task_id not in active_tasks:
        raise HTTPException(status_code=404, detail="Task not found")

    async def event_generator() -> AsyncGenerator[str, None]:
        last_stdout = ""
        last_stderr = ""
        while True:
            task = active_tasks.get(task_id)
            if not task:
                break

            stdout = task.get("stdout", "")
            stderr = task.get("stderr", "")

            if stdout != last_stdout:
                yield f"data: {json.dumps({'type': 'stdout', 'data': stdout[len(last_stdout):]})}\n\n"
                last_stdout = stdout

            if stderr != last_stderr:
                yield f"data: {json.dumps({'type': 'stderr', 'data': stderr[len(last_stderr):]})}\n\n"
                last_stderr = stderr

            if task.get("status") not in ("running", "queued"):
                break

            await asyncio.sleep(1.0)

    return StreamingResponse(event_generator(), media_type="text/event-stream")

# ---------------- Dev endpoints (patch/test) ----------------
class PatchRequest(BaseModel):
    repo: str
    diff: str

class TestRequest(BaseModel):
    repo: str
    pytest_args: Optional[str] = "-q"

@app.post("/api/dev/patch/dry_run")
async def dev_patch_dry(req: PatchRequest) -> Dict[str, Any]:
    try:
        from agi_dw.tools.patch_policy import load_env_limits, validate_unified_diff  # type: ignore
        ok, why, meta = validate_unified_diff(req.diff, load_env_limits(strict_default=True))
        policy = {"ok": bool(ok), "why": why, **(meta or {})}
    except Exception:
        policy = {"ok": True}
    try:
        from agi_dw.tools.patch_actuator import apply_unified_diff  # type: ignore


PatchRequest

            stdout = task.get("stdout", "")
            stderr = task.get("stderr", "")

            if stdout != last_stdout:
                yield f"data: {json.dumps({'type': 'stdout', 'data': stdout[len(last_stdout):]})}\n\n"
                last_stdout = stdout

            if stderr != last_stderr:
                yield f"data: {json.dumps({'type': 'stderr', 'data': stderr[len(last_stderr):]})}\n\n"
                last_stderr = stderr

            if task.get("status") not in ("running", "queued"):
                break

            await asyncio.sleep(1.0)

    return StreamingResponse(event_generator(), media_type="text/event-stream")

# ---------------- Dev endpoints (patch/test) ----------------
class PatchRequest(BaseModel):
    repo: str
    diff: str

class TestRequest(BaseModel):
    repo: str
    pytest_args: Optional[str] = "-q"

@app.post("/api/dev/patch/dry_run")
async def dev_patch_dry(req: PatchRequest) -> Dict[str, Any]:
    try:
        from agi_dw.tools.patch_policy import load_env_limits, validate_unified_diff  # type: ignore
        ok, why, meta = validate_unified_diff(req.diff, load_env_limits(strict_default=True))
        policy = {"ok": bool(ok), "why": why, **(meta or {})}
    except Exception:
        policy = {"ok": True}
    try:
        from agi_dw.tools.patch_actuator import apply_unified_diff  # type: ignore
        res = apply_unified_diff(req.repo, req.diff, allow_globs=["**/*"], block_globs=["**/.ssh/**"], max_files=50, dry_run=True)
    except Exception as e:
        raise HTTPException(status_code=400, detail=f"dry_run_failed: {e}")
    return {"ok": bool(res.get("ok", False)), "policy": policy, "result": res}


TestRequest

            if stdout != last_stdout:
                yield f"data: {json.dumps({'type': 'stdout', 'data': stdout[len(last_stdout):]})}\n\n"
                last_stdout = stdout

            if stderr != last_stderr:
                yield f"data: {json.dumps({'type': 'stderr', 'data': stderr[len(last_stderr):]})}\n\n"
                last_stderr = stderr

            if task.get("status") not in ("running", "queued"):
                break

            await asyncio.sleep(1.0)

    return StreamingResponse(event_generator(), media_type="text/event-stream")

# ---------------- Dev endpoints (patch/test) ----------------
class PatchRequest(BaseModel):
    repo: str
    diff: str

class TestRequest(BaseModel):
    repo: str
    pytest_args: Optional[str] = "-q"

@app.post("/api/dev/patch/dry_run")
async def dev_patch_dry(req: PatchRequest) -> Dict[str, Any]:
    try:
        from agi_dw.tools.patch_policy import load_env_limits, validate_unified_diff  # type: ignore
        ok, why, meta = validate_unified_diff(req.diff, load_env_limits(strict_default=True))
        policy = {"ok": bool(ok), "why": why, **(meta or {})}
    except Exception:
        policy = {"ok": True}
    try:
        from agi_dw.tools.patch_actuator import apply_unified_diff  # type: ignore
        res = apply_unified_diff(req.repo, req.diff, allow_globs=["**/*"], block_globs=["**/.ssh/**"], max_files=50, dry_run=True)
    except Exception as e:
        raise HTTPException(status_code=400, detail=f"dry_run_failed: {e}")
    return {"ok": bool(res.get("ok", False)), "policy": policy, "result": res}

@app.post("/api/dev/patch/apply")
async def dev_patch_apply(req: PatchRequest) -> Dict[str, Any]:
    try:
        from agi_dw.tools.patch_policy import load_env_limits, validate_unified_diff  # type: ignore


                yield f"data: {json.dumps({'type': 'stderr', 'data': stderr[len(last_stderr):]})}\n\n"
                last_stderr = stderr

            if task.get("status") not in ("running", "queued"):
                break

            await asyncio.sleep(1.0)

    return StreamingResponse(event_generator(), media_type="text/event-stream")

# ---------------- Dev endpoints (patch/test) ----------------
class PatchRequest(BaseModel):
    repo: str
    diff: str

class TestRequest(BaseModel):
    repo: str
    pytest_args: Optional[str] = "-q"

@app.post("/api/dev/patch/dry_run")
async def dev_patch_dry(req: PatchRequest) -> Dict[str, Any]:
    try:
        from agi_dw.tools.patch_policy import load_env_limits, validate_unified_diff  # type: ignore
        ok, why, meta = validate_unified_diff(req.diff, load_env_limits(strict_default=True))
        policy = {"ok": bool(ok), "why": why, **(meta or {})}
    except Exception:
        policy = {"ok": True}
    try:
        from agi_dw.tools.patch_actuator import apply_unified_diff  # type: ignore
        res = apply_unified_diff(req.repo, req.diff, allow_globs=["**/*"], block_globs=["**/.ssh/**"], max_files=50, dry_run=True)
    except Exception as e:
        raise HTTPException(status_code=400, detail=f"dry_run_failed: {e}")
    return {"ok": bool(res.get("ok", False)), "policy": policy, "result": res}

@app.post("/api/dev/patch/apply")
async def dev_patch_apply(req: PatchRequest) -> Dict[str, Any]:
    try:
        from agi_dw.tools.patch_policy import load_env_limits, validate_unified_diff  # type: ignore
        ok, why, meta = validate_unified_diff(req.diff, load_env_limits(strict_default=True))
        if not ok:
            return {"ok": False, "policy": {"ok": False, "why": why, **(meta or {})}}
    except Exception:
        pass
    try:
        from agi_dw.tools.patch_actuator import apply_unified_diff  # type: ignore
        res = apply_unified_diff(req.repo, req.diff, allow_globs=["**/*"], block_globs=["**/.ssh/**"], max_files=50, dry_run=False)
    except Exception as e:
        raise HTTPException(status_code=400, detail=f"apply_failed: {e}")
    return {"ok": bool(res.get("ok", False)), "result": res}

@app.post("/api/dev/run_tests")
async def dev_run_tests(req: TestRequest) -> Dict[str, Any]:
    try:


class TestRequest(BaseModel):
    repo: str
    pytest_args: Optional[str] = "-q"

@app.post("/api/dev/patch/dry_run")
async def dev_patch_dry(req: PatchRequest) -> Dict[str, Any]:
    try:
        from agi_dw.tools.patch_policy import load_env_limits, validate_unified_diff  # type: ignore
        ok, why, meta = validate_unified_diff(req.diff, load_env_limits(strict_default=True))
        policy = {"ok": bool(ok), "why": why, **(meta or {})}
    except Exception:
        policy = {"ok": True}
    try:
        from agi_dw.tools.patch_actuator import apply_unified_diff  # type: ignore
        res = apply_unified_diff(req.repo, req.diff, allow_globs=["**/*"], block_globs=["**/.ssh/**"], max_files=50, dry_run=True)
    except Exception as e:
        raise HTTPException(status_code=400, detail=f"dry_run_failed: {e}")
    return {"ok": bool(res.get("ok", False)), "policy": policy, "result": res}

@app.post("/api/dev/patch/apply")
async def dev_patch_apply(req: PatchRequest) -> Dict[str, Any]:
    try:
        from agi_dw.tools.patch_policy import load_env_limits, validate_unified_diff  # type: ignore
        ok, why, meta = validate_unified_diff(req.diff, load_env_limits(strict_default=True))
        if not ok:
            return {"ok": False, "policy": {"ok": False, "why": why, **(meta or {})}}
    except Exception:
        pass
    try:
        from agi_dw.tools.patch_actuator import apply_unified_diff  # type: ignore
        res = apply_unified_diff(req.repo, req.diff, allow_globs=["**/*"], block_globs=["**/.ssh/**"], max_files=50, dry_run=False)
    except Exception as e:
        raise HTTPException(status_code=400, detail=f"apply_failed: {e}")
    return {"ok": bool(res.get("ok", False)), "result": res}

@app.post("/api/dev/run_tests")
async def dev_run_tests(req: TestRequest) -> Dict[str, Any]:
    try:
        p = await asyncio.create_subprocess_exec(
            "pytest", *([x for x in str(req.pytest_args or "-q").split(" ") if x]),
            cwd=str(Path(req.repo)), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
        )
        out, err = await p.communicate()
        return {"ok": p.returncode == 0, "rc": p.returncode, "stdout": out.decode(errors="replace"), "stderr": err.decode(errors="replace")}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.post("/api/dev/revert")
async def dev_revert(payload: Dict[str, Any]) -> Dict[str, Any]:
    repo = str(payload.get("repo", ""))
    if not repo:
        raise HTTPException(status_code=400, detail="repo required")
    try:
        p = await asyncio.create_subprocess_exec("git", "reset", "--hard", cwd=repo, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)


        raise HTTPException(status_code=400, detail=f"dry_run_failed: {e}")
    return {"ok": bool(res.get("ok", False)), "policy": policy, "result": res}

@app.post("/api/dev/patch/apply")
async def dev_patch_apply(req: PatchRequest) -> Dict[str, Any]:
    try:
        from agi_dw.tools.patch_policy import load_env_limits, validate_unified_diff  # type: ignore
        ok, why, meta = validate_unified_diff(req.diff, load_env_limits(strict_default=True))
        if not ok:
            return {"ok": False, "policy": {"ok": False, "why": why, **(meta or {})}}
    except Exception:
        pass
    try:
        from agi_dw.tools.patch_actuator import apply_unified_diff  # type: ignore
        res = apply_unified_diff(req.repo, req.diff, allow_globs=["**/*"], block_globs=["**/.ssh/**"], max_files=50, dry_run=False)
    except Exception as e:
        raise HTTPException(status_code=400, detail=f"apply_failed: {e}")
    return {"ok": bool(res.get("ok", False)), "result": res}

@app.post("/api/dev/run_tests")
async def dev_run_tests(req: TestRequest) -> Dict[str, Any]:
    try:
        p = await asyncio.create_subprocess_exec(
            "pytest", *([x for x in str(req.pytest_args or "-q").split(" ") if x]),
            cwd=str(Path(req.repo)), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
        )
        out, err = await p.communicate()
        return {"ok": p.returncode == 0, "rc": p.returncode, "stdout": out.decode(errors="replace"), "stderr": err.decode(errors="replace")}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.post("/api/dev/revert")
async def dev_revert(payload: Dict[str, Any]) -> Dict[str, Any]:
    repo = str(payload.get("repo", ""))
    if not repo:
        raise HTTPException(status_code=400, detail="repo required")
    try:
        p = await asyncio.create_subprocess_exec("git", "reset", "--hard", cwd=repo, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
        out, err = await p.communicate()
        return {"ok": p.returncode == 0, "stdout": out.decode(errors="replace"), "stderr": err.decode(errors="replace")}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

# ---------------- Model-driven Web Browser Sessions ----------------
def _make_chrome_options_with_logs():
    try:
        import undetected_chromedriver as uc  # type: ignore
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"undetected-chromedriver not available: {e}")
    opts = uc.ChromeOptions()


    try:
        from agi_dw.tools.patch_actuator import apply_unified_diff  # type: ignore
        res = apply_unified_diff(req.repo, req.diff, allow_globs=["**/*"], block_globs=["**/.ssh/**"], max_files=50, dry_run=False)
    except Exception as e:
        raise HTTPException(status_code=400, detail=f"apply_failed: {e}")
    return {"ok": bool(res.get("ok", False)), "result": res}

@app.post("/api/dev/run_tests")
async def dev_run_tests(req: TestRequest) -> Dict[str, Any]:
    try:
        p = await asyncio.create_subprocess_exec(
            "pytest", *([x for x in str(req.pytest_args or "-q").split(" ") if x]),
            cwd=str(Path(req.repo)), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
        )
        out, err = await p.communicate()
        return {"ok": p.returncode == 0, "rc": p.returncode, "stdout": out.decode(errors="replace"), "stderr": err.decode(errors="replace")}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.post("/api/dev/revert")
async def dev_revert(payload: Dict[str, Any]) -> Dict[str, Any]:
    repo = str(payload.get("repo", ""))
    if not repo:
        raise HTTPException(status_code=400, detail="repo required")
    try:
        p = await asyncio.create_subprocess_exec("git", "reset", "--hard", cwd=repo, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
        out, err = await p.communicate()
        return {"ok": p.returncode == 0, "stdout": out.decode(errors="replace"), "stderr": err.decode(errors="replace")}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

# ---------------- Model-driven Web Browser Sessions ----------------
def _make_chrome_options_with_logs():
    try:
        import undetected_chromedriver as uc  # type: ignore
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"undetected-chromedriver not available: {e}")
    opts = uc.ChromeOptions()
    opts.add_argument("--headless=new")
    opts.add_argument("--no-sandbox")
    opts.add_argument("--disable-dev-shm-usage")
    try:
        opts.page_load_strategy = 'eager'  # type: ignore[attr-defined]
    except Exception:
        pass
    try:
        # Enable console logging
        opts.set_capability('goog:loggingPrefs', {'browser': 'ALL'})
    except Exception:
        pass


            cwd=str(Path(req.repo)), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
        )
        out, err = await p.communicate()
        return {"ok": p.returncode == 0, "rc": p.returncode, "stdout": out.decode(errors="replace"), "stderr": err.decode(errors="replace")}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.post("/api/dev/revert")
async def dev_revert(payload: Dict[str, Any]) -> Dict[str, Any]:
    repo = str(payload.get("repo", ""))
    if not repo:
        raise HTTPException(status_code=400, detail="repo required")
    try:
        p = await asyncio.create_subprocess_exec("git", "reset", "--hard", cwd=repo, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
        out, err = await p.communicate()
        return {"ok": p.returncode == 0, "stdout": out.decode(errors="replace"), "stderr": err.decode(errors="replace")}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

# ---------------- Model-driven Web Browser Sessions ----------------
def _make_chrome_options_with_logs():
    try:
        import undetected_chromedriver as uc  # type: ignore
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"undetected-chromedriver not available: {e}")
    opts = uc.ChromeOptions()
    opts.add_argument("--headless=new")
    opts.add_argument("--no-sandbox")
    opts.add_argument("--disable-dev-shm-usage")
    try:
        opts.page_load_strategy = 'eager'  # type: ignore[attr-defined]
    except Exception:
        pass
    try:
        # Enable console logging
        opts.set_capability('goog:loggingPrefs', {'browser': 'ALL'})
    except Exception:
        pass
    return opts

def _get_console_logs(driver: Any) -> List[Dict[str, Any]]:
    logs: List[Dict[str, Any]] = []
    try:
        entries = driver.get_log('browser')
        for e in entries or []:
            try:
                logs.append({"level": e.get('level'), "message": e.get('message'), "ts": e.get('timestamp')})
            except Exception:
                continue
    except Exception:
        logs = []
    return logs

class WebStartRequest(BaseModel):
    url: str

@app.post("/api/web/session/start")
async def web_session_start(req: WebStartRequest) -> Dict[str, Any]:
    try:


def _make_chrome_options_with_logs():
    try:
        import undetected_chromedriver as uc  # type: ignore
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"undetected-chromedriver not available: {e}")
    opts = uc.ChromeOptions()
    opts.add_argument("--headless=new")
    opts.add_argument("--no-sandbox")
    opts.add_argument("--disable-dev-shm-usage")
    try:
        opts.page_load_strategy = 'eager'  # type: ignore[attr-defined]
    except Exception:
        pass
    try:
        # Enable console logging
        opts.set_capability('goog:loggingPrefs', {'browser': 'ALL'})
    except Exception:
        pass
    return opts

def _get_console_logs(driver: Any) -> List[Dict[str, Any]]:
    logs: List[Dict[str, Any]] = []
    try:
        entries = driver.get_log('browser')
        for e in entries or []:
            try:
                logs.append({"level": e.get('level'), "message": e.get('message'), "ts": e.get('timestamp')})
            except Exception:
                continue
    except Exception:
        logs = []
    return logs

class WebStartRequest(BaseModel):
    url: str

@app.post("/api/web/session/start")
async def web_session_start(req: WebStartRequest) -> Dict[str, Any]:
    try:
        import undetected_chromedriver as uc  # type: ignore
        from selenium.webdriver.common.by import By  # type: ignore
        from selenium.webdriver.support.ui import WebDriverWait  # type: ignore
        from selenium.webdriver.support import expected_conditions as EC  # type: ignore
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"selenium/uc missing: {e}")
    opts = _make_chrome_options_with_logs()
    try:
        driver = uc.Chrome(options=opts)
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"driver init failed: {e}")
    try:
        driver.get(req.url)


WebStartRequest

    try:
        # Enable console logging
        opts.set_capability('goog:loggingPrefs', {'browser': 'ALL'})
    except Exception:
        pass
    return opts

def _get_console_logs(driver: Any) -> List[Dict[str, Any]]:
    logs: List[Dict[str, Any]] = []
    try:
        entries = driver.get_log('browser')
        for e in entries or []:
            try:
                logs.append({"level": e.get('level'), "message": e.get('message'), "ts": e.get('timestamp')})
            except Exception:
                continue
    except Exception:
        logs = []
    return logs

class WebStartRequest(BaseModel):
    url: str

@app.post("/api/web/session/start")
async def web_session_start(req: WebStartRequest) -> Dict[str, Any]:
    try:
        import undetected_chromedriver as uc  # type: ignore
        from selenium.webdriver.common.by import By  # type: ignore
        from selenium.webdriver.support.ui import WebDriverWait  # type: ignore
        from selenium.webdriver.support import expected_conditions as EC  # type: ignore
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"selenium/uc missing: {e}")
    opts = _make_chrome_options_with_logs()
    try:
        driver = uc.Chrome(options=opts)
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"driver init failed: {e}")
    try:
        driver.get(req.url)
        # wait minimal
        try:
            WebDriverWait(driver, 5).until(lambda d: d.execute_script('return document.readyState') in ('interactive','complete'))


        pass
    return opts

def _get_console_logs(driver: Any) -> List[Dict[str, Any]]:
    logs: List[Dict[str, Any]] = []
    try:
        entries = driver.get_log('browser')
        for e in entries or []:
            try:
                logs.append({"level": e.get('level'), "message": e.get('message'), "ts": e.get('timestamp')})
            except Exception:
                continue
    except Exception:
        logs = []
    return logs

class WebStartRequest(BaseModel):
    url: str

@app.post("/api/web/session/start")
async def web_session_start(req: WebStartRequest) -> Dict[str, Any]:
    try:
        import undetected_chromedriver as uc  # type: ignore
        from selenium.webdriver.common.by import By  # type: ignore
        from selenium.webdriver.support.ui import WebDriverWait  # type: ignore
        from selenium.webdriver.support import expected_conditions as EC  # type: ignore
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"selenium/uc missing: {e}")
    opts = _make_chrome_options_with_logs()
    try:
        driver = uc.Chrome(options=opts)
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"driver init failed: {e}")
    try:
        driver.get(req.url)
        # wait minimal
        try:
            WebDriverWait(driver, 5).until(lambda d: d.execute_script('return document.readyState') in ('interactive','complete'))
        except Exception:
            pass
        html = driver.page_source or ""
        logs = _get_console_logs(driver)
        sid = uuid.uuid4().hex
        with web_sessions_lock:
            web_sessions[sid] = {"driver": driver, "url": driver.current_url, "started_at": datetime.now().isoformat(), "last_logs": len(logs)}
        return {"session_id": sid, "url": driver.current_url, "html": html, "console": logs}
    except Exception as e:
        try:
            driver.quit()
        except Exception:
            pass
        raise HTTPException(status_code=500, detail=str(e))

class WebQueryRequest(BaseModel):
    session_id: str
    selector: str
    k: Optional[int] = 10

@app.post("/api/web/session/dom_query")
async def web_session_dom_query(req: WebQueryRequest) -> Dict[str, Any]:
    with web_sessions_lock:
        s = web_sessions.get(req.session_id)
    if not s:
        raise HTTPException(status_code=404, detail="session not found")
    driver = s.get("driver")
    try:
        # Collect basic snapshots via JS for speed
        script = (
            "var sel=arguments[0],k=arguments[1];"
            "var els=document.querySelectorAll(sel);"
            "var out=[]; for (var i=0;i<els.length && i<(k||10); i++){var el=els[i]; out.push({tag:el.tagName, id:el.id, class:el.className, text:(el.innerText||el.textContent||'').trim(), href:el.href||'', value:el.value||''});} return out;"
        )


WebQueryRequest

    try:
        driver.get(req.url)
        # wait minimal
        try:
            WebDriverWait(driver, 5).until(lambda d: d.execute_script('return document.readyState') in ('interactive','complete'))
        except Exception:
            pass
        html = driver.page_source or ""
        logs = _get_console_logs(driver)
        sid = uuid.uuid4().hex
        with web_sessions_lock:
            web_sessions[sid] = {"driver": driver, "url": driver.current_url, "started_at": datetime.now().isoformat(), "last_logs": len(logs)}
        return {"session_id": sid, "url": driver.current_url, "html": html, "console": logs}
    except Exception as e:
        try:
            driver.quit()
        except Exception:
            pass
        raise HTTPException(status_code=500, detail=str(e))

class WebQueryRequest(BaseModel):
    session_id: str
    selector: str
    k: Optional[int] = 10

@app.post("/api/web/session/dom_query")
async def web_session_dom_query(req: WebQueryRequest) -> Dict[str, Any]:
    with web_sessions_lock:
        s = web_sessions.get(req.session_id)
    if not s:
        raise HTTPException(status_code=404, detail="session not found")
    driver = s.get("driver")
    try:
        # Collect basic snapshots via JS for speed
        script = (
            "var sel=arguments[0],k=arguments[1];"
            "var els=document.querySelectorAll(sel);"
            "var out=[]; for (var i=0;i<els.length && i<(k||10); i++){var el=els[i]; out.push({tag:el.tagName, id:el.id, class:el.className, text:(el.innerText||el.textContent||'').trim(), href:el.href||'', value:el.value||''});} return out;"
        )
        nodes = driver.execute_script(script, req.selector, int(req.k or 10))
        return {"ok": True, "nodes": nodes}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


            pass
        html = driver.page_source or ""
        logs = _get_console_logs(driver)
        sid = uuid.uuid4().hex
        with web_sessions_lock:
            web_sessions[sid] = {"driver": driver, "url": driver.current_url, "started_at": datetime.now().isoformat(), "last_logs": len(logs)}
        return {"session_id": sid, "url": driver.current_url, "html": html, "console": logs}
    except Exception as e:
        try:
            driver.quit()
        except Exception:
            pass
        raise HTTPException(status_code=500, detail=str(e))

class WebQueryRequest(BaseModel):
    session_id: str
    selector: str
    k: Optional[int] = 10

@app.post("/api/web/session/dom_query")
async def web_session_dom_query(req: WebQueryRequest) -> Dict[str, Any]:
    with web_sessions_lock:
        s = web_sessions.get(req.session_id)
    if not s:
        raise HTTPException(status_code=404, detail="session not found")
    driver = s.get("driver")
    try:
        # Collect basic snapshots via JS for speed
        script = (
            "var sel=arguments[0],k=arguments[1];"
            "var els=document.querySelectorAll(sel);"
            "var out=[]; for (var i=0;i<els.length && i<(k||10); i++){var el=els[i]; out.push({tag:el.tagName, id:el.id, class:el.className, text:(el.innerText||el.textContent||'').trim(), href:el.href||'', value:el.value||''});} return out;"
        )
        nodes = driver.execute_script(script, req.selector, int(req.k or 10))
        return {"ok": True, "nodes": nodes}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

class WebActRequest(BaseModel):
    session_id: str
    action: str  # click|type|nav|submit
    target: Optional[str] = None  # CSS selector for click/type/submit
    value: Optional[str] = None   # for type/nav
    wait: Optional[str] = None    # CSS selector to wait for visible

@app.post("/api/web/session/dom_act")
async def web_session_dom_act(req: WebActRequest) -> Dict[str, Any]:
    with web_sessions_lock:
        s = web_sessions.get(req.session_id)
    if not s:
        raise HTTPException(status_code=404, detail="session not found")
    driver = s.get("driver")
    try:
        from selenium.webdriver.common.by import By  # type: ignore
        from selenium.webdriver.support.ui import WebDriverWait  # type: ignore
        from selenium.webdriver.support import expected_conditions as EC  # type: ignore
        if req.action == "nav":


WebActRequest

@app.post("/api/web/session/dom_query")
async def web_session_dom_query(req: WebQueryRequest) -> Dict[str, Any]:
    with web_sessions_lock:
        s = web_sessions.get(req.session_id)
    if not s:
        raise HTTPException(status_code=404, detail="session not found")
    driver = s.get("driver")
    try:
        # Collect basic snapshots via JS for speed
        script = (
            "var sel=arguments[0],k=arguments[1];"
            "var els=document.querySelectorAll(sel);"
            "var out=[]; for (var i=0;i<els.length && i<(k||10); i++){var el=els[i]; out.push({tag:el.tagName, id:el.id, class:el.className, text:(el.innerText||el.textContent||'').trim(), href:el.href||'', value:el.value||''});} return out;"
        )
        nodes = driver.execute_script(script, req.selector, int(req.k or 10))
        return {"ok": True, "nodes": nodes}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

class WebActRequest(BaseModel):
    session_id: str
    action: str  # click|type|nav|submit
    target: Optional[str] = None  # CSS selector for click/type/submit
    value: Optional[str] = None   # for type/nav
    wait: Optional[str] = None    # CSS selector to wait for visible

@app.post("/api/web/session/dom_act")
async def web_session_dom_act(req: WebActRequest) -> Dict[str, Any]:
    with web_sessions_lock:
        s = web_sessions.get(req.session_id)
    if not s:
        raise HTTPException(status_code=404, detail="session not found")
    driver = s.get("driver")
    try:
        from selenium.webdriver.common.by import By  # type: ignore
        from selenium.webdriver.support.ui import WebDriverWait  # type: ignore
        from selenium.webdriver.support import expected_conditions as EC  # type: ignore
        if req.action == "nav":
            driver.get(str(req.value or ""))
        elif req.action == "click":
            el = driver.find_element(By.CSS_SELECTOR, str(req.target or ""))
            el.click()
        elif req.action == "type":
            el = driver.find_element(By.CSS_SELECTOR, str(req.target or ""))
            el.clear()


    try:
        # Collect basic snapshots via JS for speed
        script = (
            "var sel=arguments[0],k=arguments[1];"
            "var els=document.querySelectorAll(sel);"
            "var out=[]; for (var i=0;i<els.length && i<(k||10); i++){var el=els[i]; out.push({tag:el.tagName, id:el.id, class:el.className, text:(el.innerText||el.textContent||'').trim(), href:el.href||'', value:el.value||''});} return out;"
        )
        nodes = driver.execute_script(script, req.selector, int(req.k or 10))
        return {"ok": True, "nodes": nodes}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

class WebActRequest(BaseModel):
    session_id: str
    action: str  # click|type|nav|submit
    target: Optional[str] = None  # CSS selector for click/type/submit
    value: Optional[str] = None   # for type/nav
    wait: Optional[str] = None    # CSS selector to wait for visible

@app.post("/api/web/session/dom_act")
async def web_session_dom_act(req: WebActRequest) -> Dict[str, Any]:
    with web_sessions_lock:
        s = web_sessions.get(req.session_id)
    if not s:
        raise HTTPException(status_code=404, detail="session not found")
    driver = s.get("driver")
    try:
        from selenium.webdriver.common.by import By  # type: ignore
        from selenium.webdriver.support.ui import WebDriverWait  # type: ignore
        from selenium.webdriver.support import expected_conditions as EC  # type: ignore
        if req.action == "nav":
            driver.get(str(req.value or ""))
        elif req.action == "click":
            el = driver.find_element(By.CSS_SELECTOR, str(req.target or ""))
            el.click()
        elif req.action == "type":
            el = driver.find_element(By.CSS_SELECTOR, str(req.target or ""))
            el.clear()
            el.send_keys(str(req.value or ""))
        elif req.action == "submit":
            el = driver.find_element(By.CSS_SELECTOR, str(req.target or ""))
            el.submit()
        else:
            raise HTTPException(status_code=400, detail="unsupported action")
        if req.wait:
            try:
                WebDriverWait(driver, 8).until(EC.visibility_of_element_located((By.CSS_SELECTOR, req.wait)))
            except Exception:
                pass
        html = driver.page_source or ""
        logs = _get_console_logs(driver)
        with web_sessions_lock:
            s["url"] = driver.current_url
            s["last_logs"] = len(logs)
        return {"ok": True, "url": driver.current_url, "html": html, "console": logs}
    except HTTPException:
        raise
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.get("/api/web/session/state")
async def web_session_state(session_id: str) -> Dict[str, Any]:
    with web_sessions_lock:
        s = web_sessions.get(session_id)
    if not s:
        raise HTTPException(status_code=404, detail="session not found")
    driver = s.get("driver")
    try:
        html = driver.page_source or ""
        logs = _get_console_logs(driver)
        return {"url": driver.current_url, "html": html, "console": logs}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.post("/api/web/session/end")
async def web_session_end(payload: Dict[str, Any]) -> Dict[str, Any]:
    sid = str(payload.get("session_id", ""))
    with web_sessions_lock:
        s = web_sessions.pop(sid, None)


            el.submit()
        else:
            raise HTTPException(status_code=400, detail="unsupported action")
        if req.wait:
            try:
                WebDriverWait(driver, 8).until(EC.visibility_of_element_located((By.CSS_SELECTOR, req.wait)))
            except Exception:
                pass
        html = driver.page_source or ""
        logs = _get_console_logs(driver)
        with web_sessions_lock:
            s["url"] = driver.current_url
            s["last_logs"] = len(logs)
        return {"ok": True, "url": driver.current_url, "html": html, "console": logs}
    except HTTPException:
        raise
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.get("/api/web/session/state")
async def web_session_state(session_id: str) -> Dict[str, Any]:
    with web_sessions_lock:
        s = web_sessions.get(session_id)
    if not s:
        raise HTTPException(status_code=404, detail="session not found")
    driver = s.get("driver")
    try:
        html = driver.page_source or ""
        logs = _get_console_logs(driver)
        return {"url": driver.current_url, "html": html, "console": logs}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.post("/api/web/session/end")
async def web_session_end(payload: Dict[str, Any]) -> Dict[str, Any]:
    sid = str(payload.get("session_id", ""))
    with web_sessions_lock:
        s = web_sessions.pop(sid, None)
    if not s:
        return {"ok": True, "note": "already closed"}
    try:
        drv = s.get("driver")
        if drv:
            drv.quit()
    except Exception:
        pass
    return {"ok": True}

@app.get("/api/web/session/list")
async def web_session_list(offset: int = 0, limit: int = 100) -> Dict[str, Any]:
    with web_sessions_lock:
        items = []


    except HTTPException:
        raise
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.get("/api/web/session/state")
async def web_session_state(session_id: str) -> Dict[str, Any]:
    with web_sessions_lock:
        s = web_sessions.get(session_id)
    if not s:
        raise HTTPException(status_code=404, detail="session not found")
    driver = s.get("driver")
    try:
        html = driver.page_source or ""
        logs = _get_console_logs(driver)
        return {"url": driver.current_url, "html": html, "console": logs}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.post("/api/web/session/end")
async def web_session_end(payload: Dict[str, Any]) -> Dict[str, Any]:
    sid = str(payload.get("session_id", ""))
    with web_sessions_lock:
        s = web_sessions.pop(sid, None)
    if not s:
        return {"ok": True, "note": "already closed"}
    try:
        drv = s.get("driver")
        if drv:
            drv.quit()
    except Exception:
        pass
    return {"ok": True}

@app.get("/api/web/session/list")
async def web_session_list(offset: int = 0, limit: int = 100) -> Dict[str, Any]:
    with web_sessions_lock:
        items = []
        for sid, s in web_sessions.items():
            try:
                items.append({"session_id": sid, "url": s.get("url"), "started_at": s.get("started_at")})
            except Exception:
                continue
    total = len(items)
    start = max(0, int(offset))
    end = max(start, min(total, start + int(limit)))
    return {"items": items[start:end], "total": total, "offset": start, "limit": int(limit)}

# ---------------- Compose (multi-step runner with traces) ----------------
class ComposeStep(BaseModel):
    tool: str  # run_cmd|apply_patch|dev_run_tests|web_dom_act|web_dom_query
    args: Dict[str, Any]


        return {"url": driver.current_url, "html": html, "console": logs}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.post("/api/web/session/end")
async def web_session_end(payload: Dict[str, Any]) -> Dict[str, Any]:
    sid = str(payload.get("session_id", ""))
    with web_sessions_lock:
        s = web_sessions.pop(sid, None)
    if not s:
        return {"ok": True, "note": "already closed"}
    try:
        drv = s.get("driver")
        if drv:
            drv.quit()
    except Exception:
        pass
    return {"ok": True}

@app.get("/api/web/session/list")
async def web_session_list(offset: int = 0, limit: int = 100) -> Dict[str, Any]:
    with web_sessions_lock:
        items = []
        for sid, s in web_sessions.items():
            try:
                items.append({"session_id": sid, "url": s.get("url"), "started_at": s.get("started_at")})
            except Exception:
                continue
    total = len(items)
    start = max(0, int(offset))
    end = max(start, min(total, start + int(limit)))
    return {"items": items[start:end], "total": total, "offset": start, "limit": int(limit)}

# ---------------- Compose (multi-step runner with traces) ----------------
class ComposeStep(BaseModel):
    tool: str  # run_cmd|apply_patch|dev_run_tests|web_dom_act|web_dom_query
    args: Dict[str, Any]

class ComposeRequest(BaseModel):
    title: str
    steps: Sequence[ComposeStep]
    idempotency_key: Optional[str] = None

@app.post("/api/dev/compose")
async def dev_compose(req: ComposeRequest) -> Dict[str, Any]:
    root = WORKSPACE_DIR
    trace_dir = root / "data" / "traces" / "compose"
    trace_dir.mkdir(parents=True, exist_ok=True)
    # Idempotency: reuse run id if key provided and a file exists
    if req.idempotency_key:
        safe_key = re.sub(r"[^a-zA-Z0-9_-]", "_", req.idempotency_key)
        run_id = safe_key


ComposeStep

            drv.quit()
    except Exception:
        pass
    return {"ok": True}

@app.get("/api/web/session/list")
async def web_session_list(offset: int = 0, limit: int = 100) -> Dict[str, Any]:
    with web_sessions_lock:
        items = []
        for sid, s in web_sessions.items():
            try:
                items.append({"session_id": sid, "url": s.get("url"), "started_at": s.get("started_at")})
            except Exception:
                continue
    total = len(items)
    start = max(0, int(offset))
    end = max(start, min(total, start + int(limit)))
    return {"items": items[start:end], "total": total, "offset": start, "limit": int(limit)}

# ---------------- Compose (multi-step runner with traces) ----------------
class ComposeStep(BaseModel):
    tool: str  # run_cmd|apply_patch|dev_run_tests|web_dom_act|web_dom_query
    args: Dict[str, Any]

class ComposeRequest(BaseModel):
    title: str
    steps: Sequence[ComposeStep]
    idempotency_key: Optional[str] = None

@app.post("/api/dev/compose")
async def dev_compose(req: ComposeRequest) -> Dict[str, Any]:
    root = WORKSPACE_DIR
    trace_dir = root / "data" / "traces" / "compose"
    trace_dir.mkdir(parents=True, exist_ok=True)
    # Idempotency: reuse run id if key provided and a file exists
    if req.idempotency_key:
        safe_key = re.sub(r"[^a-zA-Z0-9_-]", "_", req.idempotency_key)
        run_id = safe_key
    else:
        run_id = datetime.now().strftime("%Y%m%dT%H%M%SZ") + "_" + uuid.uuid4().hex[:6]
    out = trace_dir / f"{run_id}.jsonl"
    if req.idempotency_key and out.exists():
        # Return existing summary tail


ComposeRequest

@app.get("/api/web/session/list")
async def web_session_list(offset: int = 0, limit: int = 100) -> Dict[str, Any]:
    with web_sessions_lock:
        items = []
        for sid, s in web_sessions.items():
            try:
                items.append({"session_id": sid, "url": s.get("url"), "started_at": s.get("started_at")})
            except Exception:
                continue
    total = len(items)
    start = max(0, int(offset))
    end = max(start, min(total, start + int(limit)))
    return {"items": items[start:end], "total": total, "offset": start, "limit": int(limit)}

# ---------------- Compose (multi-step runner with traces) ----------------
class ComposeStep(BaseModel):
    tool: str  # run_cmd|apply_patch|dev_run_tests|web_dom_act|web_dom_query
    args: Dict[str, Any]

class ComposeRequest(BaseModel):
    title: str
    steps: Sequence[ComposeStep]
    idempotency_key: Optional[str] = None

@app.post("/api/dev/compose")
async def dev_compose(req: ComposeRequest) -> Dict[str, Any]:
    root = WORKSPACE_DIR
    trace_dir = root / "data" / "traces" / "compose"
    trace_dir.mkdir(parents=True, exist_ok=True)
    # Idempotency: reuse run id if key provided and a file exists
    if req.idempotency_key:
        safe_key = re.sub(r"[^a-zA-Z0-9_-]", "_", req.idempotency_key)
        run_id = safe_key
    else:
        run_id = datetime.now().strftime("%Y%m%dT%H%M%SZ") + "_" + uuid.uuid4().hex[:6]
    out = trace_dir / f"{run_id}.jsonl"
    if req.idempotency_key and out.exists():
        # Return existing summary tail
        try:
            with out.open("r", encoding="utf-8") as f:
                lines = f.readlines()[-50:]
            return {"run_id": run_id, "trace": str(out.relative_to(root)), "status": "idempotent_reuse", "tail": [l.strip() for l in lines]}
        except Exception:


            try:
                items.append({"session_id": sid, "url": s.get("url"), "started_at": s.get("started_at")})
            except Exception:
                continue
    total = len(items)
    start = max(0, int(offset))
    end = max(start, min(total, start + int(limit)))
    return {"items": items[start:end], "total": total, "offset": start, "limit": int(limit)}

# ---------------- Compose (multi-step runner with traces) ----------------
class ComposeStep(BaseModel):
    tool: str  # run_cmd|apply_patch|dev_run_tests|web_dom_act|web_dom_query
    args: Dict[str, Any]

class ComposeRequest(BaseModel):
    title: str
    steps: Sequence[ComposeStep]
    idempotency_key: Optional[str] = None

@app.post("/api/dev/compose")
async def dev_compose(req: ComposeRequest) -> Dict[str, Any]:
    root = WORKSPACE_DIR
    trace_dir = root / "data" / "traces" / "compose"
    trace_dir.mkdir(parents=True, exist_ok=True)
    # Idempotency: reuse run id if key provided and a file exists
    if req.idempotency_key:
        safe_key = re.sub(r"[^a-zA-Z0-9_-]", "_", req.idempotency_key)
        run_id = safe_key
    else:
        run_id = datetime.now().strftime("%Y%m%dT%H%M%SZ") + "_" + uuid.uuid4().hex[:6]
    out = trace_dir / f"{run_id}.jsonl"
    if req.idempotency_key and out.exists():
        # Return existing summary tail
        try:
            with out.open("r", encoding="utf-8") as f:
                lines = f.readlines()[-50:]
            return {"run_id": run_id, "trace": str(out.relative_to(root)), "status": "idempotent_reuse", "tail": [l.strip() for l in lines]}
        except Exception:
            return {"run_id": run_id, "trace": str(out.relative_to(root)), "status": "idempotent_reuse"}
    def _write(obj: Dict[str, Any]) -> None:
        try:
            with out.open("a", encoding="utf-8") as f:
                f.write(json.dumps(obj, ensure_ascii=False) + "\n")
        except Exception:
            pass
    summary: List[Dict[str, Any]] = []
    for i, st in enumerate(req.steps):
        rec_base = {"i": i, "tool": st.tool, "args": st.args, "ts": datetime.now().isoformat()}
        try:
            if st.tool == "run_cmd":
                argv = st.args.get("argv") or []
                cwd = st.args.get("cwd") or str(root)
                p = await asyncio.create_subprocess_exec(*argv, cwd=str(cwd), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
                out_b, err_b = await p.communicate()
                res = {"rc": p.returncode, "stdout": out_b.decode(errors="replace"), "stderr": err_b.decode(errors="replace")}
                _write({**rec_base, "result": res})
                summary.append({"tool": st.tool, "ok": p.returncode == 0})
            elif st.tool == "apply_patch":
                from agi_dw.tools.patch_actuator import apply_unified_diff  # type: ignore
                res = apply_unified_diff(st.args.get("repo"), st.args.get("diff", ""), st.args.get("allow_globs", ["**/*"]), st.args.get("block_globs", ["**/.ssh/**"]), max_files=int(st.args.get("max_files", 50)), dry_run=bool(st.args.get("dry_run", False)))
                _write({**rec_base, "result": res})
                summary.append({"tool": st.tool, "ok": bool(res.get("ok"))})
            elif st.tool == "dev_run_tests":
                res = await dev_run_tests(TestRequest(repo=str(st.args.get("repo")), pytest_args=str(st.args.get("pytest_args", "-q"))))
                _write({**rec_base, "result": res})
                summary.append({"tool": st.tool, "ok": bool(res.get("ok"))})
            elif st.tool == "web_dom_act":
                res = await web_session_dom_act(WebActRequest(**st.args))
                _write({**rec_base, "result": {"ok": res.get("ok"), "url": res.get("url")}})
                summary.append({"tool": st.tool, "ok": bool(res.get("ok"))})
            elif st.tool == "web_dom_query":
                res = await web_session_dom_query(WebQueryRequest(**st.args))
                _write({**rec_base, "result": {"ok": True, "n": len(res.get("nodes", []))}})
                summary.append({"tool": st.tool, "ok": True})
            elif st.tool == "bench_dashboard":
                # Build/update benchmarks dashboard JSON
                p = await asyncio.create_subprocess_exec(
                    "python3",
                    str(WORKSPACE_DIR / "scripts" / "bench" / "aggregate_benchmarks.py"),
                    "--results-dir",
                    str(st.args.get("results_dir", WORKSPACE_DIR / "data" / "bench" / "results")),
                    "--out",
                    str(st.args.get("out", WORKSPACE_DIR / "data" / "dashboards" / "benchmarks.json")),
                    stdout=asyncio.subprocess.PIPE,
                    stderr=asyncio.subprocess.PIPE,
                )
                out_b, err_b = await p.communicate()
                ok = (p.returncode == 0)
                _write({**rec_base, "result": {"ok": ok, "stdout": out_b.decode(errors="replace"), "stderr": err_b.decode(errors="replace")}})
                summary.append({"tool": st.tool, "ok": ok})
            elif st.tool == "bench_gate":
                # Enforce acceptance thresholds using ci_assert_bench_accept
                suites = st.args.get("suites", "humaneval,mbpp,swebench_lite")
                thresholds = str(st.args.get("thresholds", WORKSPACE_DIR / "data" / "bench" / "acceptance_thresholds.json"))
                dashboard = str(st.args.get("dashboard", WORKSPACE_DIR / "data" / "dashboards" / "benchmarks.json"))
                p = await asyncio.create_subprocess_exec(
                    "python3",
                    str(WORKSPACE_DIR / "scripts" / "bench" / "ci_assert_bench_accept.py"),
                    "--suites", suites,
                    "--thresholds", thresholds,
                    "--dashboard", dashboard,
                    stdout=asyncio.subprocess.PIPE,
                    stderr=asyncio.subprocess.PIPE,
                )
                out_b, err_b = await p.communicate()
                gate_json = {}
                try:
                    gate_json = json.loads(out_b.decode("utf-8", errors="replace").splitlines()[-1]) if out_b else {}
                except Exception:
                    gate_json = {"ok": (p.returncode == 0)}
                ok = bool(gate_json.get("ok", p.returncode == 0))
                _write({**rec_base, "result": gate_json, "stderr": err_b.decode(errors="replace")})
                summary.append({"tool": st.tool, "ok": ok})
            else:
                _write({**rec_base, "error": "unsupported_tool"})
                summary.append({"tool": st.tool, "ok": False})
        except Exception as e:
            _write({**rec_base, "error": str(e)})
            summary.append({"tool": st.tool, "ok": False})
            break
    return {"run_id": run_id, "trace": str(out.relative_to(root)), "summary": summary}

# ---------------- Tools registry ----------------
@app.get("/api/tools/registry")
async def tools_registry() -> Dict[str, Any]:
    reg = WORKSPACE_DIR / "tools" / "registry.yaml"
    if not reg.exists():
        raise HTTPException(status_code=404, detail="registry.yaml not found")
    try:
        import yaml  # type: ignore
        obj = yaml.safe_load(reg.read_text(encoding="utf-8"))
        return {"path": str(reg.relative_to(WORKSPACE_DIR)), "registry": obj}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

# ---------------- File I/O helpers ----------------
def _sha256_text(s: str) -> str:
    try:
        return hashlib.sha256(s.encode("utf-8")).hexdigest()


                try:
                    gate_json = json.loads(out_b.decode("utf-8", errors="replace").splitlines()[-1]) if out_b else {}
                except Exception:
                    gate_json = {"ok": (p.returncode == 0)}
                ok = bool(gate_json.get("ok", p.returncode == 0))
                _write({**rec_base, "result": gate_json, "stderr": err_b.decode(errors="replace")})
                summary.append({"tool": st.tool, "ok": ok})
            else:
                _write({**rec_base, "error": "unsupported_tool"})
                summary.append({"tool": st.tool, "ok": False})
        except Exception as e:
            _write({**rec_base, "error": str(e)})
            summary.append({"tool": st.tool, "ok": False})
            break
    return {"run_id": run_id, "trace": str(out.relative_to(root)), "summary": summary}

# ---------------- Tools registry ----------------
@app.get("/api/tools/registry")
async def tools_registry() -> Dict[str, Any]:
    reg = WORKSPACE_DIR / "tools" / "registry.yaml"
    if not reg.exists():
        raise HTTPException(status_code=404, detail="registry.yaml not found")
    try:
        import yaml  # type: ignore
        obj = yaml.safe_load(reg.read_text(encoding="utf-8"))
        return {"path": str(reg.relative_to(WORKSPACE_DIR)), "registry": obj}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

# ---------------- File I/O helpers ----------------
def _sha256_text(s: str) -> str:
    try:
        return hashlib.sha256(s.encode("utf-8")).hexdigest()
    except Exception:
        return ""

class FileReadRequest(BaseModel):
    path: str
    base: Optional[str] = "repo"

class FileWriteRequest(BaseModel):
    path: str
    content: str
    base: Optional[str] = "repo"
    mode: Optional[str] = "w"

@app.post("/api/dev/file/read")
async def dev_file_read(req: FileReadRequest) -> Dict[str, Any]:


            summary.append({"tool": st.tool, "ok": False})
            break
    return {"run_id": run_id, "trace": str(out.relative_to(root)), "summary": summary}

# ---------------- Tools registry ----------------
@app.get("/api/tools/registry")
async def tools_registry() -> Dict[str, Any]:
    reg = WORKSPACE_DIR / "tools" / "registry.yaml"
    if not reg.exists():
        raise HTTPException(status_code=404, detail="registry.yaml not found")
    try:
        import yaml  # type: ignore
        obj = yaml.safe_load(reg.read_text(encoding="utf-8"))
        return {"path": str(reg.relative_to(WORKSPACE_DIR)), "registry": obj}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

# ---------------- File I/O helpers ----------------
def _sha256_text(s: str) -> str:
    try:
        return hashlib.sha256(s.encode("utf-8")).hexdigest()
    except Exception:
        return ""

class FileReadRequest(BaseModel):
    path: str
    base: Optional[str] = "repo"

class FileWriteRequest(BaseModel):
    path: str
    content: str
    base: Optional[str] = "repo"
    mode: Optional[str] = "w"

@app.post("/api/dev/file/read")
async def dev_file_read(req: FileReadRequest) -> Dict[str, Any]:
    root = _safe_root(req.base or "repo")
    full = (root / req.path).resolve()
    if not str(full).startswith(str(root.resolve())):
        raise HTTPException(status_code=403, detail="Access denied")
    if not full.exists() or not full.is_file():
        raise HTTPException(status_code=404, detail="File not found")
    try:


FileReadRequest

# ---------------- Tools registry ----------------
@app.get("/api/tools/registry")
async def tools_registry() -> Dict[str, Any]:
    reg = WORKSPACE_DIR / "tools" / "registry.yaml"
    if not reg.exists():
        raise HTTPException(status_code=404, detail="registry.yaml not found")
    try:
        import yaml  # type: ignore
        obj = yaml.safe_load(reg.read_text(encoding="utf-8"))
        return {"path": str(reg.relative_to(WORKSPACE_DIR)), "registry": obj}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

# ---------------- File I/O helpers ----------------
def _sha256_text(s: str) -> str:
    try:
        return hashlib.sha256(s.encode("utf-8")).hexdigest()
    except Exception:
        return ""

class FileReadRequest(BaseModel):
    path: str
    base: Optional[str] = "repo"

class FileWriteRequest(BaseModel):
    path: str
    content: str
    base: Optional[str] = "repo"
    mode: Optional[str] = "w"

@app.post("/api/dev/file/read")
async def dev_file_read(req: FileReadRequest) -> Dict[str, Any]:
    root = _safe_root(req.base or "repo")
    full = (root / req.path).resolve()
    if not str(full).startswith(str(root.resolve())):
        raise HTTPException(status_code=403, detail="Access denied")
    if not full.exists() or not full.is_file():
        raise HTTPException(status_code=404, detail="File not found")
    try:
        text = full.read_text(encoding="utf-8", errors="replace")
        return {"path": str(full.relative_to(root)), "content": text, "hash": _sha256_text(text)}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


FileWriteRequest

    if not reg.exists():
        raise HTTPException(status_code=404, detail="registry.yaml not found")
    try:
        import yaml  # type: ignore
        obj = yaml.safe_load(reg.read_text(encoding="utf-8"))
        return {"path": str(reg.relative_to(WORKSPACE_DIR)), "registry": obj}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

# ---------------- File I/O helpers ----------------
def _sha256_text(s: str) -> str:
    try:
        return hashlib.sha256(s.encode("utf-8")).hexdigest()
    except Exception:
        return ""

class FileReadRequest(BaseModel):
    path: str
    base: Optional[str] = "repo"

class FileWriteRequest(BaseModel):
    path: str
    content: str
    base: Optional[str] = "repo"
    mode: Optional[str] = "w"

@app.post("/api/dev/file/read")
async def dev_file_read(req: FileReadRequest) -> Dict[str, Any]:
    root = _safe_root(req.base or "repo")
    full = (root / req.path).resolve()
    if not str(full).startswith(str(root.resolve())):
        raise HTTPException(status_code=403, detail="Access denied")
    if not full.exists() or not full.is_file():
        raise HTTPException(status_code=404, detail="File not found")
    try:
        text = full.read_text(encoding="utf-8", errors="replace")
        return {"path": str(full.relative_to(root)), "content": text, "hash": _sha256_text(text)}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.post("/api/dev/file/write")
async def dev_file_write(req: FileWriteRequest) -> Dict[str, Any]:
    root = _safe_root(req.base or "repo")
    full = (root / req.path).resolve()
    if not str(full).startswith(str(root.resolve())):


        raise HTTPException(status_code=500, detail=str(e))

# ---------------- File I/O helpers ----------------
def _sha256_text(s: str) -> str:
    try:
        return hashlib.sha256(s.encode("utf-8")).hexdigest()
    except Exception:
        return ""

class FileReadRequest(BaseModel):
    path: str
    base: Optional[str] = "repo"

class FileWriteRequest(BaseModel):
    path: str
    content: str
    base: Optional[str] = "repo"
    mode: Optional[str] = "w"

@app.post("/api/dev/file/read")
async def dev_file_read(req: FileReadRequest) -> Dict[str, Any]:
    root = _safe_root(req.base or "repo")
    full = (root / req.path).resolve()
    if not str(full).startswith(str(root.resolve())):
        raise HTTPException(status_code=403, detail="Access denied")
    if not full.exists() or not full.is_file():
        raise HTTPException(status_code=404, detail="File not found")
    try:
        text = full.read_text(encoding="utf-8", errors="replace")
        return {"path": str(full.relative_to(root)), "content": text, "hash": _sha256_text(text)}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.post("/api/dev/file/write")
async def dev_file_write(req: FileWriteRequest) -> Dict[str, Any]:
    root = _safe_root(req.base or "repo")
    full = (root / req.path).resolve()
    if not str(full).startswith(str(root.resolve())):
        raise HTTPException(status_code=403, detail="Access denied")
    try:
        full.parent.mkdir(parents=True, exist_ok=True)
        mode = req.mode or "w"
        if mode not in ("w", "a"):
            raise HTTPException(status_code=400, detail="Invalid mode")
        with full.open(mode, encoding="utf-8") as f:
            f.write(req.content)
        text = full.read_text(encoding="utf-8", errors="replace")
        return {"ok": True, "path": str(full.relative_to(root)), "hash": _sha256_text(text)}
    except HTTPException:
        raise
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


    path: str
    content: str
    base: Optional[str] = "repo"
    mode: Optional[str] = "w"

@app.post("/api/dev/file/read")
async def dev_file_read(req: FileReadRequest) -> Dict[str, Any]:
    root = _safe_root(req.base or "repo")
    full = (root / req.path).resolve()
    if not str(full).startswith(str(root.resolve())):
        raise HTTPException(status_code=403, detail="Access denied")
    if not full.exists() or not full.is_file():
        raise HTTPException(status_code=404, detail="File not found")
    try:
        text = full.read_text(encoding="utf-8", errors="replace")
        return {"path": str(full.relative_to(root)), "content": text, "hash": _sha256_text(text)}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.post("/api/dev/file/write")
async def dev_file_write(req: FileWriteRequest) -> Dict[str, Any]:
    root = _safe_root(req.base or "repo")
    full = (root / req.path).resolve()
    if not str(full).startswith(str(root.resolve())):
        raise HTTPException(status_code=403, detail="Access denied")
    try:
        full.parent.mkdir(parents=True, exist_ok=True)
        mode = req.mode or "w"
        if mode not in ("w", "a"):
            raise HTTPException(status_code=400, detail="Invalid mode")
        with full.open(mode, encoding="utf-8") as f:
            f.write(req.content)
        text = full.read_text(encoding="utf-8", errors="replace")
        return {"ok": True, "path": str(full.relative_to(root)), "hash": _sha256_text(text)}
    except HTTPException:
        raise
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

# ---------------- Git status/diff ----------------
@app.get("/api/dev/git/status")
async def dev_git_status(repo: str) -> Dict[str, Any]:
    try:
        # branch
        p1 = await asyncio.create_subprocess_exec("git", "rev-parse", "--abbrev-ref", "HEAD", cwd=repo, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
        out1, _ = await p1.communicate()
        branch = (out1.decode(errors="replace").strip() or "")
        # porcelain
        p2 = await asyncio.create_subprocess_exec("git", "status", "--porcelain", cwd=repo, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
        out2, _ = await p2.communicate()
        lines = [ln for ln in out2.decode(errors="replace").splitlines() if ln]
        return {"branch": branch, "changes": lines}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

class GitDiffRequest(BaseModel):
    repo: str
    path: Optional[str] = None


    root = _safe_root(req.base or "repo")
    full = (root / req.path).resolve()
    if not str(full).startswith(str(root.resolve())):
        raise HTTPException(status_code=403, detail="Access denied")
    try:
        full.parent.mkdir(parents=True, exist_ok=True)
        mode = req.mode or "w"
        if mode not in ("w", "a"):
            raise HTTPException(status_code=400, detail="Invalid mode")
        with full.open(mode, encoding="utf-8") as f:
            f.write(req.content)
        text = full.read_text(encoding="utf-8", errors="replace")
        return {"ok": True, "path": str(full.relative_to(root)), "hash": _sha256_text(text)}
    except HTTPException:
        raise
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

# ---------------- Git status/diff ----------------
@app.get("/api/dev/git/status")
async def dev_git_status(repo: str) -> Dict[str, Any]:
    try:
        # branch
        p1 = await asyncio.create_subprocess_exec("git", "rev-parse", "--abbrev-ref", "HEAD", cwd=repo, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
        out1, _ = await p1.communicate()
        branch = (out1.decode(errors="replace").strip() or "")
        # porcelain
        p2 = await asyncio.create_subprocess_exec("git", "status", "--porcelain", cwd=repo, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
        out2, _ = await p2.communicate()
        lines = [ln for ln in out2.decode(errors="replace").splitlines() if ln]
        return {"branch": branch, "changes": lines}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

class GitDiffRequest(BaseModel):
    repo: str
    path: Optional[str] = None
    staged: Optional[bool] = False

@app.post("/api/dev/git/diff")
async def dev_git_diff(req: GitDiffRequest) -> Dict[str, Any]:
    try:
        args = ["git", "diff"] + (["--cached"] if req.staged else [])
        if req.path:
            args.append(req.path)
        p = await asyncio.create_subprocess_exec(*args, cwd=req.repo, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
        out, err = await p.communicate()
        if p.returncode != 0:
            return {"ok": False, "stderr": err.decode(errors="replace")}
        return {"ok": True, "diff": out.decode(errors="replace")}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


GitDiffRequest

        raise
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

# ---------------- Git status/diff ----------------
@app.get("/api/dev/git/status")
async def dev_git_status(repo: str) -> Dict[str, Any]:
    try:
        # branch
        p1 = await asyncio.create_subprocess_exec("git", "rev-parse", "--abbrev-ref", "HEAD", cwd=repo, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
        out1, _ = await p1.communicate()
        branch = (out1.decode(errors="replace").strip() or "")
        # porcelain
        p2 = await asyncio.create_subprocess_exec("git", "status", "--porcelain", cwd=repo, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
        out2, _ = await p2.communicate()
        lines = [ln for ln in out2.decode(errors="replace").splitlines() if ln]
        return {"branch": branch, "changes": lines}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

class GitDiffRequest(BaseModel):
    repo: str
    path: Optional[str] = None
    staged: Optional[bool] = False

@app.post("/api/dev/git/diff")
async def dev_git_diff(req: GitDiffRequest) -> Dict[str, Any]:
    try:
        args = ["git", "diff"] + (["--cached"] if req.staged else [])
        if req.path:
            args.append(req.path)
        p = await asyncio.create_subprocess_exec(*args, cwd=req.repo, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
        out, err = await p.communicate()
        if p.returncode != 0:
            return {"ok": False, "stderr": err.decode(errors="replace")}
        return {"ok": True, "diff": out.decode(errors="replace")}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

# Dynamic Makefile tasks discovery
@app.get("/api/make/tasks")
async def make_tasks() -> Dict[str, List[str]]:
    mk = WORKSPACE_DIR / "Makefile"
    cats: Dict[str, List[str]] = {}


async def dev_git_status(repo: str) -> Dict[str, Any]:
    try:
        # branch
        p1 = await asyncio.create_subprocess_exec("git", "rev-parse", "--abbrev-ref", "HEAD", cwd=repo, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
        out1, _ = await p1.communicate()
        branch = (out1.decode(errors="replace").strip() or "")
        # porcelain
        p2 = await asyncio.create_subprocess_exec("git", "status", "--porcelain", cwd=repo, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
        out2, _ = await p2.communicate()
        lines = [ln for ln in out2.decode(errors="replace").splitlines() if ln]
        return {"branch": branch, "changes": lines}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

class GitDiffRequest(BaseModel):
    repo: str
    path: Optional[str] = None
    staged: Optional[bool] = False

@app.post("/api/dev/git/diff")
async def dev_git_diff(req: GitDiffRequest) -> Dict[str, Any]:
    try:
        args = ["git", "diff"] + (["--cached"] if req.staged else [])
        if req.path:
            args.append(req.path)
        p = await asyncio.create_subprocess_exec(*args, cwd=req.repo, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
        out, err = await p.communicate()
        if p.returncode != 0:
            return {"ok": False, "stderr": err.decode(errors="replace")}
        return {"ok": True, "diff": out.decode(errors="replace")}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

# Dynamic Makefile tasks discovery
@app.get("/api/make/tasks")
async def make_tasks() -> Dict[str, List[str]]:
    mk = WORKSPACE_DIR / "Makefile"
    cats: Dict[str, List[str]] = {}
    if not mk.exists():
        return cats
    try:
        with mk.open("r", encoding="utf-8", errors="ignore") as f:
            for line in f:
                line = line.strip()
                if not line or line.startswith("#"):
                    continue
                if ":" in line and not line.startswith("."):
                    target = line.split(":", 1)[0].strip()
                    if "=" in target or " " in target:
                        continue
                    # category by prefix before first dot
                    cat = target.split(".")[0] if "." in target else "misc"


    repo: str
    path: Optional[str] = None
    staged: Optional[bool] = False

@app.post("/api/dev/git/diff")
async def dev_git_diff(req: GitDiffRequest) -> Dict[str, Any]:
    try:
        args = ["git", "diff"] + (["--cached"] if req.staged else [])
        if req.path:
            args.append(req.path)
        p = await asyncio.create_subprocess_exec(*args, cwd=req.repo, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
        out, err = await p.communicate()
        if p.returncode != 0:
            return {"ok": False, "stderr": err.decode(errors="replace")}
        return {"ok": True, "diff": out.decode(errors="replace")}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

# Dynamic Makefile tasks discovery
@app.get("/api/make/tasks")
async def make_tasks() -> Dict[str, List[str]]:
    mk = WORKSPACE_DIR / "Makefile"
    cats: Dict[str, List[str]] = {}
    if not mk.exists():
        return cats
    try:
        with mk.open("r", encoding="utf-8", errors="ignore") as f:
            for line in f:
                line = line.strip()
                if not line or line.startswith("#"):
                    continue
                if ":" in line and not line.startswith("."):
                    target = line.split(":", 1)[0].strip()
                    if "=" in target or " " in target:
                        continue
                    # category by prefix before first dot
                    cat = target.split(".")[0] if "." in target else "misc"
                    cats.setdefault(cat, []).append(target)
    except Exception:
        pass
    # Sort values
    for k in list(cats.keys()):
        cats[k] = sorted(sorted(set(cats[k])))
    return cats

# ---------------- Makefile run wrapper ----------------
@app.post("/api/make/run")
async def make_run(payload: Dict[str, Any], background_tasks: BackgroundTasks, database: Session = Depends(db.get_db)) -> Dict[str, Any]:
    target = str(payload.get("target", "")).strip()
    args = payload.get("args", {})
    priority = payload.get("priority", TaskPriority.NORMAL)
    deps = payload.get("dependencies", [])
    resources = payload.get("resources")
    if not target:
        raise HTTPException(status_code=400, detail="target required")
    # Allow any discovered make target
    cats = await make_tasks()
    all_targets = {t for lst in cats.values() for t in lst}
    if target not in all_targets:
        raise HTTPException(status_code=400, detail="unknown make target")
    req = TaskRequest(task=target, args=args or None, priority=priority, dependencies=deps or None, resources=ResourceRequest(**resources) if isinstance(resources, dict) else None)
    return await start_task(req, background_tasks, database)

# ================= AGI Chat Endpoint =================


            for line in f:
                line = line.strip()
                if not line or line.startswith("#"):
                    continue
                if ":" in line and not line.startswith("."):
                    target = line.split(":", 1)[0].strip()
                    if "=" in target or " " in target:
                        continue
                    # category by prefix before first dot
                    cat = target.split(".")[0] if "." in target else "misc"
                    cats.setdefault(cat, []).append(target)
    except Exception:
        pass
    # Sort values
    for k in list(cats.keys()):
        cats[k] = sorted(sorted(set(cats[k])))
    return cats

# ---------------- Makefile run wrapper ----------------
@app.post("/api/make/run")
async def make_run(payload: Dict[str, Any], background_tasks: BackgroundTasks, database: Session = Depends(db.get_db)) -> Dict[str, Any]:
    target = str(payload.get("target", "")).strip()
    args = payload.get("args", {})
    priority = payload.get("priority", TaskPriority.NORMAL)
    deps = payload.get("dependencies", [])
    resources = payload.get("resources")
    if not target:
        raise HTTPException(status_code=400, detail="target required")
    # Allow any discovered make target
    cats = await make_tasks()
    all_targets = {t for lst in cats.values() for t in lst}
    if target not in all_targets:
        raise HTTPException(status_code=400, detail="unknown make target")
    req = TaskRequest(task=target, args=args or None, priority=priority, dependencies=deps or None, resources=ResourceRequest(**resources) if isinstance(resources, dict) else None)
    return await start_task(req, background_tasks, database)

# ================= AGI Chat Endpoint =================
class ChatRequest(BaseModel):
    message: str
    session_id: Optional[str] = None

def _json_try(s: str) -> Optional[Dict[str, Any]]:
    try:
        return json.loads(s)
    except Exception:
        return None

@app.post("/api/agent/chat")
async def agent_chat(req: ChatRequest, background_tasks: BackgroundTasks, database: Session = Depends(db.get_db)) -> Dict[str, Any]:
    """
    Minimal AGI chat endpoint with slash-commands that invoke existing capabilities.
    Fallback to simple model inference.
    """
    text = (req.message or "").strip()
    if not text:


ChatRequest

# ---------------- Makefile run wrapper ----------------
@app.post("/api/make/run")
async def make_run(payload: Dict[str, Any], background_tasks: BackgroundTasks, database: Session = Depends(db.get_db)) -> Dict[str, Any]:
    target = str(payload.get("target", "")).strip()
    args = payload.get("args", {})
    priority = payload.get("priority", TaskPriority.NORMAL)
    deps = payload.get("dependencies", [])
    resources = payload.get("resources")
    if not target:
        raise HTTPException(status_code=400, detail="target required")
    # Allow any discovered make target
    cats = await make_tasks()
    all_targets = {t for lst in cats.values() for t in lst}
    if target not in all_targets:
        raise HTTPException(status_code=400, detail="unknown make target")
    req = TaskRequest(task=target, args=args or None, priority=priority, dependencies=deps or None, resources=ResourceRequest(**resources) if isinstance(resources, dict) else None)
    return await start_task(req, background_tasks, database)

# ================= AGI Chat Endpoint =================
class ChatRequest(BaseModel):
    message: str
    session_id: Optional[str] = None

def _json_try(s: str) -> Optional[Dict[str, Any]]:
    try:
        return json.loads(s)
    except Exception:
        return None

@app.post("/api/agent/chat")
async def agent_chat(req: ChatRequest, background_tasks: BackgroundTasks, database: Session = Depends(db.get_db)) -> Dict[str, Any]:
    """
    Minimal AGI chat endpoint with slash-commands that invoke existing capabilities.
    Fallback to simple model inference.
    """
    text = (req.message or "").strip()
    if not text:
        return {"text": "Say something, or type /help for commands."}

    async def _ok(resp: Any) -> Dict[str, Any]:
        return {"text": "Done.", "data": resp}


    target = str(payload.get("target", "")).strip()
    args = payload.get("args", {})
    priority = payload.get("priority", TaskPriority.NORMAL)
    deps = payload.get("dependencies", [])
    resources = payload.get("resources")
    if not target:
        raise HTTPException(status_code=400, detail="target required")
    # Allow any discovered make target
    cats = await make_tasks()
    all_targets = {t for lst in cats.values() for t in lst}
    if target not in all_targets:
        raise HTTPException(status_code=400, detail="unknown make target")
    req = TaskRequest(task=target, args=args or None, priority=priority, dependencies=deps or None, resources=ResourceRequest(**resources) if isinstance(resources, dict) else None)
    return await start_task(req, background_tasks, database)

# ================= AGI Chat Endpoint =================
class ChatRequest(BaseModel):
    message: str
    session_id: Optional[str] = None

def _json_try(s: str) -> Optional[Dict[str, Any]]:
    try:
        return json.loads(s)
    except Exception:
        return None

@app.post("/api/agent/chat")
async def agent_chat(req: ChatRequest, background_tasks: BackgroundTasks, database: Session = Depends(db.get_db)) -> Dict[str, Any]:
    """
    Minimal AGI chat endpoint with slash-commands that invoke existing capabilities.
    Fallback to simple model inference.
    """
    text = (req.message or "").strip()
    if not text:
        return {"text": "Say something, or type /help for commands."}

    async def _ok(resp: Any) -> Dict[str, Any]:
        return {"text": "Done.", "data": resp}

    if text.startswith("/"):
        parts = text.split()
        cmd = parts[0].lower()
        args = parts[1:]

        if cmd in {"/help", "/?"}:


    # Allow any discovered make target
    cats = await make_tasks()
    all_targets = {t for lst in cats.values() for t in lst}
    if target not in all_targets:
        raise HTTPException(status_code=400, detail="unknown make target")
    req = TaskRequest(task=target, args=args or None, priority=priority, dependencies=deps or None, resources=ResourceRequest(**resources) if isinstance(resources, dict) else None)
    return await start_task(req, background_tasks, database)

# ================= AGI Chat Endpoint =================
class ChatRequest(BaseModel):
    message: str
    session_id: Optional[str] = None

def _json_try(s: str) -> Optional[Dict[str, Any]]:
    try:
        return json.loads(s)
    except Exception:
        return None

@app.post("/api/agent/chat")
async def agent_chat(req: ChatRequest, background_tasks: BackgroundTasks, database: Session = Depends(db.get_db)) -> Dict[str, Any]:
    """
    Minimal AGI chat endpoint with slash-commands that invoke existing capabilities.
    Fallback to simple model inference.
    """
    text = (req.message or "").strip()
    if not text:
        return {"text": "Say something, or type /help for commands."}

    async def _ok(resp: Any) -> Dict[str, Any]:
        return {"text": "Done.", "data": resp}

    if text.startswith("/"):
        parts = text.split()
        cmd = parts[0].lower()
        args = parts[1:]

        if cmd in {"/help", "/?"}:
            return {"text": "Commands:\n"
                            "/make <target> [args_json]\n"
                            "/logs <task_id>\n"
                            "/bench runs <suite>|/bench compare <suite> <run_a> <run_b>\n"
                            "/qa <question>\n"
                            "/search <pattern> [base] [path]\n"
                            "/repo tree [path]\n"
                            "/file read <path> [base]\n"
                            "/models|/model load <id>|/infer <prompt>"}

        if cmd == "/make" and args:
            target = args[0]
            extra = " ".join(args[1:]).strip()
            arg_obj = _json_try(extra) if extra else None
            req_obj = TaskRequest(task=target, args=arg_obj or None)
            res = await start_task(req_obj, background_tasks, database)
            return {"text": f"Started {target}", "data": res}

        if cmd == "/logs" and args:
            tid = args[0]
            try:
                logs = await get_task_logs(tid)  # type: ignore[arg-type]
            except HTTPException as e:
                return {"text": f"Error: {e.detail}"}
            # Trim for chat
            out = (logs.get("stdout", "") or "")
            err = (logs.get("stderr", "") or "")
            def _tail(s: str, n: int = 80) -> str:
                lines = s.splitlines()
                return "\n".join(lines[-n:])
            return {"text": f"STDOUT:\n{_tail(out)}\n\nSTDERR:\n{_tail(err)}"}

        if cmd == "/bench" and args:
            sub = args[0]
            if sub == "runs" and len(args) >= 2:
                suite = args[1]
                runs = await list_bench_runs(suite)
                return {"text": f"Found {len(runs)} runs.", "data": runs[:50]}
            if sub == "compare" and len(args) >= 4:
                suite, a, b = args[1], args[2], args[3]
                cmp = await bench_compare(suite, a, b)
                return await _ok(cmp)
            return {"text": "Usage: /bench runs <suite> | /bench compare <suite> <run_a> <run_b>"}

        if cmd == "/qa" and args:
            question = text[len("/qa"):].strip()
            ans = await qa_query({"question": question})
            return {"text": ans.get("answer", ""), "data": ans.get("citations")}

        if cmd == "/search" and args:
            pattern = args[0]
            base = args[1] if len(args) > 1 else "repo"
            subpath = args[2] if len(args) > 2 else ""
            hits = await code_search({"pattern": pattern, "base": base, "path": subpath, "max_results": 50})
            n = len(hits.get("results", []))
            return {"text": f"{n} hits.", "data": hits}

        if cmd == "/repo" and len(args) >= 1 and args[0] == "tree":
            sub = args[1] if len(args) > 1 else ""
            tree = await repo_tree("repo", sub)
            return await _ok(tree)

        if cmd == "/file" and len(args) >= 2 and args[0] == "read":
            path_arg = args[1]
            base_arg = args[2] if len(args) > 2 else "repo"
            fr = await dev_file_read(FileReadRequest(path=path_arg, base=base_arg))
            return {"text": fr.get("content", "")[:2000], "data": {"path": fr.get("path"), "hash": fr.get("hash")}}

        if cmd in {"/models", "/model"}:
            if len(args) == 0:
                models = await list_models()
                return await _ok(models)
            if args[0] == "load" and len(args) >= 2:
                # reuse model load endpoint
                try:
                    params = _json_try(" ".join(args[2:])) if len(args) > 2 else None
                except Exception:
                    params = None
                res = await load_model(ModelRequest(model_id=args[1], params=params))  # type: ignore[name-defined]
                return await _ok(res)
            return {"text": "Usage: /models | /model load <id> [params_json]"}

        if cmd in {"/infer", "/chat"}:
            prompt = text[len(cmd):].strip()
            out = await model_infer(InferRequest(prompt=prompt))
            return {"text": out.get("output", "")}

        if cmd == "/plan":
            # Usage: /plan <json_obs>
            try:
                obs_json = _json_try(" ".join(args)) or {}
                pr = PlannerRequest(obs=obs_json, domain=obs_json.get("kind", "cli"))
                res = await api_planner_plan(pr)
                return {"text": json.dumps(res.get("plan", {}), ensure_ascii=False)[:2000], "data": res}
            except Exception as e:
                return {"text": f"plan error: {e}"}

        if cmd == "/verify":
            # Usage: /verify <json_trace>
            try:
                trace_json = _json_try(" ".join(args)) or {}
                vr = VerifyRequest(trace=trace_json)
                res = await api_verifier_verify(vr)
                return {"text": json.dumps(res, ensure_ascii=False)[:2000], "data": res}
            except Exception as e:
                return {"text": f"verify error: {e}"}

        return {"text": "Unknown command. Type /help."}

    # Fallback: treat plain text as inference
    out = await model_infer(InferRequest(prompt=text))
    return {"text": out.get("output", "")}

# ================= HITL Approval APIs =================
class ApproveRequest(BaseModel):
    id: str
    decision: str  # "approved" | "denied" | "modified" | "deferred"
    note: Optional[str] = None
    actor: Optional[str] = "api"

@app.get("/api/hitl/pending")
async def hitl_list_pending(kind: Optional[str] = None, sweep_ttl: Optional[int] = None, include_decided: bool = False) -> Dict[str, Any]:
    """List HITL items with optional kind filter. By default, shows only undecided pending items.

    Query params:
    - kind: filter by item kind (e.g., "curriculum", "code.patch")
    - sweep_ttl: if provided, sweep/expire pending items older than TTL seconds before listing (defaults from env or 600)
    - include_decided: if true, include items that already have a decision; else hide them
    """
    try:
        root = WORKSPACE_DIR
        from agi_dw.core.hitl.approval_queue import ApprovalQueue  # type: ignore