python-codex 0.1.14__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.
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)
@@ -504,14 +627,11 @@ class WorkspaceInteractiveSession:
504
627
  self,
505
628
  queue,
506
629
  config_path: "typing.Union[str, None]" = None,
507
- initial_prompt: "typing.Union[str, None]" = None,
508
630
  ) -> None:
509
631
  self.queue = queue
510
632
  self.config_path = config_path
511
- self.initial_prompt = str(initial_prompt or "").strip()
512
633
  self.view = WebSessionView()
513
634
  self._task: "typing.Union[asyncio.Task[int], None]" = None
514
- self._initial_prompt_submitted = False
515
635
 
516
636
  async def start(self) -> "WorkspaceInteractiveSession":
517
637
  if self._task is None:
@@ -524,9 +644,6 @@ class WorkspaceInteractiveSession:
524
644
  show_banner=False,
525
645
  )
526
646
  )
527
- if self.initial_prompt and not self._initial_prompt_submitted:
528
- self._initial_prompt_submitted = True
529
- await self.view.submit(self.initial_prompt)
530
647
  return self
531
648
 
532
649
  async def close(self) -> None:
@@ -566,31 +683,220 @@ class WorkspaceInteractiveSession:
566
683
  snapshot["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
567
684
  return snapshot
568
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
+
569
839
 
570
840
  class WorkspaceSessionManager:
571
- def __init__(self, session_factory: "SessionFactory") -> None:
841
+ def __init__(
842
+ self,
843
+ session_factory: "SessionFactory",
844
+ board_path: "typing.Union[Path, None]" = None,
845
+ ) -> None:
572
846
  self._session_factory = session_factory
573
847
  self._sessions: "typing.Dict[str, WorkspaceInteractiveSession]" = {}
574
848
  self._session_order: "typing.List[str]" = []
849
+ self._state_watchers: "typing.Dict[str, asyncio.Task]" = {}
850
+ self._persisted_titles: "typing.Dict[str, str]" = {}
575
851
  self._lock = asyncio.Lock()
852
+ self._state_store = WorkspaceStateStore(board_path)
576
853
 
577
854
  async def start(self) -> None:
578
- await self.create_session()
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
+ )
579
864
 
580
865
  async def close(self) -> None:
581
866
  sessions = list(self._sessions.values())
867
+ watchers = list(self._state_watchers.values())
582
868
  self._sessions.clear()
583
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)
584
876
  for session in sessions:
585
877
  await session.close()
586
878
 
587
- async def create_session(self) -> str:
879
+ async def create_session(
880
+ self,
881
+ title: str = "",
882
+ rollout_path: str = "",
883
+ ) -> str:
588
884
  async with self._lock:
589
885
  session_id = uuid7_string()
590
886
  session = self._session_factory()
591
887
  await session.start()
888
+
889
+ if rollout_path:
890
+ await session.restore_from_rollout(rollout_path, title=title)
891
+
592
892
  self._sessions[session_id] = session
593
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
+ )
594
900
  return session_id
595
901
 
596
902
  async def close_session(self, session_id: str) -> None:
@@ -600,10 +906,47 @@ class WorkspaceSessionManager:
600
906
  session = self._sessions.pop(session_id, None)
601
907
  if session is None:
602
908
  raise KeyError(session_id)
909
+ watcher = self._state_watchers.pop(session_id, None)
910
+ self._persisted_titles.pop(session_id, None)
603
911
  self._session_order = [
604
912
  item for item in self._session_order if item != session_id
605
913
  ]
914
+ if watcher is not None:
915
+ watcher.cancel()
916
+ await asyncio.gather(watcher, return_exceptions=True)
606
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)
607
950
 
608
951
  def get(self, session_id: "typing.Union[str, None]" = None) -> "WorkspaceInteractiveSession":
609
952
  resolved_id = self.resolve_session_id(session_id)
@@ -623,14 +966,14 @@ class WorkspaceSessionManager:
623
966
  result = []
624
967
  for session_id in self._session_order:
625
968
  session = self._sessions[session_id]
