python-codex 0.2.1__py3-none-any.whl → 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
workspace_server/app.py CHANGED
@@ -3,9 +3,8 @@ import asyncio
3
3
  import html
4
4
  import json
5
5
  import os
6
+ import secrets
6
7
  import threading
7
- import tempfile
8
- from uuid import uuid4
9
8
  from dataclasses import asdict, is_dataclass
10
9
  try:
11
10
  from contextlib import asynccontextmanager
@@ -13,8 +12,8 @@ except ImportError: # pragma: no cover - Python 3.6 compatibility
13
12
  asynccontextmanager = None
14
13
  from pathlib import Path
15
14
 
16
- from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
17
- from fastapi.responses import HTMLResponse, JSONResponse, Response
15
+ from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
16
+ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
18
17
 
19
18
  from pycodex.cli import build_agent, build_cli_queue, build_model, configure_loguru
20
19
  from pycodex.interactive_session import run_interactive_session
@@ -25,7 +24,20 @@ from pycodex.utils.session_persist import (
25
24
  load_resumed_session_path,
26
25
  )
27
26
  from pycodex.utils import uuid7_string
28
- from pycodex.utils.visualize import IDLE_LISTENING_STATUS, shorten_title, tool_summary
27
+ from pycodex.utils.visualize import (
28
+ IDLE_LISTENING_STATUS,
29
+ percent_of_context_window_remaining,
30
+ shorten_title,
31
+ tool_summary,
32
+ )
33
+ from .workspaces import (
34
+ WorkspaceDefinition,
35
+ WorkspaceEntry,
36
+ WorkspaceRegistry,
37
+ WorkspaceSessionManager,
38
+ load_workspace_definitions,
39
+ session_snapshot,
40
+ )
29
41
  import typing
30
42
 
31
43
 
@@ -40,55 +52,6 @@ JSONValue = typing.Union[
40
52
  ]
41
53
 
42
54
 
43
- class WorkspaceStateStore:
44
- def __init__(self, board_path: "typing.Union[Path, None]") -> None:
45
- self.path = None if board_path is None else board_path.with_suffix(".pycodex-ws.json")
46
-
47
- def load_tabs(self) -> "typing.List[typing.Dict[str, str]]":
48
- if self.path is None or not self.path.is_file():
49
- return []
50
-
51
- try:
52
- payload = json.loads(
53
- self.path.read_text(encoding="utf-8", errors="replace") or "{}"
54
- )
55
- except (OSError, ValueError):
56
- return []
57
-
58
- tabs = payload.get("tabs") if isinstance(payload, dict) else None
59
- if not isinstance(tabs, list):
60
- return []
61
-
62
- result = []
63
- for tab in tabs:
64
- if not isinstance(tab, dict):
65
- continue
66
- title = str(tab.get("title") or "").strip()
67
- rollout_path = str(tab.get("rollout_path") or "").strip()
68
- if title or rollout_path:
69
- result.append({"title": title, "rollout_path": rollout_path})
70
- return result
71
-
72
- def save_tabs(self, tabs: "typing.Iterable[typing.Dict[str, str]]") -> None:
73
- if self.path is None:
74
- return
75
-
76
- state_tabs = [
77
- {
78
- "title": str(tab.get("title") or ""),
79
- "rollout_path": str(tab.get("rollout_path") or ""),
80
- }
81
- for tab in tabs
82
- ]
83
- payload = json.dumps(
84
- {"version": 1, "tabs": state_tabs},
85
- ensure_ascii=False,
86
- indent=2,
87
- ) + "\n"
88
- self.path.parent.mkdir(parents=True, exist_ok=True)
89
- self.path.write_text(payload, encoding="utf-8")
90
-
91
-
92
55
  def build_parser() -> "argparse.ArgumentParser":
93
56
  parser = argparse.ArgumentParser(
94
57
  prog="pycodex-ws",
@@ -100,9 +63,17 @@ def build_parser() -> "argparse.ArgumentParser":
100
63
  help="Bind address as host:port, for example 0.0.0.0:6007.",
101
64
  )
102
65
  parser.add_argument(
103
- "--board",
66
+ "--workspace-config",
67
+ default="./workspaces.json",
68
+ help=(
69
+ "Optional JSON file listing workspaces. Each entry needs `id`, "
70
+ "`board`, and `work_dir`; routes are served under /w/<id>/."
71
+ ),
72
+ )
73
+ parser.add_argument(
74
+ "--password",
104
75
  default=None,
105
- help="Optional board HTML path. Defaults to a writable random path under /tmp.",
76
+ help="Optional password required to open the workspace server.",
106
77
  )
107
78
  parser.add_argument(
108
79
  "--config",
@@ -141,32 +112,24 @@ def build_parser() -> "argparse.ArgumentParser":
141
112
  return parser
142
113
 
143
114
 
144
- def parse_target(
145
- target: str,
146
- board: "typing.Union[str, None]" = None,
147
- ) -> "typing.Tuple[str, int, typing.Union[Path, None]]":
115
+ def parse_listen(target: str) -> "typing.Tuple[str, int]":
148
116
  target_text = str(target or "").strip() or "127.0.0.1:6007"
149
- board_text = board
150
- if "+" in target_text:
151
- target_text, suffix = target_text.split("+", 1)
152
- if board_text is None:
153
- board_text = suffix
154
117
  if ":" not in target_text:
155
- raise ValueError("workspace target must look like host:port")
118
+ raise ValueError("workspace listen target must look like host:port")
156
119
  host, port_text = target_text.rsplit(":", 1)
157
120
  host = host.strip() or "127.0.0.1"
158
121
  try:
159
122
  port = int(port_text)
160
123
  except ValueError as exc:
161
124
  raise ValueError("workspace port must be an integer") from exc
162
- board_path = Path(board_text).expanduser().resolve() if board_text else None
163
- return host, port, board_path
125
+ return host, port
164
126
 
165
127
 
166
128
  SessionFactory = typing.Callable[[], object]
167
129
  ThreadedSessionFactory = typing.Callable[[], "WorkspaceInteractiveSession"]
168
130
  SESSION_CLOSE_TIMEOUT_SECONDS = 2.0
169
131
  SPINNER_STATUS_PREVIEW_LIMIT = 180
132
+ AUTH_COOKIE_NAME = "pycodex_ws_auth"
170
133
 
171
134
 
172
135
  class WebSessionView:
@@ -180,6 +143,8 @@ class WebSessionView:
180
143
  self._title = ""
181
144
  self._spinner_status = ""
182
145
  self._stream_buffer = ""
146
+ self._context_window_tokens: "typing.Union[int, None]" = None
147
+ self._context_remaining_percent: "typing.Union[int, None]" = None
183
148
  self._closed = False
184
149
  self._server_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
185
150
  self._worker_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
@@ -347,7 +312,11 @@ class WebSessionView:
347
312
  self,
348
313
  context_window_tokens: "typing.Union[int, None]",
349
314
  ) -> None:
