python-codex 0.1.14__py3-none-any.whl → 0.1.15__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,6 +3,7 @@ import asyncio
3
3
  import html
4
4
  import json
5
5
  import os
6
+ import threading
6
7
  import tempfile
7
8
  from uuid import uuid4
8
9
  from dataclasses import asdict, is_dataclass
@@ -19,6 +20,10 @@ from pycodex.cli import build_agent, build_cli_queue, build_model, configure_log
19
20
  from pycodex.interactive_session import run_interactive_session
20
21
  from pycodex.model import DEFAULT_CODEX_CONFIG_PATH
21
22
  from pycodex.protocol import AgentEvent, ToolCall
23
+ from pycodex.utils.session_persist import (
24
+ SessionRolloutRecorder,
25
+ load_resumed_session_path,
26
+ )
22
27
  from pycodex.utils import uuid7_string
23
28
  from pycodex.utils.visualize import IDLE_LISTENING_STATUS, shorten_title, tool_summary
24
29
  import typing
@@ -35,6 +40,55 @@ JSONValue = typing.Union[
35
40
  ]
36
41
 
37
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
+
38
92
  def build_parser() -> "argparse.ArgumentParser":
39
93
  parser = argparse.ArgumentParser(
40
94
  prog="pycodex-ws",
@@ -109,8 +163,10 @@ def parse_target(
109
163
  return host, port, board_path
110
164
 
111
165
 
112
- SessionFactory = typing.Callable[[], "WorkspaceInteractiveSession"]
166
+ SessionFactory = typing.Callable[[], object]
167
+ ThreadedSessionFactory = typing.Callable[[], "WorkspaceInteractiveSession"]
113
168
  SESSION_CLOSE_TIMEOUT_SECONDS = 2.0
169
+ SPINNER_STATUS_PREVIEW_LIMIT = 180
114
170
 
115
171
 
116
172
  class WebSessionView:
@@ -125,12 +181,21 @@ class WebSessionView:
125
181
  self._spinner_status = ""
126
182
  self._stream_buffer = ""
127
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
128
193
 
129
194
  async def submit(self, prompt: str) -> "typing.Dict[str, object]":
130
195
  prompt = str(prompt or "").strip()
131
196
  if not prompt:
132
197
  return {"ok": False, "error": "prompt is empty"}
133
- await self._input_queue.put(prompt)
198
+ await self._put_input(prompt)
134
199
  await self._publish(
135
200
  {
136
201
  "type": "input",
@@ -140,6 +205,18 @@ class WebSessionView:
140
205
  )
141
206
  return {"ok": True, "type": "submitted", "snapshot": self.snapshot()}
142
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
+
143
220
  async def poll_prompt(self, prompt: "typing.Union[str, None]" = None) -> "typing.Union[str, None]":
144
221
  del prompt
145
222
  if self._closed and self._input_queue.empty():
@@ -161,45 +238,51 @@ class WebSessionView:
161
238
  return str(item)
162
239
 
163
240
  def handle_event(self, event: "AgentEvent") -> None:
164
- self._apply_runtime_event(event)
165
- payload = {
166
- "type": "event",
167
- "kind": str(getattr(event, "kind", "")),
168
- "turn_id": str(getattr(event, "turn_id", "")),
169
- "payload": _json_safe(getattr(event, "payload", {})),
170
- "snapshot": self.snapshot(),
171
- }
172
- if payload["kind"] == "tool_completed":
173
- payload["summary"] = tool_summary(getattr(event, "payload", {}))
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", {}))
174
252
  self._publish_nowait(payload)
175
253
 
176
254
  def finish_stream(self) -> None:
177
- if not self._stream_buffer:
178
- return
179
- active_turn = self._last_active_turn()
180
- if active_turn is not None and not active_turn.get("response"):
181
- active_turn["response"] = self._stream_buffer
182
- active_turn["thinking"] = ""
183
- active_turn["_thinking_active"] = False
184
- self._stream_buffer = ""
185
- self._publish_nowait({"type": "snapshot", "snapshot": self.snapshot()})
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)
186
266
 
187
267
  def write_line(self, text: str) -> None:
188
- text = str(text or "")
189
- turn = self._new_control_turn(text)
190
- turn["response"] = text
191
- turn["status"] = "completed"
192
- self._publish_nowait({"type": "snapshot", "snapshot": self.snapshot()})
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)
193
275
 
194
276
  def show_error(self, text: str) -> None:
195
277
  self.finish_stream()
196
- turn = self._new_control_turn("")
197
- turn["error"] = str(text or "")
198
- turn["status"] = "error"
199
- self._publish_nowait({"type": "snapshot", "snapshot": self.snapshot()})
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)
200
284
 
