python-codex 0.1.13__py3-none-any.whl → 0.2.0__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.
Files changed (50) hide show
  1. pycodex/agent.py +71 -11
  2. pycodex/cli.py +16 -356
  3. pycodex/context.py +12 -0
  4. pycodex/feishu_card.py +76 -30
  5. pycodex/feishu_link.py +131 -11
  6. pycodex/interactive_session.py +397 -0
  7. pycodex/model.py +11 -22
  8. pycodex/protocol.py +0 -5
  9. pycodex/runtime.py +23 -0
  10. pycodex/runtime_services.py +2 -2
  11. pycodex/tools/agent_tool_schemas.py +1 -1
  12. pycodex/tools/apply_patch_tool.py +1 -1
  13. pycodex/tools/base_tool.py +1 -27
  14. pycodex/tools/close_agent_tool.py +11 -4
  15. pycodex/tools/code_mode_manager.py +1 -1
  16. pycodex/tools/exec_command_tool.py +40 -16
  17. pycodex/tools/exec_tool.py +18 -2
  18. pycodex/tools/grep_files_tool.py +19 -6
  19. pycodex/tools/ipython_tool.py +3 -2
  20. pycodex/tools/list_dir_tool.py +19 -6
  21. pycodex/tools/read_file_tool.py +39 -9
  22. pycodex/tools/request_permissions_tool.py +12 -1
  23. pycodex/tools/request_user_input_tool.py +28 -1
  24. pycodex/tools/send_input_tool.py +4 -2
  25. pycodex/tools/shell_command_tool.py +23 -6
  26. pycodex/tools/shell_tool.py +13 -4
  27. pycodex/tools/spawn_agent_tool.py +31 -8
  28. pycodex/tools/unified_exec_manager.py +49 -93
  29. pycodex/tools/update_plan_tool.py +14 -6
  30. pycodex/tools/view_image_tool.py +17 -16
  31. pycodex/tools/wait_agent_tool.py +15 -3
  32. pycodex/tools/wait_tool.py +18 -4
  33. pycodex/tools/web_search_tool.py +2 -1
  34. pycodex/tools/write_stdin_tool.py +42 -10
  35. pycodex/utils/compactor.py +7 -1
  36. pycodex/utils/session_persist.py +42 -1
  37. pycodex/utils/truncation.py +206 -0
  38. pycodex/utils/visualize.py +34 -15
  39. {python_codex-0.1.13.dist-info → python_codex-0.2.0.dist-info}/METADATA +4 -1
  40. python_codex-0.2.0.dist-info/RECORD +88 -0
  41. {python_codex-0.1.13.dist-info → python_codex-0.2.0.dist-info}/entry_points.txt +1 -0
  42. workspace_server/__init__.py +23 -0
  43. workspace_server/__main__.py +5 -0
  44. workspace_server/app.py +1347 -0
  45. workspace_server/workspace.html +866 -0
  46. pycodex/prompts/exec_tools.json +0 -411
  47. pycodex/prompts/subagent_tools.json +0 -163
  48. python_codex-0.1.13.dist-info/RECORD +0 -84
  49. {python_codex-0.1.13.dist-info → python_codex-0.2.0.dist-info}/WHEEL +0 -0
  50. {python_codex-0.1.13.dist-info → python_codex-0.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1347 @@
1
+ import argparse
2
+ import asyncio
3
+ import html
4
+ import json
5
+ import os
6
+ import threading
7
+ import tempfile
8
+ from uuid import uuid4
9
+ from dataclasses import asdict, is_dataclass
10
+ try:
11
+ from contextlib import asynccontextmanager
12
+ except ImportError: # pragma: no cover - Python 3.6 compatibility
13
+ asynccontextmanager = None
14
+ from pathlib import Path
15
+
16
+ from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
17
+ from fastapi.responses import HTMLResponse, JSONResponse, Response
18
+
19
+ from pycodex.cli import build_agent, build_cli_queue, build_model, configure_loguru
20
+ from pycodex.interactive_session import run_interactive_session
21
+ from pycodex.model import DEFAULT_CODEX_CONFIG_PATH
22
+ from pycodex.protocol import AgentEvent, ToolCall
23
+ from pycodex.utils.session_persist import (
24
+ SessionRolloutRecorder,
25
+ load_resumed_session_path,
26
+ )
27
+ from pycodex.utils import uuid7_string
28
+ from pycodex.utils.visualize import IDLE_LISTENING_STATUS, shorten_title, tool_summary
29
+ import typing
30
+
31
+
32
+ JSONValue = typing.Union[
33
+ None,
34
+ bool,
35
+ int,
36
+ float,
37
+ str,
38
+ typing.List["JSONValue"],
39
+ typing.Dict[str, "JSONValue"],
40
+ ]
41
+
42
+
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
+ def build_parser() -> "argparse.ArgumentParser":
93
+ parser = argparse.ArgumentParser(
94
+ prog="pycodex-ws",
95
+ description="Run a local pycodex workspace with a board and chat session.",
96
+ )
97
+ parser.add_argument(
98
+ "--listen",
99
+ default="127.0.0.1:6007",
100
+ help="Bind address as host:port, for example 0.0.0.0:6007.",
101
+ )
102
+ parser.add_argument(
103
+ "--board",
104
+ default=None,
105
+ help="Optional board HTML path. Defaults to a writable random path under /tmp.",
106
+ )
107
+ parser.add_argument(
108
+ "--config",
109
+ default=str(DEFAULT_CODEX_CONFIG_PATH),
110
+ help="Path to Codex config.toml.",
111
+ )
112
+ parser.add_argument("--profile", default=None, help="Optional profile name.")
113
+ parser.add_argument(
114
+ "--system-prompt",
115
+ default=None,
116
+ help="Optional base instructions override passed to the model.",
117
+ )
118
+ parser.add_argument(
119
+ "--timeout-seconds",
120
+ type=float,
121
+ default=120.0,
122
+ help="HTTP timeout for one model call.",
123
+ )
124
+ parser.add_argument(
125
+ "--vllm-endpoint",
126
+ default=None,
127
+ help="Optional base URL for a chat-completions-backed vLLM server.",
128
+ )
129
+ parser.add_argument(
130
+ "--use-chat-completion",
131
+ default=False,
132
+ action="store_true",
133
+ help="Start a local responses compat server for this session.",
134
+ )
135
+ parser.add_argument(
136
+ "--use-messages",
137
+ default=False,
138
+ action="store_true",
139
+ help="Route through a downstream /v1/messages backend.",
140
+ )
141
+ return parser
142
+
143
+
144
+ def parse_target(
145
+ target: str,
146
+ board: "typing.Union[str, None]" = None,
147
+ ) -> "typing.Tuple[str, int, typing.Union[Path, None]]":
148
+ 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
+ if ":" not in target_text:
155
+ raise ValueError("workspace target must look like host:port")
156
+ host, port_text = target_text.rsplit(":", 1)
157
+ host = host.strip() or "127.0.0.1"
158
+ try:
159
+ port = int(port_text)
160
+ except ValueError as exc:
161
+ 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
164
+
165
+
166
+ SessionFactory = typing.Callable[[], object]
167
+ ThreadedSessionFactory = typing.Callable[[], "WorkspaceInteractiveSession"]
168
+ SESSION_CLOSE_TIMEOUT_SECONDS = 2.0
169
+ SPINNER_STATUS_PREVIEW_LIMIT = 180
170
+
171
+
172
+ class WebSessionView:
173
+ def __init__(self) -> None:
174
+ self._input_queue: "asyncio.Queue" = asyncio.Queue()
175
+ self._subscribers: "typing.Set[asyncio.Queue]" = set()
176
+ self._events: "typing.List[typing.Dict[str, object]]" = []
177
+ self._turns: "typing.List[typing.Dict[str, object]]" = []
178
+ self._turns_by_submission_id: "typing.Dict[str, typing.Dict[str, object]]" = {}
179
+ self._turns_by_turn_id: "typing.Dict[str, typing.Dict[str, object]]" = {}
180
+ self._title = ""
181
+ self._spinner_status = ""
182
+ self._stream_buffer = ""
183
+ self._closed = False
184
+ self._server_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
185
+ self._worker_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
186
+ self._lock = threading.RLock()
187
+
188
+ def attach_server_loop(self, loop: "asyncio.AbstractEventLoop") -> None:
189
+ self._server_loop = loop
190
+
191
+ def attach_worker_loop(self, loop: "asyncio.AbstractEventLoop") -> None:
192
+ self._worker_loop = loop
193
+
194
+ async def submit(self, prompt: str) -> "typing.Dict[str, object]":
195
+ prompt = str(prompt or "").strip()
196
+ if not prompt:
197
+ return {"ok": False, "error": "prompt is empty"}
198
+ await self._put_input(prompt)
199
+ await self._publish(
200
+ {
201
+ "type": "input",
202
+ "prompt": prompt,
203
+ "snapshot": self.snapshot(),
204
+ }
205
+ )
206
+ return {"ok": True, "type": "submitted", "snapshot": self.snapshot()}
207
+
208
+ async def _put_input(self, item: object) -> None:
209
+ worker_loop = self._worker_loop
210
+ try:
211
+ running_loop = asyncio.get_running_loop()
212
+ except RuntimeError:
213
+ running_loop = None
214
+ if worker_loop is None or worker_loop is running_loop:
215
+ await self._input_queue.put(item)
216
+ return
217
+ future = asyncio.run_coroutine_threadsafe(self._input_queue.put(item), worker_loop)
218
+ await asyncio.wrap_future(future)
219
+
220
+ async def poll_prompt(self, prompt: "typing.Union[str, None]" = None) -> "typing.Union[str, None]":
221
+ del prompt
222
+ if self._closed and self._input_queue.empty():
223
+ raise EOFError()
224
+ try:
225
+ item = self._input_queue.get_nowait()
226
+ except asyncio.QueueEmpty:
227
+ return None
228
+ if item is None:
229
+ raise EOFError()
230
+ return str(item)
231
+
232
+ async def get_prompt(self, prompt: "typing.Union[str, None]" = None) -> "str":
233
+ if prompt:
234
+ self.write_line(prompt)
235
+ item = await self._input_queue.get()
236
+ if item is None:
237
+ raise EOFError()
238
+ return str(item)
239
+
240
+ def handle_event(self, event: "AgentEvent") -> None:
241
+ with self._lock:
242
+ self._apply_runtime_event(event)
243
+ payload = {
244
+ "type": "event",
245
+ "kind": str(getattr(event, "kind", "")),
246
+ "turn_id": str(getattr(event, "turn_id", "")),
247
+ "payload": _json_safe(getattr(event, "payload", {})),
248
+ "snapshot": self.snapshot(),
249
+ }
250
+ if payload["kind"] == "tool_completed":
251
+ payload["summary"] = tool_summary(getattr(event, "payload", {}))
252
+ self._publish_nowait(payload)
253
+
254
+ def finish_stream(self) -> None:
255
+ with self._lock:
256
+ if not self._stream_buffer:
257
+ return
258
+ active_turn = self._last_active_turn()
259
+ if active_turn is not None and not active_turn.get("response"):
260
+ active_turn["response"] = self._stream_buffer
261
+ active_turn["thinking"] = ""
262
+ active_turn["_thinking_active"] = False
263
+ self._stream_buffer = ""
264
+ event = {"type": "snapshot", "snapshot": self.snapshot()}
265
+ self._publish_nowait(event)
266
+
267
+ def write_line(self, text: str) -> None:
268
+ with self._lock:
269
+ text = str(text or "")
270
+ turn = self._new_control_turn(text)
271
+ turn["response"] = text
272
+ turn["status"] = "completed"
273
+ event = {"type": "snapshot", "snapshot": self.snapshot()}
274
+ self._publish_nowait(event)
275
+
276
+ def show_error(self, text: str) -> None:
277
+ self.finish_stream()
278
+ with self._lock:
279
+ turn = self._new_control_turn("")
280
+ turn["error"] = str(text or "")
281
+ turn["status"] = "error"
282
+ event = {"type": "snapshot", "snapshot": self.snapshot()}
283
+ self._publish_nowait(event)
284
+
285
+ def show_history(self) -> None:
286
+ assistant_turns = [turn for turn in self._turns if turn.get("kind") != "control"]
287
+ if not assistant_turns:
288
+ self.write_line("No history yet.")
289
+ return
290
+ lines = ["Session: {0}".format(self._title or "untitled")]
291
+ for index, turn in enumerate(assistant_turns, start=1):
292
+ prompt = str(turn.get("prompt") or "")
293
+ response = str(turn.get("response") or turn.get("thinking") or "")
294
+ lines.append("[{0}]U> {1}".format(index, prompt))
295
+ if response:
296
+ lines.append("[{0}]A> {1}".format(index, response))
297
+ self.write_line("\n".join(lines))
298
+
299
+ def show_title(self) -> None:
300
+ self.write_line("Session: {0}".format(self._title or "untitled"))
301
+
302
+ def set_session_title(self, title: str) -> None:
303
+ with self._lock:
304
+ self._set_title(title)
305
+ event = {
306
+ "type": "title_changed",
307
+ "title": self._title,
308
+ "snapshot": self.snapshot(),
309
+ }
310
+ self._publish_nowait(event)
311
+
312
+ def show_resumed_session(self, title: str) -> None:
313
+ with self._lock:
314
+ self._set_title(title)
315
+ event = {"type": "snapshot", "snapshot": self.snapshot()}
316
+ self._publish_nowait(event)
317
+
318
+ def load_session_history(
319
+ self,
320
+ title: "typing.Union[str, None]",
321
+ history: "typing.Iterable[typing.Tuple[str, str]]",
322
+ ) -> None:
323
+ self.finish_stream()
324
+ with self._lock:
325
+ self._set_title(title)
326
+ self._turns = []
327
+ self._turns_by_submission_id = {}
328
+ self._turns_by_turn_id = {}
329
+ self._events = []
330
+ for prompt, response in history:
331
+ submission_id = uuid7_string()
332
+ turn = self._ensure_turn(submission_id, submission_id, str(prompt or ""))
333
+ turn["response"] = str(response or "")
334
+ turn["status"] = "completed"
335
+ turn["queue"] = "history"
336
+ turn["sender"] = "resume"
337
+ event = {"type": "snapshot", "snapshot": self.snapshot()}
338
+ self._publish_nowait(event)
339
+
340
+ def show_steer_queued(self, turn_id: str, prompt: str) -> None:
341
+ del turn_id, prompt
342
+
343
+ def schedule_steer_inserted(self, turn_id: str, prompt: str) -> None:
344
+ del turn_id, prompt
345
+
346
+ def set_context_window_tokens(
347
+ self,
348
+ context_window_tokens: "typing.Union[int, None]",
349
+ ) -> None:
350
+ del context_window_tokens
351
+
352
+ def subscribe(self) -> "asyncio.Queue":
353
+ queue: "asyncio.Queue" = asyncio.Queue()
354
+ with self._lock:
355
+ self._subscribers.add(queue)
356
+ event = {
357
+ "type": "hello",
358
+ "events": list(self._events[-200:]),
359
+ "snapshot": self.snapshot(),
360
+ }
361
+ queue.put_nowait(event)
362
+ return queue
363
+
364
+ def unsubscribe(self, queue: "asyncio.Queue") -> None:
365
+ with self._lock:
366
+ self._subscribers.discard(queue)
367
+
368
+ def close(self) -> None:
369
+ with self._lock:
370
+ self._closed = True
371
+ subscribers = tuple(self._subscribers)
372
+ self._subscribers.clear()
373
+ worker_loop = self._worker_loop
374
+ if worker_loop is None:
375
+ self._input_queue.put_nowait(None)
376
+ else:
377
+ asyncio.run_coroutine_threadsafe(self._input_queue.put(None), worker_loop)
378
+ self._publish_to_queues(subscribers, None)
379
+
380
+ def snapshot(self) -> "typing.Dict[str, object]":
381
+ with self._lock:
382
+ return {
383
+ "running": bool(self._spinner_status),
384
+ "status": self._spinner_status,
385
+ "status_kind": "spinner" if self._spinner_status else "idle",
386
+ "spinner": self._spinner_status,
387
+ "model": "pycodex",
388
+ "title": self._title,
389
+ "turns": [_public_turn(turn) for turn in self._turns[-80:]],
390
+ }
391
+
392
+ def summary(self) -> "typing.Dict[str, object]":
393
+ with self._lock:
394
+ return {
395
+ "running": bool(self._spinner_status),
396
+ "spinner": self._spinner_status,
397
+ "title": self._title,
398
+ "turn_count": len(self._turns),
399
+ }
400
+
401
+ def _apply_runtime_event(self, event: "AgentEvent") -> None:
402
+ kind = str(getattr(event, "kind", "") or "")
403
+ payload = getattr(event, "payload", {})
404
+ if not isinstance(payload, dict):
405
+ payload = {}
406
+ turn_id = str(payload.get("turn_id") or getattr(event, "turn_id", "") or "")
407
+ submission_id = str(payload.get("submission_id") or turn_id or "")
408
+ turn = self._turns_by_submission_id.get(submission_id)
409
+ if turn is None and turn_id and not submission_id:
410
+ turn = self._turns_by_turn_id.get(turn_id)
411
+
412
+ if kind == "turn_started":
413
+ self._set_spinner_status(kind)
414
+ prompt = payload.get("user_text") or "\n".join(
415
+ str(item) for item in payload.get("user_texts", []) or []
416
+ )
417
+ if not self._title and str(prompt or "").strip():
418
+ self._set_title(shorten_title(str(prompt or "")))
419
+ turn = self._ensure_turn(submission_id, turn_id, str(prompt or ""))
420
+ turn["status"] = "running"
421
+ turn["thinking"] = ""
422
+ turn["_thinking_active"] = False
423
+ turn["error"] = ""
424
+ return
425
+
426
+ self._apply_spinner_event(kind, payload)
427
+ if turn is None:
428
+ return
429
+
430
+ if kind == "assistant_delta":
431
+ turn["status"] = "responding"
432
+ delta = str(payload.get("delta") or "")
433
+ self._stream_buffer += delta
434
+ if turn.get("_thinking_active"):
435
+ turn["thinking"] = str(turn.get("thinking") or "") + delta
436
+ else:
437
+ turn["thinking"] = delta
438
+ turn["_thinking_active"] = True
439
+ return
440
+
441
+ if kind == "tool_started":
442
+ turn["status"] = "tool"
443
+ turn["tool_name"] = str(payload.get("tool_name") or "")
444
+ turn["_thinking_active"] = False
445
+ return
446
+
447
+ if kind == "tool_completed":
448
+ turn["_thinking_active"] = False
449
+ turn["status"] = "running"
450
+ return
451
+
452
+ if kind == "turn_completed":
453
+ response = str(payload.get("output_text") or "")
454
+ if response:
455
+ turn["response"] = response
456
+ elif turn.get("thinking"):
457
+ turn["response"] = str(turn.get("thinking") or "")
458
+ turn["thinking"] = ""
459
+ turn["_thinking_active"] = False
460
+ turn["status"] = "completed"
461
+ self._stream_buffer = ""
462
+ return
463
+
464
+ if kind in {"turn_failed", "submission_failed"}:
465
+ turn["status"] = "error"
466
+ turn["error"] = str(payload.get("error") or kind)
467
+ self._stream_buffer = ""
468
+ return
469
+
470
+ if kind in {"turn_interrupted", "submission_cancelled"}:
471
+ if turn.get("thinking") and not turn.get("response"):
472
+ turn["response"] = str(turn.get("thinking") or "")
473
+ turn["thinking"] = ""
474
+ turn["_thinking_active"] = False
475
+ turn["status"] = "interrupted"
476
+ self._stream_buffer = ""
477
+
478
+ def _apply_spinner_event(
479
+ self,
480
+ kind: str,
481
+ payload: "typing.Dict[str, object]",
482
+ ) -> None:
483
+ if kind == "assistant_delta":
484
+ self._set_spinner_status("talking")
485
+ return
486
+ if kind == "stream_error":
487
+ self._set_spinner_status("reconnecting")
488
+ return
489
+ if kind == "auto_compact_started":
490
+ self._set_spinner_status("compacting")
491
+ return
492
+ if kind == "auto_compact_completed":
493
+ self._set_spinner_status("compacted")
494
+ return
495
+ if kind == "tool_started":
496
+ tool_name = str(payload.get("tool_name") or "").strip()
497
+ call = payload.get("call")
498
+ if tool_name and isinstance(call, ToolCall):
499
+ self._set_spinner_status(
500
+ shorten_title(
501
+ "calling {0}({1})".format(tool_name, call.arguments),
502
+ limit=SPINNER_STATUS_PREVIEW_LIMIT,
503
+ )
504
+ )
505
+ elif tool_name:
506
+ self._set_spinner_status("calling {0}".format(tool_name))
507
+ else:
508
+ self._set_spinner_status("calling provider tools")
509
+ return
510
+ if kind == "tool_completed":
511
+ tool_name = str(payload.get("tool_name") or "").strip()
512
+ if tool_name:
513
+ self._set_spinner_status("called {0}".format(tool_name))
514
+ return
515
+ if kind in {"turn_completed", "turn_failed", "turn_interrupted"}:
516
+ self._set_idle_spinner_status(payload)
517
+ return
518
+ if kind == "submission_failed":
519
+ self._set_spinner_status("")
520
+
521
+ def _set_spinner_status(self, text: "typing.Union[str, None]") -> None:
522
+ self._spinner_status = str(text or "").strip()
523
+
524
+ def _set_idle_spinner_status(self, payload: "typing.Dict[str, object]") -> None:
525
+ try:
526
+ background_work_count = int(payload.get("background_exec_count", 0))
527
+ except (TypeError, ValueError):
528
+ background_work_count = 0
529
+ if background_work_count > 0:
530
+ self._set_spinner_status(IDLE_LISTENING_STATUS)
531
+ else:
532
+ self._set_spinner_status("")
533
+
534
+ def _ensure_turn(
535
+ self,
536
+ submission_id: str,
537
+ turn_id: str,
538
+ prompt: str,
539
+ ) -> "typing.Dict[str, object]":
540
+ submission_id = str(submission_id or "").strip()
541
+ turn_id = str(turn_id or submission_id).strip()
542
+ turn = self._turns_by_submission_id.get(submission_id)
543
+ if turn is None and turn_id and not submission_id:
544
+ turn = self._turns_by_turn_id.get(turn_id)
545
+ if turn is None:
546
+ turn = {
547
+ "submission_id": submission_id,
548
+ "turn_id": turn_id,
549
+ "prompt": prompt,
550
+ "response": "",
551
+ "thinking": "",
552
+ "_thinking_active": False,
553
+ "status": "queued",
554
+ "error": "",
555
+ "kind": "assistant",
556
+ "queue": "steer",
557
+ "sender": "web",
558
+ }
559
+ self._turns.append(turn)
560
+ if submission_id:
561
+ turn["submission_id"] = submission_id
562
+ self._turns_by_submission_id[submission_id] = turn
563
+ if turn_id:
564
+ turn["turn_id"] = turn_id
565
+ self._turns_by_turn_id[turn_id] = turn
566
+ if prompt:
567
+ turn["prompt"] = prompt
568
+ return turn
569
+
570
+ def _new_control_turn(self, text: str) -> "typing.Dict[str, object]":
571
+ submission_id = uuid7_string()
572
+ turn = self._ensure_turn(submission_id, submission_id, "")
573
+ turn["kind"] = "control"
574
+ turn["queue"] = "control"
575
+ turn["sender"] = "web"
576
+ turn["status"] = "running"
577
+ turn["error"] = ""
578
+ turn["response"] = ""
579
+ turn["thinking"] = ""
580
+ turn["prompt"] = ""
581
+ return turn
582
+
583
+ def _set_title(self, title: "typing.Union[str, None]") -> None:
584
+ self._title = str(title or "").strip()
585
+
586
+ def _last_active_turn(self) -> "typing.Union[typing.Dict[str, object], None]":
587
+ for turn in reversed(self._turns):
588
+ if turn.get("kind") != "control" and turn.get("status") not in {
589
+ "completed",
590
+ "error",
591
+ "interrupted",
592
+ }:
593
+ return turn
594
+ return None
595
+
596
+ def _publish_nowait(self, event: "typing.Dict[str, object]") -> None:
597
+ with self._lock:
598
+ self._events.append(event)
599
+ if len(self._events) > 500:
600
+ del self._events[:-500]
601
+ subscribers = tuple(self._subscribers)
602
+ self._publish_to_queues(subscribers, event)
603
+
604
+ def _publish_to_queues(
605
+ self,
606
+ queues: "typing.Iterable[asyncio.Queue]",
607
+ event: "typing.Union[typing.Dict[str, object], None]",
608
+ ) -> None:
609
+ loop = self._server_loop
610
+ if loop is None:
611
+ for queue in queues:
612
+ queue.put_nowait(event)
613
+ return
614
+
615
+ def publish() -> None:
616
+ for queue in queues:
617
+ queue.put_nowait(event)
618
+
619
+ loop.call_soon_threadsafe(publish)
620
+
621
+ async def _publish(self, event: "typing.Dict[str, object]") -> None:
622
+ self._publish_nowait(event)
623
+
624
+
625
+ class WorkspaceInteractiveSession:
626
+ def __init__(
627
+ self,
628
+ queue,
629
+ config_path: "typing.Union[str, None]" = None,
630
+ ) -> None:
631
+ self.queue = queue
632
+ self.config_path = config_path
633
+ self.view = WebSessionView()
634
+ self._task: "typing.Union[asyncio.Task[int], None]" = None
635
+
636
+ async def start(self) -> "WorkspaceInteractiveSession":
637
+ if self._task is None:
638
+ self._task = asyncio.create_task(
639
+ run_interactive_session(
640
+ self.queue,
641
+ False,
642
+ self.config_path,
643
+ view=self.view,
644
+ show_banner=False,
645
+ )
646
+ )
647
+ return self
648
+
649
+ async def close(self) -> None:
650
+ self.view.close()
651
+ task = self._task
652
+ if task is None:
653
+ return
654
+ try:
655
+ await asyncio.wait_for(
656
+ asyncio.shield(task),
657
+ timeout=SESSION_CLOSE_TIMEOUT_SECONDS,
658
+ )
659
+ except asyncio.TimeoutError:
660
+ cancel_current = getattr(self.queue, "cancel_current", None)
661
+ if callable(cancel_current):
662
+ cancel_current()
663
+ task.cancel()
664
+ await asyncio.gather(task, return_exceptions=True)
665
+ finally:
666
+ self._task = None
667
+
668
+ async def submit(self, prompt: str, sender: str = "web") -> "typing.Dict[str, object]":
669
+ del sender
670
+ result = await self.view.submit(prompt)
671
+ result["snapshot"] = self.snapshot()
672
+ return result
673
+
674
+ def subscribe(self) -> "asyncio.Queue":
675
+ return self.view.subscribe()
676
+
677
+ def unsubscribe(self, queue: "asyncio.Queue") -> None:
678
+ self.view.unsubscribe(queue)
679
+
680
+ def snapshot(self) -> "typing.Dict[str, object]":
681
+ snapshot = self.view.snapshot()
682
+ agent = getattr(self.queue, "_agent", None)
683
+ snapshot["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
684
+ return snapshot
685
+
686
+ def summary(self) -> "typing.Dict[str, object]":
687
+ summary = self.view.summary()
688
+ agent = getattr(self.queue, "_agent", None)
689
+ summary["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
690
+ return summary
691
+
692
+ def rollout_path(self) -> str:
693
+ recorder = getattr(getattr(self.queue, "_agent", None), "_rollout_recorder", None)
694
+ path = getattr(recorder, "rollout_path", None)
695
+ return "" if path is None else str(path)
696
+
697
+ async def restore_from_rollout(self, rollout_path: str, title: str = "") -> None:
698
+ resumed = load_resumed_session_path(rollout_path, thread_name=title or None)
699
+ agent = self.queue._agent
700
+ agent.replace_history(resumed["history"])
701
+ model_client = getattr(agent, "_model_client", None)
702
+ if hasattr(model_client, "_session_id"):
703
+ model_client._session_id = str(resumed["session_id"])
704
+ agent.set_rollout_recorder(SessionRolloutRecorder.resume(resumed["rollout_path"]))
705
+ self.view.load_session_history(
706
+ str(title or resumed["title"]),
707
+ tuple(resumed["turns"]),
708
+ )
709
+
710
+
711
+ class ThreadedWorkspaceInteractiveSession:
712
+ def __init__(
713
+ self,
714
+ session_factory: "ThreadedSessionFactory",
715
+ server_loop: "asyncio.AbstractEventLoop",
716
+ ) -> None:
717
+ self._session_factory = session_factory
718
+ self._server_loop = server_loop
719
+ self._view = WebSessionView()
720
+ self._view.attach_server_loop(server_loop)
721
+ self._thread: "typing.Union[threading.Thread, None]" = None
722
+ self._worker_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
723
+ self._ready = threading.Event()
724
+ self._closed = threading.Event()
725
+ self._startup_error: "typing.Union[BaseException, None]" = None
726
+ self._session: "typing.Union[WorkspaceInteractiveSession, None]" = None
727
+
728
+ async def start(self) -> "ThreadedWorkspaceInteractiveSession":
729
+ if self._thread is not None:
730
+ return self
731
+ self._thread = threading.Thread(
732
+ target=self._thread_main,
733
+ name="pycodex-workspace-session",
734
+ daemon=True,
735
+ )
736
+ self._thread.start()
737
+ await asyncio.to_thread(self._ready.wait)
738
+ if self._startup_error is not None:
739
+ raise RuntimeError("workspace session thread failed to start") from self._startup_error
740
+ return self
741
+
742
+ def _thread_main(self) -> None:
743
+ loop = asyncio.new_event_loop()
744
+ self._worker_loop = loop
745
+ self._view.attach_worker_loop(loop)
746
+ asyncio.set_event_loop(loop)
747
+ try:
748
+ session = self._session_factory()
749
+ session.view = self._view
750
+ self._session = session
751
+ loop.run_until_complete(session.start())
752
+ self._ready.set()
753
+ loop.run_forever()
754
+ except BaseException as exc:
755
+ self._startup_error = exc
756
+ self._ready.set()
757
+ finally:
758
+ session = self._session
759
+ if session is not None:
760
+ try:
761
+ loop.run_until_complete(session.close())
762
+ except BaseException:
763
+ pass
764
+ pending = asyncio.all_tasks(loop)
765
+ for task in pending:
766
+ task.cancel()
767
+ if pending:
768
+ loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
769
+ asyncio.set_event_loop(None)
770
+ loop.close()
771
+ self._closed.set()
772
+
773
+ async def close(self) -> None:
774
+ session = self._session
775
+ loop = self._worker_loop
776
+ if session is not None and loop is not None and loop.is_running():
777
+ future = asyncio.run_coroutine_threadsafe(session.close(), loop)
778
+ try:
779
+ await asyncio.wait_for(
780
+ asyncio.wrap_future(future),
781
+ timeout=SESSION_CLOSE_TIMEOUT_SECONDS + 1.0,
782
+ )
783
+ except (asyncio.TimeoutError, RuntimeError):
784
+ cancel_current = getattr(getattr(session, "queue", None), "cancel_current", None)
785
+ if callable(cancel_current):
786
+ cancel_current()
787
+ if loop is not None and loop.is_running():
788
+ loop.call_soon_threadsafe(loop.stop)
789
+ thread = self._thread
790
+ if thread is not None:
791
+ await asyncio.to_thread(thread.join, SESSION_CLOSE_TIMEOUT_SECONDS + 1.0)
792
+ self._thread = None
793
+
794
+ async def submit(self, prompt: str, sender: str = "web") -> "typing.Dict[str, object]":
795
+ del sender
796
+ result = await self._view.submit(prompt)
797
+ result["snapshot"] = self.snapshot()
798
+ return result
799
+
800
+ def subscribe(self) -> "asyncio.Queue":
801
+ return self._view.subscribe()
802
+
803
+ def unsubscribe(self, queue: "asyncio.Queue") -> None:
804
+ self._view.unsubscribe(queue)
805
+
806
+ def snapshot(self) -> "typing.Dict[str, object]":
807
+ snapshot = self._view.snapshot()
808
+ session = self._session
809
+ queue = getattr(session, "queue", None)
810
+ agent = getattr(queue, "_agent", None)
811
+ snapshot["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
812
+ return snapshot
813
+
814
+ def summary(self) -> "typing.Dict[str, object]":
815
+ summary = self._view.summary()
816
+ session = self._session
817
+ queue = getattr(session, "queue", None)
818
+ agent = getattr(queue, "_agent", None)
819
+ summary["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
820
+ return summary
821
+
822
+ def rollout_path(self) -> str:
823
+ if self._session is None:
824
+ return ""
825
+ return self._session.rollout_path()
826
+
827
+ async def restore_from_rollout(self, rollout_path: str, title: str = "") -> None:
828
+ session = self._session
829
+ loop = self._worker_loop
830
+ if session is None or loop is None:
831
+ return
832
+
833
+ future = asyncio.run_coroutine_threadsafe(
834
+ session.restore_from_rollout(rollout_path, title=title),
835
+ loop,
836
+ )
837
+ await asyncio.wrap_future(future)
838
+
839
+
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
+ )
864
+
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
+
889
+ if rollout_path:
890
+ await session.restore_from_rollout(rollout_path, title=title)
891
+
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)
899
+ )
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()
922
+ 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)
936
+
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)
950
+
951
+ def get(self, session_id: "typing.Union[str, None]" = None) -> "WorkspaceInteractiveSession":
952
+ resolved_id = self.resolve_session_id(session_id)
953
+ 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
+ }
978
+ )
979
+ return result
980
+
981
+
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
+
992
+ if asynccontextmanager is not None:
993
+ @asynccontextmanager
994
+ async def lifespan(_app):
995
+ await manager.start()
996
+ try:
997
+ yield
998
+ finally:
999
+ await manager.close()
1000
+
1001
+ app = FastAPI(lifespan=lifespan)
1002
+ else:
1003
+ app = FastAPI()
1004
+
1005
+ @app.on_event("startup")
1006
+ async def startup() -> None:
1007
+ await manager.start()
1008
+
1009
+ @app.on_event("shutdown")
1010
+ async def shutdown() -> None:
1011
+ await manager.close()
1012
+
1013
+ @app.get("/")
1014
+ async def index() -> HTMLResponse:
1015
+ return _html_response(_render_workspace_shell(board_path))
1016
+
1017
+ @app.get("/favicon.ico")
1018
+ async def favicon() -> Response:
1019
+ return Response(status_code=204)
1020
+
1021
+ @app.api_route("/board", methods=["GET", "HEAD"])
1022
+ async def board() -> Response:
1023
+ return _board_response(board_path)
1024
+
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
+ @app.get("/api/board")
1032
+ 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
+ )
1044
+
1045
+ @app.get("/ws/session")
1046
+ 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"))
1060
+
1061
+ @app.get("/api/sessions")
1062
+ async def sessions() -> JSONResponse:
1063
+ return JSONResponse({"sessions": manager.list_sessions()})
1064
+
1065
+ @app.post("/api/sessions")
1066
+ 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
+ )
1076
+
1077
+ @app.delete("/api/sessions/{session_id}")
1078
+ 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()})
1086
+
1087
+ @app.get("/api/session")
1088
+ async def session(
1089
+ session_id: "typing.Union[str, None]" = None,
1090
+ ) -> 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
+ )
1103
+
1104
+ @app.post("/api/session/message")
1105
+ async def message(
1106
+ payload: "typing.Dict[str, object]",
1107
+ ) -> 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)
1118
+
1119
+ @app.websocket("/ws/session")
1120
+ 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:
1126
+ await websocket.close(code=1008)
1127
+ 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)
1163
+
1164
+ return app
1165
+
1166
+
1167
+ def _session_snapshot(session) -> "typing.Dict[str, object]":
1168
+ return typing.cast("typing.Dict[str, object]", session.snapshot())
1169
+
1170
+
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
+ }
1182
+
1183
+
1184
+ def _public_turn(turn: "typing.Dict[str, object]") -> "typing.Dict[str, object]":
1185
+ return typing.cast(
1186
+ "typing.Dict[str, object]",
1187
+ _json_safe(
1188
+ {
1189
+ "submission_id": turn.get("submission_id", ""),
1190
+ "turn_id": turn.get("turn_id", ""),
1191
+ "prompt": turn.get("prompt", ""),
1192
+ "response": turn.get("response", ""),
1193
+ "thinking": turn.get("thinking", ""),
1194
+ "status": turn.get("status", ""),
1195
+ "error": turn.get("error", ""),
1196
+ "queue": turn.get("queue", ""),
1197
+ "sender": turn.get("sender", ""),
1198
+ "kind": turn.get("kind", "assistant"),
1199
+ }
1200
+ ),
1201
+ )
1202
+
1203
+
1204
+ def _json_safe(value: object) -> "JSONValue":
1205
+ if value is None or isinstance(value, (bool, int, float, str)):
1206
+ return value
1207
+ if isinstance(value, (list, tuple)):
1208
+ return [_json_safe(item) for item in value]
1209
+ if isinstance(value, dict):
1210
+ return {str(key): _json_safe(item) for key, item in value.items()}
1211
+ if is_dataclass(value):
1212
+ return _json_safe(asdict(value))
1213
+ try:
1214
+ json.dumps(value)
1215
+ except TypeError:
1216
+ return str(value)
1217
+ return typing.cast(JSONValue, value)
1218
+
1219
+
1220
+ def _html_response(content: str) -> HTMLResponse:
1221
+ return HTMLResponse(
1222
+ content,
1223
+ headers={
1224
+ "Cache-Control": "no-store",
1225
+ "Pragma": "no-cache",
1226
+ },
1227
+ )
1228
+
1229
+
1230
+ async def _send_ws_events(websocket: WebSocket, subscriber: "asyncio.Queue") -> None:
1231
+ while True:
1232
+ event = await subscriber.get()
1233
+ if event is None:
1234
+ return
1235
+ await websocket.send_json(event)
1236
+
1237
+
1238
+ def run_serve_cli(args: "argparse.Namespace") -> int:
1239
+ import uvicorn
1240
+
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
+
1246
+ configure_loguru()
1247
+ def build_session() -> "WorkspaceInteractiveSession":
1248
+ model = build_model(
1249
+ config_path=args.config,
1250
+ profile=args.profile,
1251
+ timeout_seconds=args.timeout_seconds,
1252
+ vllm_endpoint=args.vllm_endpoint,
1253
+ use_chat_completion=args.use_chat_completion or None,
1254
+ use_messages=args.use_messages,
1255
+ )
1256
+ agent = build_agent(
1257
+ model,
1258
+ config_path=args.config,
1259
+ profile=args.profile,
1260
+ system_prompt=args.system_prompt,
1261
+ session_mode="tui",
1262
+ extra_contextual_user_messages=(
1263
+ [_board_context_text(board_path)] if board_path is not None else []
1264
+ ),
1265
+ )
1266
+ return WorkspaceInteractiveSession(
1267
+ build_cli_queue(agent),
1268
+ config_path=args.config,
1269
+ )
1270
+
1271
+ def session_factory() -> "ThreadedWorkspaceInteractiveSession":
1272
+ return ThreadedWorkspaceInteractiveSession(build_session, asyncio.get_running_loop())
1273
+
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,
1278
+ )
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
+
1284
+
1285
+ def _board_context_text(board_path: Path) -> str:
1286
+ return (
1287
+ "Current workspace board file: {0}. "
1288
+ "Changes you make to this file are shown to the user in real time. "
1289
+ "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])
1295
+
1296
+
1297
+ def _format_board_path_for_prompt(board_path: Path) -> str:
1298
+ resolved = board_path.resolve()
1299
+ try:
1300
+ relative = os.path.relpath(str(resolved), str(Path.cwd().resolve()))
1301
+ except ValueError:
1302
+ return str(resolved)
1303
+ if relative == ".":
1304
+ return "."
1305
+ if relative.startswith(".."):
1306
+ return str(resolved)
1307
+ return "./{0}".format(relative)
1308
+
1309
+
1310
+ def _render_workspace_shell(board_path: "typing.Union[Path, None]") -> str:
1311
+ board_label = str(board_path) if board_path is not None else "No board"
1312
+ template = (Path(__file__).with_name("workspace.html")).read_text(
1313
+ encoding="utf-8"
1314
+ )
1315
+ return template.replace("__BOARD_LABEL__", html.escape(board_label))
1316
+
1317
+
1318
+ def _render_empty_board() -> str:
1319
+ return """<!doctype html>
1320
+ <html><head><meta charset="utf-8"><title>No board</title></head>
1321
+ <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>
1324
+ </body></html>"""
1325
+
1326
+
1327
+ def _render_missing_board(board_path: Path) -> str:
1328
+ escaped_path = html.escape(str(board_path))
1329
+ return """<!doctype html>
1330
+ <html><head><meta charset="utf-8"><title>Board pending</title></head>
1331
+ <body style="font:14px -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;padding:24px">
1332
+ <h1>Board pending</h1>
1333
+ <p>The board file does not exist yet.</p>
1334
+ <p><code>{0}</code></p>
1335
+ </body></html>""".format(escaped_path)
1336
+
1337
+
1338
+ def main(argv: "typing.Union[typing.Sequence[str], None]" = None) -> int:
1339
+ parser = build_parser()
1340
+ args = parser.parse_args(argv)
1341
+ try:
1342
+ return run_serve_cli(args)
1343
+ except ValueError as exc:
1344
+ parser.error(str(exc))
1345
+ except KeyboardInterrupt:
1346
+ return 130
1347
+ return 0