350
- del context_window_tokens
315
+ with self._lock:
316
+ self._context_window_tokens = context_window_tokens
317
+ self._context_remaining_percent = (
318
+ 100 if context_window_tokens is not None else None
319
+ )
351
320
 
352
321
  def subscribe(self) -> "asyncio.Queue":
353
322
  queue: "asyncio.Queue" = asyncio.Queue()
@@ -386,6 +355,7 @@ class WebSessionView:
386
355
  "spinner": self._spinner_status,
387
356
  "model": "pycodex",
388
357
  "title": self._title,
358
+ "context_remaining_percent": self._context_remaining_percent,
389
359
  "turns": [_public_turn(turn) for turn in self._turns[-80:]],
390
360
  }
391
361
 
@@ -396,6 +366,8 @@ class WebSessionView:
396
366
  "spinner": self._spinner_status,
397
367
  "title": self._title,
398
368
  "turn_count": len(self._turns),
369
+ "last_assistant": _last_assistant_text(self._turns),
370
+ "context_remaining_percent": self._context_remaining_percent,
399
371
  }
400
372
 
401
373
  def _apply_runtime_event(self, event: "AgentEvent") -> None:
@@ -403,6 +375,9 @@ class WebSessionView:
403
375
  payload = getattr(event, "payload", {})
404
376
  if not isinstance(payload, dict):
405
377
  payload = {}
378
+ if kind == "token_count":
379
+ self._update_context_window(payload.get("usage"))
380
+ return
406
381
  turn_id = str(payload.get("turn_id") or getattr(event, "turn_id", "") or "")
407
382
  submission_id = str(payload.get("submission_id") or turn_id or "")
408
383
  turn = self._turns_by_submission_id.get(submission_id)
@@ -475,6 +450,22 @@ class WebSessionView:
475
450
  turn["status"] = "interrupted"
476
451
  self._stream_buffer = ""
477
452
 
