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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ def main():
if token == 'serve':
from src.entrypoints.serve_cli import run_serve_subcommand
return run_serve_subcommand(rest)
if token == 'desktop':
from src.entrypoints.desktop_cli import run_desktop_subcommand
return run_desktop_subcommand(rest)
if token == 'tui':
return _run_tui_subcommand(rest)
if token == 'migrate':
Expand Down
98 changes: 98 additions & 0 deletions src/entrypoints/desktop_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""``clawcodex desktop`` — launch the ClawCodex Desktop app.

Dev-oriented launcher for the Electron shell in ``ui-desktop/``: it resolves
the checkout this CLI is running from, makes sure the app's npm deps exist,
and hands off to ``npm run dev`` (Vite renderer + Electron main, which spawns
`clawcodex serve` as its backend). The spawned app is pointed back at THIS
checkout via ``CLAWCODEX_DESKTOP_BACKEND_ROOT`` so the backend it boots is the
code you're sitting in, not whatever else is on PATH.

Packaged-app launching (installed .app/.exe) arrives with the packaging
stage; this entry covers the source-checkout path the TUI's ``clawcodex``
command already serves.

Usage::

clawcodex desktop [--install] [--no-dev]

--install run `npm ci` even if node_modules exists
--no-dev build once and launch electron directly (`npm run start`)
"""

from __future__ import annotations

import argparse
import os
import shutil
import subprocess
import sys
from pathlib import Path


def repo_root() -> Path:
"""The clawcodex checkout this module runs from."""
return Path(__file__).resolve().parents[2]


def desktop_dir(root: Path | None = None) -> Path:
return (root or repo_root()) / "ui-desktop"


def launch_env(root: Path) -> dict[str, str]:
"""Environment for the app process: pin the backend to this checkout."""
env = dict(os.environ)
env.setdefault("CLAWCODEX_DESKTOP_CLAWCODEX_ROOT", str(root))
return env


def build_launch_plan(app_dir: Path, *, install: bool, dev: bool) -> list[list[str]]:
"""The npm commands to run, in order. Pure for testing."""
plan: list[list[str]] = []
if install or not (app_dir / "node_modules").is_dir():
plan.append(["npm", "ci"])
plan.append(["npm", "run", "dev"] if dev else ["npm", "run", "start"])
return plan


def run_desktop_subcommand(argv: list[str]) -> int:
parser = argparse.ArgumentParser(
prog="clawcodex desktop",
description="Launch the ClawCodex Desktop app from this checkout.",
)
parser.add_argument("--install", action="store_true",
help="Reinstall ui-desktop npm deps first (npm ci).")
parser.add_argument("--no-dev", action="store_true", dest="no_dev",
help="Build once and launch Electron directly instead "
"of the dev server.")
args = parser.parse_args(argv)

root = repo_root()
app_dir = desktop_dir(root)
if not (app_dir / "package.json").is_file():
print(f"desktop: no ui-desktop app at {app_dir} — run from a full "
"clawcodex checkout (git pull to get the desktop app).",
file=sys.stderr)
return 2
if shutil.which("npm") is None:
print("desktop: npm not found on PATH — install Node.js 22+ first.",
file=sys.stderr)
return 2

env = launch_env(root)
for cmd in build_launch_plan(app_dir, install=args.install, dev=not args.no_dev):
print(f"desktop: {' '.join(cmd)} (in {app_dir})", file=sys.stderr)
try:
result = subprocess.run(cmd, cwd=str(app_dir), env=env)
except KeyboardInterrupt:
return 0
if result.returncode != 0:
return result.returncode
return 0


__all__ = ["build_launch_plan", "desktop_dir", "launch_env", "repo_root",
"run_desktop_subcommand"]


if __name__ == "__main__":
raise SystemExit(run_desktop_subcommand(sys.argv[1:]))
32 changes: 25 additions & 7 deletions src/server/desktop_gateway_methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,16 @@

CONTROL_TIMEOUT_S = 30.0

# GUI↔backend contract version. The ClawCodex ladder restarts at 1 (the
# reference implementation was at 5); bump when the desktop starts requiring
# a capability this server ships (the renderer's REQUIRED_BACKEND_CONTRACT in
# ui-desktop/src/store/updates.ts must match).
DESKTOP_CONTRACT = 1


def _init_session_info(init: dict[str, Any]) -> dict[str, Any]:
"""system/init frame → the ``session.info`` payload the renderer reads."""
payload: dict[str, Any] = {"running": False}
payload: dict[str, Any] = {"running": False, "desktop_contract": DESKTOP_CONTRACT}
cwd = init.get("cwd")
if cwd:
payload["cwd"] = cwd
Expand Down Expand Up @@ -153,6 +159,8 @@ async def _route(self, frame: dict[str, Any]) -> None:
mode = frame.get("permission_mode")
if mode:
await self._broadcast("session.info", {"approval_mode": mode, "running": False})
# Turn end persisted the transcript — nudge sidebars to refresh.
await self._broadcast("sessions.changed", {})

async def _route_ask(self, frame: dict[str, Any]) -> None:
rid = str(frame.get("request_id") or "")
Expand Down Expand Up @@ -274,7 +282,10 @@ def __init__(self, websocket: WebSocket, state: DesktopServeState) -> None:
async def on_open(self) -> None:
for session in self.state.sessions.values():
session.sockets.add(self.websocket)
await send_event(self.websocket, "gateway.ready", {"app": "clawcodex"})
# change_events: sessions.changed is pushed after every turn, so the
# renderer can demote its sidebar polling.
await send_event(self.websocket, "gateway.ready",
{"app": "clawcodex", "change_events": True})

async def on_close(self) -> None:
for session in self.state.sessions.values():
Expand Down Expand Up @@ -335,18 +346,25 @@ async def session_create(self, params: dict[str, Any]) -> dict[str, Any]:
async def session_resume(self, params: dict[str, Any]) -> dict[str, Any]:
wanted = str(params.get("session_id") or "") or None
session = await self._create(params.get("cwd"), wanted)
return {
response: dict[str, Any] = {
"session_id": session.session_id,
"stored_session_id": wanted or session.session_id,
"resumed": wanted or session.session_id,
# Transcript hydration ships with the sessions REST stage; the
# agent's context IS restored (resume control), history paints
# lazily once /api/sessions lands.
"message_count": 0,
"messages": [],
"messages_omitted": True,
"info": _init_session_info(session.init_info),
}
omit = bool(params.get("omit_messages") or params.get("lazy"))
if wanted and not omit:
from src.server.desktop_sessions import load_session_messages

stored = load_session_messages(self.state.saved_sessions_dir(), wanted)
if stored is not None:
response["messages"] = stored["messages"]
response["message_count"] = stored["message_count"]
elif wanted and omit:
response["messages_omitted"] = True
return response

async def session_activate(self, params: dict[str, Any]) -> dict[str, Any]:
if params.get("session_id"):
Expand Down
Loading
Loading