201
285
  def show_history(self) -> None:
202
- self.finish_stream()
203
286
  assistant_turns = [turn for turn in self._turns if turn.get("kind") != "control"]
204
287
  if not assistant_turns:
205
288
  self.write_line("No history yet.")
@@ -214,17 +297,23 @@ class WebSessionView:
214
297
  self.write_line("\n".join(lines))
215
298
 
216
299
  def show_title(self) -> None:
217
- self.finish_stream()
218
300
  self.write_line("Session: {0}".format(self._title or "untitled"))
219
301
 
220
302
  def set_session_title(self, title: str) -> None:
221
- self.finish_stream()
222
- self._title = str(title or "").strip()
223
- self._publish_nowait({"type": "snapshot", "snapshot": self.snapshot()})
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)
224
311
 
225
312
  def show_resumed_session(self, title: str) -> None:
226
- self._title = str(title or "")
227
- self._publish_nowait({"type": "snapshot", "snapshot": self.snapshot()})
313
+ with self._lock:
314
+ self._set_title(title)
315
+ event = {"type": "snapshot", "snapshot": self.snapshot()}
316
+ self._publish_nowait(event)
228
317
 
229
318
  def load_session_history(
230
319
  self,
@@ -232,26 +321,27 @@ class WebSessionView:
232
321
  history: "typing.Iterable[typing.Tuple[str, str]]",
233
322
  ) -> None:
234
323
  self.finish_stream()
235
- self._title = title or ""
236
- self._turns = []
237
- self._turns_by_submission_id = {}
238
- self._turns_by_turn_id = {}
239
- self._events = []
240
- for prompt, response in history:
241
- submission_id = uuid7_string()
242
- turn = self._ensure_turn(submission_id, submission_id, str(prompt or ""))
243
- turn["response"] = str(response or "")
244
- turn["status"] = "completed"
245
- turn["queue"] = "history"
246
- turn["sender"] = "resume"
247
- self._publish_nowait({"type": "snapshot", "snapshot": self.snapshot()})
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)
248
339
 
249
340
  def show_steer_queued(self, turn_id: str, prompt: str) -> None:
250
- self.write_line("[steer] queued: {0}".format(shorten_title(prompt, limit=72)))
341
+ del turn_id, prompt
251
342
 
252
343
  def schedule_steer_inserted(self, turn_id: str, prompt: str) -> None:
253
- del turn_id
254
- self.write_line("[steer] inserted: {0}".format(shorten_title(prompt, limit=72)))
344
+ del turn_id, prompt
255
345
 
256
346
  def set_context_window_tokens(
257
347
  self,
@@ -261,36 +351,52 @@ class WebSessionView:
261
351
 
262
352
  def subscribe(self) -> "asyncio.Queue":
263
353
  queue: "asyncio.Queue" = asyncio.Queue()
264
- self._subscribers.add(queue)
265
- queue.put_nowait(
266
- {
354
+ with self._lock:
355
+ self._subscribers.add(queue)
356
+ event = {
267
357
  "type": "hello",
268
358
  "events": list(self._events[-200:]),
269
359
  "snapshot": self.snapshot(),
270
360
  }
271
- )
361
+ queue.put_nowait(event)
272
362
  return queue
273
363
 
274
364
  def unsubscribe(self, queue: "asyncio.Queue") -> None:
275
- self._subscribers.discard(queue)
365
+ with self._lock:
366
+ self._subscribers.discard(queue)
276
367
 
277
368
  def close(self) -> None:
278
- self._closed = True
279
- self._input_queue.put_nowait(None)
280
- for subscriber in tuple(self._subscribers):
281
- subscriber.put_nowait(None)
282
- self._subscribers.clear()
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)
283
379
 
284
380
  def snapshot(self) -> "typing.Dict[str, object]":
285
- return {
286
- "running": bool(self._spinner_status),
287
- "status": self._spinner_status,
288
- "status_kind": "spinner" if self._spinner_status else "idle",
289
- "spinner": self._spinner_status,
290
- "model": "pycodex",
291
- "title": self._title,
292
- "turns": [_public_turn(turn) for turn in self._turns[-80:]],
293
- }
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
+ }
294
400
 