453
+ def _update_context_window(self, usage: "object") -> None:
454
+ if self._context_window_tokens is None:
455
+ return
456
+ if not isinstance(usage, dict):
457
+ self._context_remaining_percent = None
458
+ return
459
+ try:
460
+ total_tokens = int(usage["total_tokens"])
461
+ except (KeyError, TypeError, ValueError):
462
+ self._context_remaining_percent = None
463
+ return
464
+ self._context_remaining_percent = percent_of_context_window_remaining(
465
+ total_tokens,
466
+ self._context_window_tokens,
467
+ )
468
+
478
469
  def _apply_spinner_event(
479
470
  self,
480
471
  kind: str,
@@ -837,179 +828,307 @@ class ThreadedWorkspaceInteractiveSession:
837
828
  await asyncio.wrap_future(future)
838
829
 
839
830
 
840
- class WorkspaceSessionManager:
841
- def __init__(
842
- self,
843
- session_factory: "SessionFactory",
844
- board_path: "typing.Union[Path, None]" = None,
845
- ) -> None:
846
- self._session_factory = session_factory
847
- self._sessions: "typing.Dict[str, WorkspaceInteractiveSession]" = {}
848
- self._session_order: "typing.List[str]" = []
849
- self._state_watchers: "typing.Dict[str, asyncio.Task]" = {}
850
- self._persisted_titles: "typing.Dict[str, str]" = {}
851
- self._lock = asyncio.Lock()
852
- self._state_store = WorkspaceStateStore(board_path)
853
-
854
- async def start(self) -> None:
855
- state_tabs = self._state_store.load_tabs()
856
- if not state_tabs:
857
- await self.create_session()
858
- return
859
- for tab in state_tabs:
860
- await self.create_session(
861
- title=str(tab.get("title") or ""),
862
- rollout_path=str(tab.get("rollout_path") or ""),
863
- )
831
+ def create_app(
832
+ session_source: "typing.Union[WorkspaceSessionManager, SessionFactory]",
833
+ board_path: "typing.Union[Path, None]",
834
+ password: "typing.Union[str, None]" = None,
835
+ ) -> FastAPI:
836
+ manager = (
837
+ session_source
838
+ if isinstance(session_source, WorkspaceSessionManager)
839
+ else WorkspaceSessionManager(session_source, board_path)
840
+ )
841
+ app = _create_lifespan_app(manager.start, manager.close)
842
+ auth_token = _install_auth(app, password)
843
+ _install_workspace_routes(app, manager, board_path)
844
+ app.state.workspace_auth_token = auth_token
845
+ return app
864
846
 
865
- async def close(self) -> None:
866
- sessions = list(self._sessions.values())
867
- watchers = list(self._state_watchers.values())
868
- self._sessions.clear()
869
- self._session_order = []
870
- self._state_watchers.clear()
871
- self._persisted_titles.clear()
872
- for watcher in watchers:
873
- watcher.cancel()
874
- if watchers:
875
- await asyncio.gather(*watchers, return_exceptions=True)
876
- for session in sessions:
877
- await session.close()
878
-
879
- async def create_session(
880
- self,
881
- title: str = "",
882
- rollout_path: str = "",
883
- ) -> str:
884
- async with self._lock:
885
- session_id = uuid7_string()
886
- session = self._session_factory()
887
- await session.start()
888
847
 
889
- if rollout_path:
890
- await session.restore_from_rollout(rollout_path, title=title)
848
+ def create_multi_workspace_app(
849
+ registry: 'WorkspaceRegistry',
850
+ password: "typing.Union[str, None]" = None,
851
+ ) -> FastAPI:
852
+ app = _create_lifespan_app(registry.start, registry.close)
853
+ auth_token = _install_auth(app, password)
891
854
 
892
- self._sessions[session_id] = session
893
- self._session_order.append(session_id)
894
- self._persisted_titles[session_id] = str(
895
- _session_summary(session).get("title") or ""
896
- )
897
- self._state_watchers[session_id] = asyncio.create_task(
898
- self._watch_session_title(session_id, session)
855
+ @app.get("/")
856
+ async def index() -> Response:
857
+ return _html_response(_render_workspaces_manager_shell())
858
+
859
+ @app.get("/favicon.ico")
860
+ async def favicon() -> Response:
861
+ return Response(status_code=204)
862
+
863
+ @app.get("/api/workspaces")
864
+ async def workspaces() -> JSONResponse:
865
+ return JSONResponse({"workspaces": registry.list_workspaces()})
866
+
867
+ @app.post("/api/workspaces")
868
+ async def add_workspace(payload: "typing.Dict[str, object]") -> JSONResponse:
869
+ try:
870
+ entry = await registry.add_workspace(
871
+ str(payload.get("name") or ""),
872
+ work_dir=str(payload.get("dir") or "./"),
873
+ board=(
874
+ None
875
+ if payload.get("board") in (None, "")
876
+ else str(payload.get("board"))
877
+ ),
899
878
  )
900
- return session_id
901
-
902
- async def close_session(self, session_id: str) -> None:
903
- async with self._lock:
904
- if len(self._session_order) <= 1:
905
- raise ValueError("cannot close the last session")
906
- session = self._sessions.pop(session_id, None)
907
- if session is None:
908
- raise KeyError(session_id)
909
- watcher = self._state_watchers.pop(session_id, None)
910
- self._persisted_titles.pop(session_id, None)
911
- self._session_order = [
912
- item for item in self._session_order if item != session_id
913
- ]
914
- if watcher is not None:
915
- watcher.cancel()
916
- await asyncio.gather(watcher, return_exceptions=True)
917
- await session.close()
918
- self.persist_workspace_state()
919
-
920
- async def _watch_session_title(self, session_id: str, session) -> None:
921
- subscriber = session.subscribe()
879
+ except ValueError as exc:
880
+ return JSONResponse({"ok": False, "error": str(exc)}, status_code=400)
881
+ return JSONResponse(
882
+ {
883
+ "ok": True,
884
+ "workspace": entry.to_dict(),
885
+ "workspaces": registry.list_workspaces(),
886
+ }
887
+ )
888
+
889
+ @app.delete("/api/workspaces/{workspace_id}")
890
+ async def delete_workspace(workspace_id: str) -> JSONResponse:
922
891
  try:
923
- while True:
924
- event = await subscriber.get()
925
- if event is None:
926
- return
927
- if not isinstance(event, dict) or event.get("type") != "title_changed":
928
- continue
929
- title = str(event.get("title") or "")
930
- if title == self._persisted_titles.get(session_id, ""):
931
- continue
932
- self._persisted_titles[session_id] = title
933
- self.persist_workspace_state()
934
- finally:
935
- session.unsubscribe(subscriber)
892
+ await registry.delete_workspace(workspace_id)
893
+ except KeyError:
894
+ raise HTTPException(status_code=404, detail="workspace not found")
895
+ return JSONResponse({"ok": True, "workspaces": registry.list_workspaces()})
896
+
897
+ @app.api_route("/w/{workspace_id}", methods=["GET", "HEAD"])
898
+ async def workspace_index_redirect(workspace_id: str) -> RedirectResponse:
899
+ _workspace_entry_or_404(registry, workspace_id)
900
+ return RedirectResponse(url="/w/{0}/".format(workspace_id), status_code=307)
901
+
902
+ @app.api_route("/w/{workspace_id}/", methods=["GET", "HEAD"])
903
+ async def workspace_index(workspace_id: str) -> HTMLResponse:
904
+ entry = _workspace_entry_or_404(registry, workspace_id)
905
+ return _html_response(
906
+ _render_workspace_shell(
907
+ entry.definition.board_path,
908
+ title=entry.definition.workspace_id,
909
+ work_dir=entry.definition.work_dir,
910
+ )
911
+ )
936
912
 
937
- def persist_workspace_state(self) -> None:
938
- tabs = []
939
- for session_id in self._session_order:
940
- session = self._sessions.get(session_id)
941
- if session is None:
942
- continue
943
- summary = _session_summary(session)
944
- title = str(summary.get("title") or "").strip()
945
- rollout_path = str(session.rollout_path() or "")
946
- if not title and not rollout_path:
947
- continue
948
- tabs.append({"title": title, "rollout_path": rollout_path})
949
- self._state_store.save_tabs(tabs)
913
+ @app.api_route("/w/{workspace_id}/board", methods=["GET", "HEAD"])
914
+ async def workspace_board(workspace_id: str) -> Response:
915
+ entry = _workspace_entry_or_404(registry, workspace_id)
916
+ return _board_response(entry.definition.board_path)
917
+
918
+ @app.get("/w/{workspace_id}/api/board")
919
+ async def workspace_board_status(workspace_id: str) -> JSONResponse:
920
+ entry = _workspace_entry_or_404(registry, workspace_id)
921
+ return _board_status_response(entry.definition.board_path)
922
+
923
+ @app.get("/w/{workspace_id}/ws/session")
924
+ async def workspace_websocket_backend_hint(workspace_id: str) -> JSONResponse:
925
+ _workspace_entry_or_404(registry, workspace_id)
926
+ return _websocket_backend_hint_response()
927
+
928
+ @app.get("/w/{workspace_id}/api/sessions")
929
+ async def workspace_sessions(workspace_id: str) -> JSONResponse:
930
+ entry = _workspace_entry_or_404(registry, workspace_id)
931
+ return _sessions_response(entry.manager)
932
+
933
+ @app.post("/w/{workspace_id}/api/sessions")
934
+ async def workspace_new_session(workspace_id: str) -> JSONResponse:
935
+ entry = _workspace_entry_or_404(registry, workspace_id)
936
+ return await _new_session_response(entry.manager)
937
+
938
+ @app.delete("/w/{workspace_id}/api/sessions/{session_id}")
939
+ async def workspace_delete_session(workspace_id: str, session_id: str) -> JSONResponse:
940
+ entry = _workspace_entry_or_404(registry, workspace_id)
941
+ return await _delete_session_response(entry.manager, session_id)
942
+
943
+ @app.get("/w/{workspace_id}/api/session")
944
+ async def workspace_session(
945
+ workspace_id: str,
946
+ session_id: "typing.Union[str, None]" = None,
947
+ ) -> JSONResponse:
948
+ entry = _workspace_entry_or_404(registry, workspace_id)
949
+ return _session_response(entry.manager, session_id)
950
+
951
+ @app.post("/w/{workspace_id}/api/session/message")
952
+ async def workspace_message(
953
+ workspace_id: str,
954
+ payload: "typing.Dict[str, object]",
955
+ ) -> JSONResponse:
956
+ entry = _workspace_entry_or_404(registry, workspace_id)
957
+ return await _message_response(entry.manager, payload)
950
958
 
951
- def get(self, session_id: "typing.Union[str, None]" = None) -> "WorkspaceInteractiveSession":
952
- resolved_id = self.resolve_session_id(session_id)
959
+ @app.websocket("/w/{workspace_id}/ws/session")
960
+ async def workspace_websocket_session(workspace_id: str, websocket: WebSocket) -> None:
961
+ if not _auth_cookie_matches(auth_token, websocket.cookies.get(AUTH_COOKIE_NAME)):
962
+ await websocket.close(code=1008)
963
+ return
953
964
  try:
954
- return self._sessions[resolved_id]
955
- except KeyError:
956
- raise KeyError(resolved_id)
957
-
958
- def resolve_session_id(self, session_id: "typing.Union[str, None]" = None) -> str:
959
- if session_id:
960
- return str(session_id)
961
- if not self._session_order:
962
- raise KeyError("no sessions")
963
- return self._session_order[0]
964
-
965
- def list_sessions(self) -> "typing.List[typing.Dict[str, object]]":
966
- result = []
967
- for session_id in self._session_order:
968
- session = self._sessions[session_id]
969
- summary = _session_summary(session)
970
- result.append(
971
- {
972
- "id": session_id,
973
- "title": summary.get("title") or "pycodex",
974
- "running": bool(summary.get("running")),
975
- "spinner": summary.get("spinner") or "",
976
- "turn_count": summary.get("turn_count") or 0,
977
- }
965
+ entry = registry.get(workspace_id)
966
+ except (KeyError, ValueError):
967
+ await websocket.close(code=1008)
968
+ return
969
+ await _websocket_session_handler(entry.manager, websocket)
970
+
971
+ return app
972
+
973
+
974
+ def _install_auth(app: FastAPI, password: "typing.Union[str, None]") -> str:
975
+ password_text = str(password or "")
976
+ if not password_text:
977
+ return ""
978
+ token = secrets.token_urlsafe(32)
979
+
980
+ @app.middleware("http")
981
+ async def auth_middleware(request: Request, call_next):
982
+ path = request.url.path
983
+ if path in {"/favicon.ico", "/login"} or _auth_cookie_matches(
984
+ token,
985
+ request.cookies.get(AUTH_COOKIE_NAME),
986
+ ):
987
+ return await call_next(request)
988
+ if path.startswith("/api/") or "/api/" in path:
989
+ return JSONResponse(
990
+ {"ok": False, "error": "authentication required"},
991
+ status_code=401,
978
992
  )
979
- return result
993
+ return RedirectResponse(url="/login", status_code=303)
994
+
995
+ @app.get("/login")
996
+ async def login_page() -> HTMLResponse:
997
+ return _html_response(_render_login_shell())
998
+
999
+ @app.post("/login")
1000
+ async def login(payload: "typing.Dict[str, object]") -> JSONResponse:
1001
+ if not secrets.compare_digest(str(payload.get("password") or ""), password_text):
1002
+ return JSONResponse(
1003
+ {"ok": False, "error": "invalid password"},
1004
+ status_code=401,
1005
+ )
1006
+ response = JSONResponse({"ok": True})
1007
+ response.set_cookie(
1008
+ AUTH_COOKIE_NAME,
1009
+ token,
1010
+ httponly=True,
1011
+ samesite="lax",
1012
+ )
1013
+ return response
980
1014
 
1015
+ return token
981
1016
 
982
- def create_app(
983
- session_source: "typing.Union[WorkspaceSessionManager, SessionFactory]",
984
- board_path: "typing.Union[Path, None]",
985
- ) -> FastAPI:
986
- manager = (
987
- session_source
988
- if isinstance(session_source, WorkspaceSessionManager)
989
- else WorkspaceSessionManager(session_source, board_path)
990
- )
991
1017
 
1018
+ def _auth_cookie_matches(token: str, cookie: "typing.Union[str, None]") -> bool:
1019
+ if not token:
1020
+ return True
1021
+ return bool(cookie) and secrets.compare_digest(str(cookie), token)
1022
+
1023
+
1024
+ def _render_login_shell() -> str:
1025
+ return """<!doctype html>
1026
+ <html>
1027
+ <head>
1028
+ <meta charset="utf-8">
1029
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1030
+ <title>pycodex login</title>
1031
+ <style>
1032
+ :root {
1033
+ color-scheme: light dark;
1034
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
1035
+ }
1036
+ body {
1037
+ min-height: 100vh;
1038
+ margin: 0;
1039
+ display: grid;
1040
+ place-items: center;
1041
+ background: Canvas;
1042
+ color: CanvasText;
1043
+ }
1044
+ form {
1045
+ width: min(360px, calc(100vw - 32px));
1046
+ display: grid;
1047
+ gap: 12px;
1048
+ }
1049
+ h1 {
1050
+ margin: 0;
1051
+ font-size: 22px;
1052
+ }
1053
+ input, button {
1054
+ min-height: 38px;
1055
+ border-radius: 7px;
1056
+ border: 1px solid color-mix(in srgb, CanvasText 18%, Canvas 82%);
1057
+ padding: 8px 10px;
1058
+ font: inherit;
1059
+ }
1060
+ button {
1061
+ cursor: pointer;
1062
+ }
1063
+ .status {
1064
+ min-height: 20px;
1065
+ color: #b42318;
1066
+ font-size: 13px;
1067
+ }
1068
+ </style>
1069
+ </head>
1070
+ <body>
1071
+ <form id="loginForm">
1072
+ <h1>pycodex workspace</h1>
1073
+ <input id="passwordInput" type="password" autocomplete="current-password" autofocus>
1074
+ <button type="submit">Open</button>
1075
+ <div id="status" class="status" role="status"></div>
1076
+ </form>
1077
+ <script>
1078
+ const form = document.getElementById("loginForm");
1079
+ const passwordInput = document.getElementById("passwordInput");
1080
+ const statusEl = document.getElementById("status");
1081
+ form.addEventListener("submit", async function(event) {
1082
+ event.preventDefault();
1083
+ statusEl.textContent = "";
1084
+ const response = await fetch("login", {
1085
+ method: "POST",
1086
+ headers: {"Content-Type": "application/json"},
1087
+ body: JSON.stringify({password: passwordInput.value}),
1088
+ });
1089
+ if (response.ok) {
1090
+ window.location.href = "/";
1091
+ return;
1092
+ }
1093
+ statusEl.textContent = "Invalid password";
1094
+ });
1095
+ </script>
1096
+ </body>
1097
+ </html>"""
1098
+
1099
+
1100
+ def _create_lifespan_app(
1101
+ start: "typing.Callable[[], typing.Awaitable[None]]",
1102
+ close: "typing.Callable[[], typing.Awaitable[None]]",
1103
+ ) -> FastAPI:
992
1104
  if asynccontextmanager is not None:
993
1105
  @asynccontextmanager
994
1106
  async def lifespan(_app):
995
- await manager.start()
1107
+ await start()
996
1108
  try:
997
1109
  yield
998
1110
  finally:
999
- await manager.close()
1111
+ await close()
1112
+
1113
+ return FastAPI(lifespan=lifespan)
1114
+
1115
+ app = FastAPI()
1000
1116
 
1001
- app = FastAPI(lifespan=lifespan)
1002
- else:
1003
- app = FastAPI()
1117
+ @app.on_event("startup")
1118
+ async def startup() -> None:
1119
+ await start()
1004
1120
 
1005
- @app.on_event("startup")
1006
- async def startup() -> None:
1007
- await manager.start()
1121
+ @app.on_event("shutdown")
1122
+ async def shutdown() -> None:
1123
+ await close()
1124
+ return app
1008
1125
 
1009
- @app.on_event("shutdown")
1010
- async def shutdown() -> None:
1011
- await manager.close()
1012
1126
 
1127
+ def _install_workspace_routes(
1128
+ app: FastAPI,
1129
+ manager: WorkspaceSessionManager,
1130
+ board_path: "typing.Union[Path, None]",
1131
+ ) -> None:
1013
1132
  @app.get("/")
1014
1133
  async def index() -> HTMLResponse:
1015
1134
  return _html_response(_render_workspace_shell(board_path))
@@ -1022,163 +1141,189 @@ def create_app(
1022
1141
  async def board() -> Response:
1023
1142
  return _board_response(board_path)
1024
1143
 
1025
- @app.api_route("/{path_name}", methods=["GET", "HEAD"])
1026
- async def legacy_board_path(path_name: str) -> Response:
1027
- if board_path is not None and path_name == board_path.name:
1028
- return _board_response(board_path)
1029
- raise HTTPException(status_code=404, detail="not found")
1030
-
1031
1144
  @app.get("/api/board")
1032
1145
  async def board_status() -> JSONResponse:
1033
- if board_path is None or not board_path.is_file():
1034
- return JSONResponse({"exists": False})
1035
- stat = board_path.stat()
1036
- return JSONResponse(
1037
- {
1038
- "exists": True,
1039
- "path": str(board_path),
1040
- "mtime_ns": stat.st_mtime_ns,
1041
- "size": stat.st_size,
1042
- }
1043
- )
1146
+ return _board_status_response(board_path)
1044
1147
 
1045
1148
  @app.get("/ws/session")
1046
1149
  async def websocket_backend_hint() -> JSONResponse:
1047
- return JSONResponse(
1048
- {
1049
- "error": "websocket backend is unavailable; HTTP polling is active",
1050
- },
1051
- status_code=426,
1052
- )
1053
-
1054
- def _board_response(board_path: "typing.Union[Path, None]") -> Response:
1055
- if board_path is None:
1056
- return _html_response(_render_empty_board())
1057
- if not board_path.is_file():
1058
- return _html_response(_render_missing_board(board_path))
1059
- return _html_response(board_path.read_text(encoding="utf-8", errors="replace"))
1150
+ return _websocket_backend_hint_response()
1060
1151
 
1061
1152
  @app.get("/api/sessions")
1062
1153
  async def sessions() -> JSONResponse:
1063
- return JSONResponse({"sessions": manager.list_sessions()})
1154
+ return _sessions_response(manager)
1064
1155
 
1065
1156
  @app.post("/api/sessions")
1066
1157
  async def new_session() -> JSONResponse:
1067
- session_id = await manager.create_session()
1068
- return JSONResponse(
1069
- {
1070
- "ok": True,
1071
- "session_id": session_id,
1072
- "sessions": manager.list_sessions(),
1073
- "snapshot": _session_snapshot(manager.get(session_id)),
1074
- }
1075
- )
1158
+ return await _new_session_response(manager)
1076
1159
 
1077
1160
  @app.delete("/api/sessions/{session_id}")
1078
1161
  async def delete_session(session_id: str) -> JSONResponse:
1079
- try:
1080
- await manager.close_session(session_id)
1081
- except KeyError:
1082
- raise HTTPException(status_code=404, detail="session not found")
1083
- except ValueError as exc:
1084
- return JSONResponse({"ok": False, "error": str(exc)}, status_code=400)
1085
- return JSONResponse({"ok": True, "sessions": manager.list_sessions()})
1162
+ return await _delete_session_response(manager, session_id)
1086
1163
 
1087
1164
  @app.get("/api/session")
1088
1165
  async def session(
1089
1166
  session_id: "typing.Union[str, None]" = None,
1090
1167
  ) -> JSONResponse:
1091
- try:
1092
- resolved_id = manager.resolve_session_id(session_id)
1093
- link = manager.get(resolved_id)
1094
- except KeyError:
1095
- raise HTTPException(status_code=404, detail="session not found")
1096
- return JSONResponse(
1097
- {
1098
- "session_id": resolved_id,
1099
- "sessions": manager.list_sessions(),
1100
- "snapshot": _session_snapshot(link),
1101
- }
1102
- )
1168
+ return _session_response(manager, session_id)
1103
1169
 
1104
1170
  @app.post("/api/session/message")
1105
1171
  async def message(
1106
1172
  payload: "typing.Dict[str, object]",
1107
1173
  ) -> JSONResponse:
1108
- session_id = str(payload.get("session_id") or "")
1109
- try:
1110
- link = manager.get(session_id or None)
1111
- except KeyError:
1112
- raise HTTPException(status_code=404, detail="session not found")
1113
- result = await link.submit(str(payload.get("prompt") or ""))
1114
- if isinstance(result, dict):
1115
- result.setdefault("sessions", manager.list_sessions())
1116
- status = 200 if result.get("ok") else 400
1117
- return JSONResponse(result, status_code=status)
1174
+ return await _message_response(manager, payload)
1118
1175
 
1119
1176
  @app.websocket("/ws/session")
1120
1177
  async def websocket_session(websocket: WebSocket) -> None:
1121
- await websocket.accept()
1122
- session_id = str(websocket.query_params.get("session_id") or "")
1123
- try:
1124
- link = manager.get(session_id or None)
1125
- except KeyError:
1178
+ auth_token = typing.cast(str, app.state.workspace_auth_token)
1179
+ if not _auth_cookie_matches(auth_token, websocket.cookies.get(AUTH_COOKIE_NAME)):
1126
1180
  await websocket.close(code=1008)
1127
1181
  return
1128
- subscriber = link.subscribe()
1129
- sender = asyncio.create_task(_send_ws_events(websocket, subscriber))
1130
- try:
1131
- while True:
1132
- data = await websocket.receive_text()
1133
- try:
1134
- payload = json.loads(data)
1135
- except ValueError:
1136
- await websocket.send_json({"type": "error", "error": "invalid json"})
1137
- continue
1138
- action = str(payload.get("type") or payload.get("action") or "")
1139
- if action == "send":
1140
- target_session_id = str(payload.get("session_id") or session_id or "")
1141
- try:
1142
- target_link = manager.get(target_session_id or None)
1143
- except KeyError:
1144
- await websocket.send_json(
1145
- {"type": "error", "error": "session not found"}
1146
- )
1147
- continue
1148
- result = await target_link.submit(
1149
- str(payload.get("prompt") or ""),
1150
- sender=str(payload.get("sender") or "web"),
1151
- )
1152
- await websocket.send_json({"type": "send_result", "result": result})
1153
- elif action == "ping":
1154
- await websocket.send_json({"type": "pong"})
1155
- else:
1156
- await websocket.send_json({"type": "error", "error": "unknown action"})
1157
- except WebSocketDisconnect:
1158
- pass
1159
- finally:
1160
- link.unsubscribe(subscriber)
1161
- sender.cancel()
1162
- await asyncio.gather(sender, return_exceptions=True)
1182
+ await _websocket_session_handler(manager, websocket)
1183
+
1184
+
1185
+ def _board_status_response(board_path: "typing.Union[Path, None]") -> JSONResponse:
1186
+ if board_path is None or not board_path.is_file():
1187
+ return JSONResponse({"exists": False})
1188
+ stat = board_path.stat()
1189
+ return JSONResponse(
1190
+ {
1191
+ "exists": True,
1192
+ "path": str(board_path),
1193
+ "mtime_ns": stat.st_mtime_ns,
1194
+ "size": stat.st_size,
1195
+ }
1196
+ )
1163
1197
 
1164
- return app
1165
1198
 
1199
+ def _websocket_backend_hint_response() -> JSONResponse:
1200
+ return JSONResponse(
1201
+ {
1202
+ "error": "websocket backend is unavailable; HTTP polling is active",
1203
+ },
1204
+ status_code=426,
1205
+ )
1166
1206
 
1167
- def _session_snapshot(session) -> "typing.Dict[str, object]":
1168
- return typing.cast("typing.Dict[str, object]", session.snapshot())
1169
1207
 
1208
+ def _sessions_response(manager: 'WorkspaceSessionManager') -> JSONResponse:
1209
+ return JSONResponse({"sessions": manager.list_sessions()})
1170
1210
 
1171
- def _session_summary(session) -> "typing.Dict[str, object]":
1172
- summary = getattr(session, "summary", None)
1173
- if callable(summary):
1174
- return typing.cast("typing.Dict[str, object]", summary())
1175
- snapshot = _session_snapshot(session)
1176
- return {
1177
- "title": snapshot.get("title") or "",
1178
- "running": bool(snapshot.get("running")),
1179
- "spinner": snapshot.get("spinner") or "",
1180
- "turn_count": len(snapshot.get("turns") or []),
1181
- }
1211
+
1212
+ async def _new_session_response(manager: 'WorkspaceSessionManager') -> JSONResponse:
1213
+ session_id = await manager.create_session()
1214
+ return JSONResponse(
1215
+ {
1216
+ "ok": True,
1217
+ "session_id": session_id,
1218
+ "sessions": manager.list_sessions(),
1219
+ "snapshot": session_snapshot(manager.get(session_id)),
1220
+ }
1221
+ )
1222
+
1223
+
1224
+ async def _delete_session_response(
1225
+ manager: 'WorkspaceSessionManager',
1226
+ session_id: str,
1227
+ ) -> JSONResponse:
1228
+ try:
1229
+ await manager.close_session(session_id)
1230
+ except KeyError:
1231
+ raise HTTPException(status_code=404, detail="session not found")
1232
+ except ValueError as exc:
1233
+ return JSONResponse({"ok": False, "error": str(exc)}, status_code=400)
1234
+ return JSONResponse({"ok": True, "sessions": manager.list_sessions()})
1235
+
1236
+
1237
+ def _session_response(
1238
+ manager: 'WorkspaceSessionManager',
1239
+ session_id: "typing.Union[str, None]" = None,
1240
+ ) -> JSONResponse:
1241
+ try:
1242
+ resolved_id = manager.resolve_session_id(session_id)
1243
+ link = manager.get(resolved_id)
1244
+ except KeyError:
1245
+ raise HTTPException(status_code=404, detail="session not found")
1246
+ return JSONResponse(
1247
+ {
1248
+ "session_id": resolved_id,
1249
+ "sessions": manager.list_sessions(),
1250
+ "snapshot": session_snapshot(link),
1251
+ }
1252
+ )
1253
+
1254
+
1255
+ async def _message_response(
1256
+ manager: 'WorkspaceSessionManager',
1257
+ payload: "typing.Dict[str, object]",
1258
+ ) -> JSONResponse:
1259
+ session_id = str(payload.get("session_id") or "")
1260
+ try:
1261
+ link = manager.get(session_id or None)
1262
+ except KeyError:
1263
+ raise HTTPException(status_code=404, detail="session not found")
1264
+ result = await link.submit(str(payload.get("prompt") or ""))
1265
+ if isinstance(result, dict):
1266
+ result.setdefault("sessions", manager.list_sessions())
1267
+ status = 200 if result.get("ok") else 400
1268
+ return JSONResponse(result, status_code=status)
1269
+
1270
+
1271
+ async def _websocket_session_handler(
1272
+ manager: 'WorkspaceSessionManager',
1273
+ websocket: WebSocket,
1274
+ ) -> None:
1275
+ await websocket.accept()
1276
+ session_id = str(websocket.query_params.get("session_id") or "")
1277
+ try:
1278
+ link = manager.get(session_id or None)
1279
+ except KeyError:
1280
+ await websocket.close(code=1008)
1281
+ return
1282
+ subscriber = link.subscribe()
1283
+ sender = asyncio.create_task(_send_ws_events(websocket, subscriber))
1284
+ try:
1285
+ while True:
1286
+ data = await websocket.receive_text()
1287
+ try:
1288
+ payload = json.loads(data)
1289
+ except ValueError:
1290
+ await websocket.send_json({"type": "error", "error": "invalid json"})
1291
+ continue
1292
+ action = str(payload.get("type") or payload.get("action") or "")
1293
+ if action == "send":
1294
+ target_session_id = str(payload.get("session_id") or session_id or "")
1295
+ try:
1296
+ target_link = manager.get(target_session_id or None)
1297
+ except KeyError:
1298
+ await websocket.send_json(
1299
+ {"type": "error", "error": "session not found"}
1300
+ )
1301
+ continue
1302
+ result = await target_link.submit(
1303
+ str(payload.get("prompt") or ""),
1304
+ sender=str(payload.get("sender") or "web"),
1305
+ )
1306
+ await websocket.send_json({"type": "send_result", "result": result})
1307
+ elif action == "ping":
1308
+ await websocket.send_json({"type": "pong"})
1309
+ else:
1310
+ await websocket.send_json({"type": "error", "error": "unknown action"})
1311
+ except WebSocketDisconnect:
1312
+ pass
1313
+ finally:
1314
+ link.unsubscribe(subscriber)
1315
+ sender.cancel()
1316
+ await asyncio.gather(sender, return_exceptions=True)
1317
+
1318
+
1319
+ def _last_assistant_text(turns: "typing.Iterable[typing.Dict[str, object]]") -> str:
1320
+ for turn in reversed(list(turns)):
1321
+ if str(turn.get("kind") or "assistant") == "control":
1322
+ continue
1323
+ response = str(turn.get("response") or "").strip()
1324
+ if response:
1325
+ return response
1326
+ return ""
1182
1327
 
1183
1328
 
1184
1329
  def _public_turn(turn: "typing.Dict[str, object]") -> "typing.Dict[str, object]":
@@ -1235,15 +1380,70 @@ async def _send_ws_events(websocket: WebSocket, subscriber: "asyncio.Queue") ->
1235
1380
  await websocket.send_json(event)
1236
1381
 
1237
1382
 
1383
+ def _board_response(board_path: "typing.Union[Path, None]") -> Response:
1384
+ if board_path is None:
1385
+ return _html_response(_render_empty_board())
1386
+ if not board_path.is_file():
1387
+ return _html_response(_render_missing_board(board_path))
1388
+ return _html_response(board_path.read_text(encoding="utf-8", errors="replace"))
1389
+
1390
+
1391
+ def _workspace_entry_or_404(
1392
+ registry: 'WorkspaceRegistry',
1393
+ workspace_id: str,
1394
+ ) -> 'WorkspaceEntry':
1395
+ try:
1396
+ return registry.get(workspace_id)
1397
+ except (KeyError, ValueError):
1398
+ raise HTTPException(status_code=404, detail="workspace not found")
1399
+
1400
+
1238
1401
  def run_serve_cli(args: "argparse.Namespace") -> int:
1239
1402
  import uvicorn
1240
1403
 
1241
- board_arg = args.board if args.board is not None else _default_board_path()
1242
- host, port, board_path = parse_target(args.listen, board_arg)
1243
- if board_path is not None and not board_path.parent.is_dir():
1244
- raise ValueError("board parent directory does not exist: {0}".format(board_path.parent))
1245
-
1404
+ host, port = parse_listen(args.listen)
1246
1405
  configure_loguru()
1406
+
1407
+ definitions = load_workspace_definitions(args.workspace_config)
1408
+ entries = [
1409
+ _build_workspace_entry(definition, args)
1410
+ for definition in definitions
1411
+ ]
1412
+ registry = WorkspaceRegistry(
1413
+ entries,
1414
+ config_path=args.workspace_config,
1415
+ entry_factory=lambda definition, persist_callback: _build_workspace_entry(
1416
+ definition,
1417
+ args,
1418
+ persist_callback,
1419
+ ),
1420
+ )
1421
+ app = create_multi_workspace_app(registry, password=args.password)
1422
+
1423
+ print(
1424
+ "pycodex workspace listening on http://{0}:{1}".format(host, port),
1425
+ flush=True,
1426
+ )
1427
+ for definition in definitions:
1428
+ print(
1429
+ "workspace {0}: board={1} work_dir={2} url=http://{3}:{4}/w/{0}/".format(
1430
+ definition.workspace_id,
1431
+ definition.board_path or "",
1432
+ definition.work_dir,
1433
+ host,
1434
+ port,
1435
+ ),
1436
+ flush=True,
1437
+ )
1438
+ uvicorn.run(app, host=host, port=port, loop="asyncio")
1439
+ return 0
1440
+
1441
+
1442
+ def _build_workspace_entry(
1443
+ definition: 'WorkspaceDefinition',
1444
+ args: "argparse.Namespace",
1445
+ persist_callback: "typing.Union[typing.Callable[[], None], None]" = None,
1446
+ ) -> 'WorkspaceEntry':
1247
1447
  def build_session() -> "WorkspaceInteractiveSession":
1248
1448
  model = build_model(
1249
1449
  config_path=args.config,
@@ -1260,8 +1460,11 @@ def run_serve_cli(args: "argparse.Namespace") -> int:
1260
1460
  system_prompt=args.system_prompt,
1261
1461
  session_mode="tui",
1262
1462
  extra_contextual_user_messages=(
1263
- [_board_context_text(board_path)] if board_path is not None else []
1463
+ [_board_context_text(definition.board_path, definition.work_dir)]
1464
+ if definition.board_path is not None
1465
+ else []
1264
1466
  ),
1467
+ cwd=definition.work_dir,
1265
1468
  )
1266
1469
  return WorkspaceInteractiveSession(
1267
1470
  build_cli_queue(agent),
@@ -1271,33 +1474,37 @@ def run_serve_cli(args: "argparse.Namespace") -> int:
1271
1474
  def session_factory() -> "ThreadedWorkspaceInteractiveSession":
1272
1475
  return ThreadedWorkspaceInteractiveSession(build_session, asyncio.get_running_loop())
1273
1476
 
1274
- app = create_app(WorkspaceSessionManager(session_factory, board_path), board_path)
1275
- print(
1276
- "pycodex workspace listening on http://{0}:{1}".format(host, port),
1277
- flush=True,
1477
+ return WorkspaceEntry(
1478
+ definition=definition,
1479
+ manager=WorkspaceSessionManager(
1480
+ session_factory,
1481
+ definition.board_path,
1482
+ persist_callback=persist_callback,
1483
+ ),
1278
1484
  )
1279
- if board_path is not None:
1280
- print("board: {0}".format(board_path), flush=True)
1281
- uvicorn.run(app, host=host, port=port, loop="asyncio")
1282
- return 0
1283
1485
 
1284
1486
 
1285
- def _board_context_text(board_path: Path) -> str:
1487
+ def _board_context_text(
1488
+ board_path: Path,
1489
+ work_dir: "typing.Union[Path, None]" = None,
1490
+ ) -> str:
1286
1491
  return (
1287
1492
  "Current workspace board file: {0}. "
1288
1493
  "Changes you make to this file are shown to the user in real time. "
1289
1494
  "You can create or modify this file anytime."
1290
- ).format(_format_board_path_for_prompt(board_path))
1291
-
1292
-
1293
- def _default_board_path() -> Path:
1294
- return Path(tempfile.gettempdir()) / "pcws-{0}.html".format(uuid4().hex[:8])
1495
+ ).format(_format_board_path_for_prompt(board_path, work_dir=work_dir))
1295
1496
 
1296
1497
 
1297
- def _format_board_path_for_prompt(board_path: Path) -> str:
1498
+ def _format_board_path_for_prompt(
1499
+ board_path: Path,
1500
+ work_dir: "typing.Union[Path, None]" = None,
1501
+ ) -> str:
1298
1502
  resolved = board_path.resolve()
1299
1503
  try:
1300
- relative = os.path.relpath(str(resolved), str(Path.cwd().resolve()))
1504
+ relative = os.path.relpath(
1505
+ str(resolved),
1506
+ str(Path(work_dir or Path.cwd()).resolve()),
1507
+ )
1301
1508
  except ValueError:
1302
1509
  return str(resolved)
1303
1510
  if relative == ".":
@@ -1307,20 +1514,35 @@ def _format_board_path_for_prompt(board_path: Path) -> str:
1307
1514
  return "./{0}".format(relative)
1308
1515
 
1309
1516
 
1310
- def _render_workspace_shell(board_path: "typing.Union[Path, None]") -> str:
1517
+ def _render_workspace_shell(
1518
+ board_path: "typing.Union[Path, None]",
1519
+ title: "typing.Union[str, None]" = None,
1520
+ work_dir: "typing.Union[Path, None]" = None,
1521
+ ) -> str:
1311
1522
  board_label = str(board_path) if board_path is not None else "No board"
1523
+ cwd_label = str(work_dir or Path.cwd())
1524
+ page_title = str(title or "pycodex workspace")
1312
1525
  template = (Path(__file__).with_name("workspace.html")).read_text(
1313
1526
  encoding="utf-8"
1314
1527
  )
1315
- return template.replace("__BOARD_LABEL__", html.escape(board_label))
1528
+ return (
1529
+ template
1530
+ .replace("__WORKSPACE_TITLE__", html.escape(page_title))
1531
+ .replace("__BOARD_LABEL__", html.escape(board_label))
1532
+ .replace("__CWD_LABEL__", html.escape(cwd_label))
1533
+ )
1534
+
1535
+
1536
+ def _render_workspaces_manager_shell() -> str:
1537
+ return (Path(__file__).with_name("workspaces.html")).read_text(encoding="utf-8")
1316
1538
 
1317
1539
 
1318
1540
  def _render_empty_board() -> str:
1319
1541
  return """<!doctype html>
1320
1542
  <html><head><meta charset="utf-8"><title>No board</title></head>
1321
1543
  <body style="font:14px -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;padding:24px">
1322
- <h1>No board configured</h1>
1323
- <p>Start with <code>pycodex-ws --listen 0.0.0.0:6007 --board ./board.html</code>.</p>
1544
+ <h1>No board</h1>
1545
+ <p>Add a workspace from the workspaces manager page.</p>
1324
1546
  </body></html>"""
1325
1547
 
1326
1548