626
- snapshot = _session_snapshot(session)
969
+ summary = _session_summary(session)
627
970
  result.append(
628
971
  {
629
972
  "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 []),
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,
634
977
  }
635
978
  )
636
979
  return result
@@ -643,7 +986,7 @@ def create_app(
643
986
  manager = (
644
987
  session_source
645
988
  if isinstance(session_source, WorkspaceSessionManager)
646
- else WorkspaceSessionManager(session_source)
989
+ else WorkspaceSessionManager(session_source, board_path)
647
990
  )
648
991
 
649
992
  if asynccontextmanager is not None:
@@ -742,7 +1085,9 @@ def create_app(
742
1085
  return JSONResponse({"ok": True, "sessions": manager.list_sessions()})
743
1086
 
744
1087
  @app.get("/api/session")
745
- async def session(session_id: "typing.Union[str, None]" = None) -> JSONResponse:
1088
+ async def session(
1089
+ session_id: "typing.Union[str, None]" = None,
1090
+ ) -> JSONResponse:
746
1091
  try:
747
1092
  resolved_id = manager.resolve_session_id(session_id)
748
1093
  link = manager.get(resolved_id)
@@ -757,7 +1102,9 @@ def create_app(
757
1102
  )
758
1103
 
759
1104
  @app.post("/api/session/message")
760
- async def message(payload: "typing.Dict[str, object]") -> JSONResponse:
1105
+ async def message(
1106
+ payload: "typing.Dict[str, object]",
1107
+ ) -> JSONResponse:
761
1108
  session_id = str(payload.get("session_id") or "")
762
1109
  try:
763
1110
  link = manager.get(session_id or None)
@@ -821,6 +1168,19 @@ def _session_snapshot(session) -> "typing.Dict[str, object]":
821
1168
  return typing.cast("typing.Dict[str, object]", session.snapshot())
822
1169
 
823
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
+
824
1184
  def _public_turn(turn: "typing.Dict[str, object]") -> "typing.Dict[str, object]":
825
1185
  return typing.cast(
826
1186
  "typing.Dict[str, object]",
@@ -884,7 +1244,7 @@ def run_serve_cli(args: "argparse.Namespace") -> int:
884
1244
  raise ValueError("board parent directory does not exist: {0}".format(board_path.parent))
885
1245
 
886
1246
  configure_loguru()
887
- def session_factory() -> "WorkspaceInteractiveSession":
1247
+ def build_session() -> "WorkspaceInteractiveSession":
888
1248
  model = build_model(
889
1249
  config_path=args.config,
890
1250
  profile=args.profile,
@@ -899,15 +1259,19 @@ def run_serve_cli(args: "argparse.Namespace") -> int:
899
1259
  profile=args.profile,
900
1260
  system_prompt=args.system_prompt,
901
1261
  session_mode="tui",
1262
+ extra_contextual_user_messages=(
1263
+ [_board_context_text(board_path)] if board_path is not None else []
1264
+ ),
902
1265
  )
903
- initial_prompt = _board_prompt_text(board_path) if board_path is not None else None
904
1266
  return WorkspaceInteractiveSession(
905
1267
  build_cli_queue(agent),
906
1268
  config_path=args.config,
907
- initial_prompt=initial_prompt,
908
1269
  )
909
1270
 
910
- app = create_app(WorkspaceSessionManager(session_factory), board_path)
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)
911
1275
  print(
912
1276
  "pycodex workspace listening on http://{0}:{1}".format(host, port),
913
1277
  flush=True,
@@ -918,11 +1282,11 @@ def run_serve_cli(args: "argparse.Namespace") -> int:
918
1282
  return 0
919
1283
 
920
1284
 
921
- def _board_prompt_text(board_path: Path) -> str:
1285
+ def _board_context_text(board_path: Path) -> str:
922
1286
  return (
923
1287
  "Current workspace board file: {0}. "
924
1288
  "Changes you make to this file are shown to the user in real time. "
925
- "You can create or modify this file anytime. Reply OK now."
1289
+ "You can create or modify this file anytime."
926
1290
  ).format(_format_board_path_for_prompt(board_path))
927
1291
 
928
1292