295
401
  def _apply_runtime_event(self, event: "AgentEvent") -> None:
296
402
  kind = str(getattr(event, "kind", "") or "")
@@ -300,7 +406,7 @@ class WebSessionView:
300
406
  turn_id = str(payload.get("turn_id") or getattr(event, "turn_id", "") or "")
301
407
  submission_id = str(payload.get("submission_id") or turn_id or "")
302
408
  turn = self._turns_by_submission_id.get(submission_id)
303
- if turn is None and turn_id:
409
+ if turn is None and turn_id and not submission_id:
304
410
  turn = self._turns_by_turn_id.get(turn_id)
305
411
 
306
412
  if kind == "turn_started":
@@ -309,7 +415,7 @@ class WebSessionView:
309
415
  str(item) for item in payload.get("user_texts", []) or []
310
416
  )
311
417
  if not self._title and str(prompt or "").strip():
312
- self._title = shorten_title(str(prompt or ""))
418
+ self._set_title(shorten_title(str(prompt or "")))
313
419
  turn = self._ensure_turn(submission_id, turn_id, str(prompt or ""))
314
420
  turn["status"] = "running"
315
421
  turn["thinking"] = ""
@@ -340,11 +446,7 @@ class WebSessionView:
340
446
 
341
447
  if kind == "tool_completed":
342
448
  turn["_thinking_active"] = False
343
- if payload.get("is_error"):
344
- turn["status"] = "error"
345
- turn["error"] = str(payload.get("summary") or payload.get("tool_name") or "tool failed")
346
- else:
347
- turn["status"] = "running"
449
+ turn["status"] = "running"
348
450
  return
349
451
 
350
452
  if kind == "turn_completed":
@@ -397,7 +499,7 @@ class WebSessionView:
397
499
  self._set_spinner_status(
398
500
  shorten_title(
399
501
  "calling {0}({1})".format(tool_name, call.arguments),
400
- limit=72,
502
+ limit=SPINNER_STATUS_PREVIEW_LIMIT,
401
503
  )
402
504
  )
403
505
  elif tool_name:
@@ -438,7 +540,7 @@ class WebSessionView:
438
540
  submission_id = str(submission_id or "").strip()
439
541
  turn_id = str(turn_id or submission_id).strip()
440
542
  turn = self._turns_by_submission_id.get(submission_id)
441
- if turn is None and turn_id:
543
+ if turn is None and turn_id and not submission_id:
442
544
  turn = self._turns_by_turn_id.get(turn_id)
443
545
  if turn is None:
444
546
  turn = {
@@ -478,6 +580,9 @@ class WebSessionView:
478
580
  turn["prompt"] = ""
479
581
  return turn
480
582
 
583
+ def _set_title(self, title: "typing.Union[str, None]") -> None:
584
+ self._title = str(title or "").strip()
585
+
481
586
  def _last_active_turn(self) -> "typing.Union[typing.Dict[str, object], None]":
482
587
  for turn in reversed(self._turns):
483
588
  if turn.get("kind") != "control" and turn.get("status") not in {
@@ -489,11 +594,29 @@ class WebSessionView:
489
594
  return None
490
595
 
491
596
  def _publish_nowait(self, event: "typing.Dict[str, object]") -> None:
492
- self._events.append(event)
493
- if len(self._events) > 500:
494
- del self._events[:-500]
495
- for subscriber in tuple(self._subscribers):
496
- subscriber.put_nowait(event)
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)
497
620
 
498
621
  async def _publish(self, event: "typing.Dict[str, object]") -> None:
499
622
  self._publish_nowait(event)
@@ -513,7 +636,10 @@ class WorkspaceInteractiveSession:
513
636
  self._task: "typing.Union[asyncio.Task[int], None]" = None
514
637
  self._initial_prompt_submitted = False
515
638
 
516
- async def start(self) -> "WorkspaceInteractiveSession":
639
+ async def start(
640
+ self,
641
+ submit_initial_prompt: bool = True,
642
+ ) -> "WorkspaceInteractiveSession":
517
643
  if self._task is None:
518
644
  self._task = asyncio.create_task(
519
645
  run_interactive_session(
@@ -524,7 +650,11 @@ class WorkspaceInteractiveSession:
524
650
  show_banner=False,
525
651
  )
526
652
  )
527
- if self.initial_prompt and not self._initial_prompt_submitted:
653
+ if (
654
+ submit_initial_prompt
655
+ and self.initial_prompt
656
+ and not self._initial_prompt_submitted
657
+ ):
528
658
  self._initial_prompt_submitted = True
529
659
  await self.view.submit(self.initial_prompt)
530
660
  return self
@@ -566,31 +696,227 @@ class WorkspaceInteractiveSession:
566
696
  snapshot["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
567
697
  return snapshot
568
698
 
699
+ def summary(self) -> "typing.Dict[str, object]":
700
+ summary = self.view.summary()
701
+ agent = getattr(self.queue, "_agent", None)
702
+ summary["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
703
+ return summary
704
+
705
+ def rollout_path(self) -> str:
706
+ recorder = getattr(getattr(self.queue, "_agent", None), "_rollout_recorder", None)
707
+ path = getattr(recorder, "rollout_path", None)
708
+ return "" if path is None else str(path)
709
+
710
+ async def restore_from_rollout(self, rollout_path: str, title: str = "") -> None:
711
+ resumed = load_resumed_session_path(rollout_path, thread_name=title or None)
712
+ agent = self.queue._agent
713
+ agent.replace_history(resumed["history"])
714
+ model_client = getattr(agent, "_model_client", None)
715
+ if hasattr(model_client, "_session_id"):
716
+ model_client._session_id = str(resumed["session_id"])
717
+ agent.set_rollout_recorder(SessionRolloutRecorder.resume(resumed["rollout_path"]))
718
+ self.view.load_session_history(
719
+ str(title or resumed["title"]),
720
+ tuple(resumed["turns"]),
721
+ )
722
+
723
+
724
+ class ThreadedWorkspaceInteractiveSession:
725
+ def __init__(
726
+ self,
727
+ session_factory: "ThreadedSessionFactory",
728
+ server_loop: "asyncio.AbstractEventLoop",
729
+ ) -> None:
730
+ self._session_factory = session_factory
731
+ self._server_loop = server_loop
732
+ self._view = WebSessionView()
733
+ self._view.attach_server_loop(server_loop)
734
+ self._thread: "typing.Union[threading.Thread, None]" = None
735
+ self._worker_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
736
+ self._ready = threading.Event()
737
+ self._closed = threading.Event()
738
+ self._startup_error: "typing.Union[BaseException, None]" = None
739
+ self._session: "typing.Union[WorkspaceInteractiveSession, None]" = None
740
+ self._submit_initial_prompt = True
741
+
742
+ async def start(
743
+ self,
744
+ submit_initial_prompt: bool = True,
745
+ ) -> "ThreadedWorkspaceInteractiveSession":
746
+ if self._thread is not None:
747
+ return self
748
+ self._submit_initial_prompt = submit_initial_prompt
749
+ self._thread = threading.Thread(
750
+ target=self._thread_main,
751
+ name="pycodex-workspace-session",
752
+ daemon=True,
753
+ )
754
+ self._thread.start()
755
+ await asyncio.to_thread(self._ready.wait)
756
+ if self._startup_error is not None:
757
+ raise RuntimeError("workspace session thread failed to start") from self._startup_error
758
+ return self
759
+
760
+ def _thread_main(self) -> None:
761
+ loop = asyncio.new_event_loop()
762
+ self._worker_loop = loop
763
+ self._view.attach_worker_loop(loop)
764
+ asyncio.set_event_loop(loop)
765
+ try:
766
+ session = self._session_factory()
767
+ session.view = self._view
768
+ self._session = session
769
+ loop.run_until_complete(
770
+ session.start(submit_initial_prompt=self._submit_initial_prompt)
771
+ )
772
+ self._ready.set()
773
+ loop.run_forever()
774
+ except BaseException as exc:
775
+ self._startup_error = exc
776
+ self._ready.set()
777
+ finally:
778
+ session = self._session
779
+ if session is not None:
780
+ try:
781
+ loop.run_until_complete(session.close())
782
+ except BaseException:
783
+ pass
784
+ pending = asyncio.all_tasks(loop)
785
+ for task in pending:
786
+ task.cancel()
787
+ if pending:
788
+ loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
789
+ asyncio.set_event_loop(None)
790
+ loop.close()
791
+ self._closed.set()
792
+
793
+ async def close(self) -> None:
794
+ session = self._session
795
+ loop = self._worker_loop
796
+ if session is not None and loop is not None and loop.is_running():
797
+ future = asyncio.run_coroutine_threadsafe(session.close(), loop)
798
+ try:
799
+ await asyncio.wait_for(
800
+ asyncio.wrap_future(future),
801
+ timeout=SESSION_CLOSE_TIMEOUT_SECONDS + 1.0,
802
+ )
803
+ except (asyncio.TimeoutError, RuntimeError):
804
+ cancel_current = getattr(getattr(session, "queue", None), "cancel_current", None)
805
+ if callable(cancel_current):
806
+ cancel_current()
807
+ if loop is not None and loop.is_running():
808
+ loop.call_soon_threadsafe(loop.stop)
809
+ thread = self._thread
810
+ if thread is not None:
811
+ await asyncio.to_thread(thread.join, SESSION_CLOSE_TIMEOUT_SECONDS + 1.0)
812
+ self._thread = None
813
+
814
+ async def submit(self, prompt: str, sender: str = "web") -> "typing.Dict[str, object]":
815
+ del sender
816
+ result = await self._view.submit(prompt)
817
+ result["snapshot"] = self.snapshot()
818
+ return result
819
+
820
+ def subscribe(self) -> "asyncio.Queue":
821
+ return self._view.subscribe()
822
+
823
+ def unsubscribe(self, queue: "asyncio.Queue") -> None:
824
+ self._view.unsubscribe(queue)
825
+
826
+ def snapshot(self) -> "typing.Dict[str, object]":
827
+ snapshot = self._view.snapshot()
828
+ session = self._session
829
+ queue = getattr(session, "queue", None)
830
+ agent = getattr(queue, "_agent", None)
831
+ snapshot["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
832
+ return snapshot
833
+
834
+ def summary(self) -> "typing.Dict[str, object]":
835
+ summary = self._view.summary()
836
+ session = self._session
837
+ queue = getattr(session, "queue", None)
838
+ agent = getattr(queue, "_agent", None)
839
+ summary["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
840
+ return summary
841
+
842
+ def rollout_path(self) -> str:
843
+ if self._session is None:
844
+ return ""
845
+ return self._session.rollout_path()
846
+
847
+ async def restore_from_rollout(self, rollout_path: str, title: str = "") -> None:
848
+ session = self._session
849
+ loop = self._worker_loop
850
+ if session is None or loop is None:
851
+ return
852
+
853
+ future = asyncio.run_coroutine_threadsafe(
854
+ session.restore_from_rollout(rollout_path, title=title),
855
+ loop,
856
+ )
857
+ await asyncio.wrap_future(future)
858
+
569
859
 
570
860
  class WorkspaceSessionManager:
571
- def __init__(self, session_factory: "SessionFactory") -> None:
861
+ def __init__(
862
+ self,
863
+ session_factory: "SessionFactory",
864
+ board_path: "typing.Union[Path, None]" = None,
865
+ ) -> None:
572
866
  self._session_factory = session_factory
573
867
  self._sessions: "typing.Dict[str, WorkspaceInteractiveSession]" = {}
574
868
  self._session_order: "typing.List[str]" = []
869
+ self._state_watchers: "typing.Dict[str, asyncio.Task]" = {}
870
+ self._persisted_titles: "typing.Dict[str, str]" = {}
575
871
  self._lock = asyncio.Lock()
872
+ self._state_store = WorkspaceStateStore(board_path)
576
873
 
577
874
  async def start(self) -> None:
578
- await self.create_session()
875
+ state_tabs = self._state_store.load_tabs()
876
+ if not state_tabs:
877
+ await self.create_session()
878
+ return
879
+ for tab in state_tabs:
880
+ await self.create_session(
881
+ title=str(tab.get("title") or ""),
882
+ rollout_path=str(tab.get("rollout_path") or ""),
883
+ )
579
884
 
580
885
  async def close(self) -> None:
581
886
  sessions = list(self._sessions.values())
887
+ watchers = list(self._state_watchers.values())
582
888
  self._sessions.clear()
583
889
  self._session_order = []
890
+ self._state_watchers.clear()
891
+ self._persisted_titles.clear()
892
+ for watcher in watchers:
893
+ watcher.cancel()
894
+ if watchers:
895
+ await asyncio.gather(*watchers, return_exceptions=True)
584
896
  for session in sessions:
585
897
  await session.close()
586
898
 
587
- async def create_session(self) -> str:
899
+ async def create_session(
900
+ self,
901
+ title: str = "",
902
+ rollout_path: str = "",
903
+ ) -> str:
588
904
  async with self._lock:
589
905
  session_id = uuid7_string()
590
906
  session = self._session_factory()
591
- await session.start()
907
+ await session.start(submit_initial_prompt=not bool(rollout_path))
908
+
909
+ if rollout_path:
910
+ await session.restore_from_rollout(rollout_path, title=title)
911
+
592
912
  self._sessions[session_id] = session
593
913
  self._session_order.append(session_id)
914
+ self._persisted_titles[session_id] = str(
915
+ _session_summary(session).get("title") or ""
916
+ )
917
+ self._state_watchers[session_id] = asyncio.create_task(
918
+ self._watch_session_title(session_id, session)
919
+ )
594
920
  return session_id
595
921
 
596
922
  async def close_session(self, session_id: str) -> None:
@@ -600,10 +926,47 @@ class WorkspaceSessionManager:
600
926
  session = self._sessions.pop(session_id, None)
601
927
  if session is None:
602
928
  raise KeyError(session_id)
929
+ watcher = self._state_watchers.pop(session_id, None)
930
+ self._persisted_titles.pop(session_id, None)
603
931
  self._session_order = [
604
932
  item for item in self._session_order if item != session_id
605
933
  ]
934
+ if watcher is not None:
935
+ watcher.cancel()
936
+ await asyncio.gather(watcher, return_exceptions=True)
606
937
  await session.close()
938
+ self.persist_workspace_state()
939
+
940
+ async def _watch_session_title(self, session_id: str, session) -> None:
941
+ subscriber = session.subscribe()
942
+ try:
943
+ while True:
944
+ event = await subscriber.get()
945
+ if event is None:
946
+ return
947
+ if not isinstance(event, dict) or event.get("type") != "title_changed":
948
+ continue
949
+ title = str(event.get("title") or "")
950
+ if title == self._persisted_titles.get(session_id, ""):
951
+ continue
952
+ self._persisted_titles[session_id] = title
953
+ self.persist_workspace_state()
954
+ finally:
955
+ session.unsubscribe(subscriber)
956
+
957
+ def persist_workspace_state(self) -> None:
958
+ tabs = []
959
+ for session_id in self._session_order:
960
+ session = self._sessions.get(session_id)
961
+ if session is None:
962
+ continue
963
+ summary = _session_summary(session)
964
+ title = str(summary.get("title") or "").strip()
965
+ rollout_path = str(session.rollout_path() or "")
966
+ if not title and not rollout_path:
967
+ continue
968
+ tabs.append({"title": title, "rollout_path": rollout_path})
969
+ self._state_store.save_tabs(tabs)
607
970
 
608
971
  def get(self, session_id: "typing.Union[str, None]" = None) -> "WorkspaceInteractiveSession":
609
972
  resolved_id = self.resolve_session_id(session_id)
@@ -623,14 +986,14 @@ class WorkspaceSessionManager:
623
986
  result = []
624
987
  for session_id in self._session_order:
625
988
  session = self._sessions[session_id]
626
- snapshot = _session_snapshot(session)
989
+ summary = _session_summary(session)
627
990
  result.append(
628
991
  {
629
992
  "id": session_id,
630
- "title": snapshot.get("title") or "pycodex",
631
- "running": bool(snapshot.get("running")),
632
- "spinner": snapshot.get("spinner") or "",
633
- "turn_count": len(snapshot.get("turns") or []),
993
+ "title": summary.get("title") or "pycodex",
994
+ "running": bool(summary.get("running")),
995
+ "spinner": summary.get("spinner") or "",
996
+ "turn_count": summary.get("turn_count") or 0,
634
997
  }
635
998
  )
636
999
  return result
@@ -643,7 +1006,7 @@ def create_app(
643
1006
  manager = (
644
1007
  session_source
645
1008
  if isinstance(session_source, WorkspaceSessionManager)
646
- else WorkspaceSessionManager(session_source)
1009
+ else WorkspaceSessionManager(session_source, board_path)
647
1010
  )
648
1011
 
649
1012
  if asynccontextmanager is not None:
@@ -742,7 +1105,9 @@ def create_app(
742
1105
  return JSONResponse({"ok": True, "sessions": manager.list_sessions()})
743
1106
 
744
1107
  @app.get("/api/session")
745
- async def session(session_id: "typing.Union[str, None]" = None) -> JSONResponse:
1108
+ async def session(
1109
+ session_id: "typing.Union[str, None]" = None,
1110
+ ) -> JSONResponse:
746
1111
  try:
747
1112
  resolved_id = manager.resolve_session_id(session_id)
748
1113
  link = manager.get(resolved_id)
@@ -757,7 +1122,9 @@ def create_app(
757
1122
  )
758
1123
 
759
1124
  @app.post("/api/session/message")
760
- async def message(payload: "typing.Dict[str, object]") -> JSONResponse:
1125
+ async def message(
1126
+ payload: "typing.Dict[str, object]",
1127
+ ) -> JSONResponse:
761
1128
  session_id = str(payload.get("session_id") or "")
762
1129
  try:
763
1130
  link = manager.get(session_id or None)
@@ -821,6 +1188,19 @@ def _session_snapshot(session) -> "typing.Dict[str, object]":
821
1188
  return typing.cast("typing.Dict[str, object]", session.snapshot())
822
1189
 
823
1190
 
1191
+ def _session_summary(session) -> "typing.Dict[str, object]":
1192
+ summary = getattr(session, "summary", None)
1193
+ if callable(summary):
1194
+ return typing.cast("typing.Dict[str, object]", summary())
1195
+ snapshot = _session_snapshot(session)
1196
+ return {
1197
+ "title": snapshot.get("title") or "",
1198
+ "running": bool(snapshot.get("running")),
1199
+ "spinner": snapshot.get("spinner") or "",
1200
+ "turn_count": len(snapshot.get("turns") or []),
1201
+ }
1202
+
1203
+
824
1204
  def _public_turn(turn: "typing.Dict[str, object]") -> "typing.Dict[str, object]":
825
1205
  return typing.cast(
826
1206
  "typing.Dict[str, object]",
@@ -884,7 +1264,7 @@ def run_serve_cli(args: "argparse.Namespace") -> int:
884
1264
  raise ValueError("board parent directory does not exist: {0}".format(board_path.parent))
885
1265
 
886
1266
  configure_loguru()
887
- def session_factory() -> "WorkspaceInteractiveSession":
1267
+ def build_session() -> "WorkspaceInteractiveSession":
888
1268
  model = build_model(
889
1269
  config_path=args.config,
890
1270
  profile=args.profile,
@@ -907,7 +1287,10 @@ def run_serve_cli(args: "argparse.Namespace") -> int:
907
1287
  initial_prompt=initial_prompt,
908
1288
  )
909
1289
 
910
- app = create_app(WorkspaceSessionManager(session_factory), board_path)
1290
+ def session_factory() -> "ThreadedWorkspaceInteractiveSession":
1291
+ return ThreadedWorkspaceInteractiveSession(build_session, asyncio.get_running_loop())
1292
+
1293
+ app = create_app(WorkspaceSessionManager(session_factory, board_path), board_path)
911
1294
  print(
912
1295
  "pycodex workspace listening on http://{0}:{1}".format(host, port),
913
1296
  flush=True,