python-codex 0.2.7__py3-none-any.whl → 0.3.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 (84) hide show
  1. pycodex/__init__.py +14 -14
  2. pycodex/agent.py +465 -499
  3. pycodex/bootstrap.py +417 -0
  4. pycodex/cli.py +236 -510
  5. pycodex/compat.py +19 -5
  6. pycodex/context.py +222 -212
  7. pycodex/doctor.py +52 -48
  8. pycodex/events.py +857 -0
  9. pycodex/feishu_card.py +217 -163
  10. pycodex/feishu_link.py +43 -83
  11. pycodex/model.py +324 -253
  12. pycodex/model_metadata.py +19 -7
  13. pycodex/portable.py +76 -45
  14. pycodex/portable_server.py +32 -24
  15. pycodex/prompts/models.json +245 -983
  16. pycodex/protocol.py +177 -137
  17. pycodex/runtime.py +579 -176
  18. pycodex/runtime_services.py +204 -157
  19. pycodex/tools/__init__.py +1 -1
  20. pycodex/tools/apply_patch_tool.py +69 -48
  21. pycodex/tools/base_tool.py +89 -42
  22. pycodex/tools/clock_tool.py +58 -25
  23. pycodex/tools/close_agent_tool.py +2 -2
  24. pycodex/tools/code_mode_manager.py +77 -64
  25. pycodex/tools/exec_command_tool.py +26 -11
  26. pycodex/tools/exec_tool.py +4 -4
  27. pycodex/tools/grep_files_tool.py +12 -10
  28. pycodex/tools/ipython_tool.py +10 -13
  29. pycodex/tools/list_dir_tool.py +13 -9
  30. pycodex/tools/read_file_tool.py +29 -17
  31. pycodex/tools/request_permissions_tool.py +15 -5
  32. pycodex/tools/request_user_input_tool.py +13 -104
  33. pycodex/tools/resume_agent_tool.py +2 -2
  34. pycodex/tools/send_input_tool.py +11 -8
  35. pycodex/tools/shell_command_tool.py +7 -5
  36. pycodex/tools/shell_tool.py +7 -5
  37. pycodex/tools/spawn_agent_tool.py +7 -4
  38. pycodex/tools/unified_exec_manager.py +102 -69
  39. pycodex/tools/update_plan_tool.py +8 -5
  40. pycodex/tools/view_image_tool.py +7 -5
  41. pycodex/tools/wait_agent_tool.py +27 -4
  42. pycodex/tools/wait_tool.py +5 -4
  43. pycodex/tools/web_search_tool.py +4 -2
  44. pycodex/tools/write_stdin_tool.py +12 -11
  45. pycodex/utils/__init__.py +2 -17
  46. pycodex/utils/compactor.py +41 -72
  47. pycodex/utils/debug.py +2 -2
  48. pycodex/utils/dotenv.py +6 -7
  49. pycodex/utils/event_helpers.py +190 -0
  50. pycodex/utils/get_env.py +27 -70
  51. pycodex/{image_utils.py → utils/image_utils.py} +8 -11
  52. pycodex/utils/random_ids.py +1 -2
  53. pycodex/utils/session_persist.py +217 -163
  54. pycodex/utils/truncation.py +21 -45
  55. python_codex-0.3.0.dist-info/METADATA +704 -0
  56. python_codex-0.3.0.dist-info/RECORD +90 -0
  57. responses_server/__init__.py +1 -5
  58. responses_server/__main__.py +0 -1
  59. responses_server/app.py +36 -31
  60. responses_server/config.py +23 -23
  61. responses_server/messages_api.py +51 -53
  62. responses_server/payload_processors.py +25 -20
  63. responses_server/server.py +11 -11
  64. responses_server/session_store.py +14 -11
  65. responses_server/stream_router.py +101 -98
  66. responses_server/tools/custom_adapter.py +17 -16
  67. responses_server/tools/web_search.py +39 -36
  68. responses_server/trajectory_dump.py +36 -14
  69. workspace_server/__main__.py +0 -1
  70. workspace_server/app.py +461 -375
  71. workspace_server/workspace.html +852 -228
  72. workspace_server/workspaces.html +94 -95
  73. workspace_server/workspaces.py +137 -79
  74. pycodex/collaboration.py +0 -20
  75. pycodex/interactive_session.py +0 -415
  76. pycodex/prompts/collaboration_default.md +0 -11
  77. pycodex/prompts/collaboration_plan.md +0 -128
  78. pycodex/utils/toolcall_visualize.py +0 -713
  79. pycodex/utils/visualize.py +0 -560
  80. python_codex-0.2.7.dist-info/METADATA +0 -455
  81. python_codex-0.2.7.dist-info/RECORD +0 -93
  82. {python_codex-0.2.7.dist-info → python_codex-0.3.0.dist-info}/WHEEL +0 -0
  83. {python_codex-0.2.7.dist-info → python_codex-0.3.0.dist-info}/entry_points.txt +0 -0
  84. {python_codex-0.2.7.dist-info → python_codex-0.3.0.dist-info}/licenses/LICENSE +0 -0
workspace_server/app.py CHANGED
@@ -6,12 +6,15 @@ import mimetypes
6
6
  import os
7
7
  import secrets
8
8
  import threading
9
- from dataclasses import asdict, is_dataclass
9
+ from dataclasses import asdict, fields, is_dataclass
10
+
10
11
  try:
11
12
  from contextlib import asynccontextmanager
12
13
  except ImportError: # pragma: no cover - Python 3.6 compatibility
13
14
  asynccontextmanager = None
15
+ import typing
14
16
  from pathlib import Path
17
+ from urllib.parse import urlencode
15
18
 
16
19
  from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
17
20
  from fastapi.responses import (
@@ -22,22 +25,43 @@ from fastapi.responses import (
22
25
  Response,
23
26
  )
24
27
 
25
- from pycodex.cli import build_agent, build_cli_queue, build_model, configure_loguru
26
- from pycodex.interactive_session import run_interactive_session
27
- from pycodex.model import DEFAULT_CODEX_CONFIG_PATH
28
- from pycodex.protocol import AgentEvent, ToolCall
29
- from pycodex.utils.session_persist import (
30
- SessionRolloutRecorder,
31
- load_resumed_session_path,
28
+ from pycodex.bootstrap import build_agent, build_model, build_runtime, configure_loguru
29
+ from pycodex.events import (
30
+ IDLE_SLEEPING_STATUS,
31
+ AssistantDeltaEvent,
32
+ AutoCompactCompletedEvent,
33
+ AutoCompactFailedEvent,
34
+ AutoCompactStartedEvent,
35
+ CommandCompletedEvent,
36
+ CommandFailedEvent,
37
+ CompactCompletedEvent,
38
+ CompactFailedEvent,
39
+ CompactStartedEvent,
40
+ Event,
41
+ InputQueuedEvent,
42
+ InputRequestedEvent,
43
+ InputResolvedEvent,
44
+ SessionClosedEvent,
45
+ SessionStateEvent,
46
+ StreamErrorEvent,
47
+ TerminalEvent,
48
+ TokenCountEvent,
49
+ ToolCalledEvent,
50
+ ToolCompletedEvent,
51
+ ToolStartedEvent,
52
+ TurnCompletedEvent,
53
+ TurnEvent,
54
+ TurnFailedEvent,
55
+ TurnInterruptedEvent,
56
+ TurnStartedEvent,
32
57
  )
58
+ from pycodex.model import DEFAULT_CODEX_CONFIG_PATH
33
59
  from pycodex.utils import uuid7_string
34
- from pycodex.utils.visualize import (
35
- IDLE_SLEEPING_STATUS,
36
- background_work_count,
37
- percent_of_context_window_remaining,
60
+ from pycodex.utils.event_helpers import (
61
+ completed_history,
38
62
  shorten_title,
39
- tool_summary,
40
63
  )
64
+
41
65
  from .workspaces import (
42
66
  WorkspaceDefinition,
43
67
  WorkspaceEntry,
@@ -46,8 +70,6 @@ from .workspaces import (
46
70
  load_workspace_definitions,
47
71
  session_snapshot,
48
72
  )
49
- import typing
50
-
51
73
 
52
74
  JSONValue = typing.Union[
53
75
  None,
@@ -141,93 +163,41 @@ def parse_listen(target: str) -> "typing.Tuple[str, int]":
141
163
 
142
164
  SessionFactory = typing.Callable[[], object]
143
165
  ThreadedSessionFactory = typing.Callable[[], "WorkspaceInteractiveSession"]
144
- SESSION_CLOSE_TIMEOUT_SECONDS = 2.0
145
166
  SPINNER_STATUS_PREVIEW_LIMIT = 180
146
167
  AUTH_COOKIE_NAME = "pycodex_ws_auth"
147
168
 
148
169
 
149
170
  class WebSessionView:
150
171
  def __init__(self) -> None:
151
- self._input_queue: "asyncio.Queue" = asyncio.Queue()
152
172
  self._subscribers: "typing.Set[asyncio.Queue]" = set()
153
173
  self._events: "typing.List[typing.Dict[str, object]]" = []
154
174
  self._turns: "typing.List[typing.Dict[str, object]]" = []
155
175
  self._turns_by_submission_id: "typing.Dict[str, typing.Dict[str, object]]" = {}
156
176
  self._turns_by_turn_id: "typing.Dict[str, typing.Dict[str, object]]" = {}
157
177
  self._title = ""
178
+ self._model = "pycodex"
179
+ self._rollout_path = ""
180
+ self._recorded_rollout_path = ""
181
+ self._input_request = None
182
+ self._accepts_input = True
158
183
  self._spinner_status = ""
159
184
  self._stream_buffer = ""
160
- self._context_window_tokens: "typing.Union[int, None]" = None
161
- self._context_remaining_percent: "typing.Union[int, None]" = None
162
- self._closed = False
185
+ self._max_context_window: "typing.Union[int, None]" = None
186
+ self._auto_compact_token_limit: "typing.Union[int, None]" = None
187
+ self._usage_tokens: "typing.Union[int, None]" = None
163
188
  self._server_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
164
- self._worker_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
165
189
  self._lock = threading.RLock()
166
190
 
167
191
  def attach_server_loop(self, loop: "asyncio.AbstractEventLoop") -> None:
168
192
  self._server_loop = loop
169
193
 
170
- def attach_worker_loop(self, loop: "asyncio.AbstractEventLoop") -> None:
171
- self._worker_loop = loop
172
-
173
- async def submit(self, prompt: str) -> "typing.Dict[str, object]":
174
- prompt = str(prompt or "").strip()
175
- if not prompt:
176
- return {"ok": False, "error": "prompt is empty"}
177
- await self._put_input(prompt)
178
- await self._publish(
179
- {
180
- "type": "input",
181
- "prompt": prompt,
182
- "snapshot": self.snapshot(),
183
- }
184
- )
185
- return {"ok": True, "type": "submitted", "snapshot": self.snapshot()}
186
-
187
- async def _put_input(self, item: object) -> None:
188
- worker_loop = self._worker_loop
189
- try:
190
- running_loop = asyncio.get_running_loop()
191
- except RuntimeError:
192
- running_loop = None
193
- if worker_loop is None or worker_loop is running_loop:
194
- await self._input_queue.put(item)
195
- return
196
- future = asyncio.run_coroutine_threadsafe(self._input_queue.put(item), worker_loop)
197
- await asyncio.wrap_future(future)
198
-
199
- async def poll_prompt(self, prompt: "typing.Union[str, None]" = None) -> "typing.Union[str, None]":
200
- del prompt
201
- if self._closed and self._input_queue.empty():
202
- raise EOFError()
203
- try:
204
- item = self._input_queue.get_nowait()
205
- except asyncio.QueueEmpty:
206
- return None
207
- if item is None:
208
- raise EOFError()
209
- return str(item)
210
-
211
- async def get_prompt(self, prompt: "typing.Union[str, None]" = None) -> "str":
212
- if prompt:
213
- self.write_line(prompt)
214
- item = await self._input_queue.get()
215
- if item is None:
216
- raise EOFError()
217
- return str(item)
218
-
219
- def handle_event(self, event: "AgentEvent") -> None:
194
+ def handle_event(self, event: "Event") -> None:
220
195
  with self._lock:
221
196
  self._apply_runtime_event(event)
222
- payload = {
223
- "type": "event",
224
- "kind": str(getattr(event, "kind", "")),
225
- "turn_id": str(getattr(event, "turn_id", "")),
226
- "payload": _json_safe(getattr(event, "payload", {})),
227
- "snapshot": self.snapshot(),
228
- }
229
- if payload["kind"] == "tool_completed":
230
- payload["summary"] = tool_summary(getattr(event, "payload", {}))
197
+ payload = _event_data(event)
198
+ payload.update({"type": "event", "snapshot": self.snapshot()})
199
+ if isinstance(event, ToolCompletedEvent):
200
+ payload["summary"] = event.visualize()
231
201
  self._publish_nowait(payload)
232
202
 
233
203
  def finish_stream(self) -> None:
@@ -261,23 +231,6 @@ class WebSessionView:
261
231
  event = {"type": "snapshot", "snapshot": self.snapshot()}
262
232
  self._publish_nowait(event)
263
233
 
264
- def show_history(self) -> None:
265
- assistant_turns = [turn for turn in self._turns if turn.get("kind") != "control"]
266
- if not assistant_turns:
267
- self.write_line("No history yet.")
268
- return
269
- lines = ["Session: {0}".format(self._title or "untitled")]
270
- for index, turn in enumerate(assistant_turns, start=1):
271
- prompt = str(turn.get("prompt") or "")
272
- response = str(turn.get("response") or turn.get("thinking") or "")
273
- lines.append("[{0}]U> {1}".format(index, prompt))
274
- if response:
275
- lines.append("[{0}]A> {1}".format(index, response))
276
- self.write_line("\n".join(lines))
277
-
278
- def show_title(self) -> None:
279
- self.write_line("Session: {0}".format(self._title or "untitled"))
280
-
281
234
  def set_session_title(self, title: str) -> None:
282
235
  with self._lock:
283
236
  self._set_title(title)
@@ -288,12 +241,6 @@ class WebSessionView:
288
241
  }
289
242
  self._publish_nowait(event)
290
243
 
291
- def show_resumed_session(self, title: str) -> None:
292
- with self._lock:
293
- self._set_title(title)
294
- event = {"type": "snapshot", "snapshot": self.snapshot()}
295
- self._publish_nowait(event)
296
-
297
244
  def load_session_history(
298
245
  self,
299
246
  title: "typing.Union[str, None]",
@@ -308,7 +255,9 @@ class WebSessionView:
308
255
  self._events = []
309
256
  for prompt, response in history:
310
257
  submission_id = uuid7_string()
311
- turn = self._ensure_turn(submission_id, submission_id, str(prompt or ""))
258
+ turn = self._ensure_turn(
259
+ submission_id, submission_id, str(prompt or "")
260
+ )
312
261
  turn["response"] = str(response or "")
313
262
  turn["status"] = "completed"
314
263
  turn["queue"] = "history"
@@ -316,22 +265,6 @@ class WebSessionView:
316
265
  event = {"type": "snapshot", "snapshot": self.snapshot()}
317
266
  self._publish_nowait(event)
318
267
 
319
- def show_steer_queued(self, turn_id: str, prompt: str) -> None:
320
- del turn_id, prompt
321
-
322
- def schedule_steer_inserted(self, turn_id: str, prompt: str) -> None:
323
- del turn_id, prompt
324
-
325
- def set_context_window_tokens(
326
- self,
327
- context_window_tokens: "typing.Union[int, None]",
328
- ) -> None:
329
- with self._lock:
330
- self._context_window_tokens = context_window_tokens
331
- self._context_remaining_percent = (
332
- 100 if context_window_tokens is not None else None
333
- )
334
-
335
268
  def subscribe(self) -> "asyncio.Queue":
336
269
  queue: "asyncio.Queue" = asyncio.Queue()
337
270
  with self._lock:
@@ -350,14 +283,8 @@ class WebSessionView:
350
283
 
351
284
  def close(self) -> None:
352
285
  with self._lock:
353
- self._closed = True
354
286
  subscribers = tuple(self._subscribers)
355
287
  self._subscribers.clear()
356
- worker_loop = self._worker_loop
357
- if worker_loop is None:
358
- self._input_queue.put_nowait(None)
359
- else:
360
- asyncio.run_coroutine_threadsafe(self._input_queue.put(None), worker_loop)
361
288
  self._publish_to_queues(subscribers, None)
362
289
 
363
290
  def snapshot(self) -> "typing.Dict[str, object]":
@@ -367,58 +294,120 @@ class WebSessionView:
367
294
  "status": self._spinner_status,
368
295
  "status_kind": "spinner" if self._spinner_status else "idle",
369
296
  "spinner": self._spinner_status,
370
- "model": "pycodex",
297
+ "model": self._model,
298
+ "rollout_path": self._rollout_path,
299
+ "recorded_rollout_path": self._recorded_rollout_path,
300
+ "input_request": _json_safe(self._input_request),
301
+ "queued_inputs": [
302
+ {"queue": turn["queue"], "prompt": turn["prompt"]}
303
+ for turn in self._turns_by_submission_id.values()
304
+ if not turn["turn_id"]
305
+ ],
306
+ "accepts_input": self._accepts_input,
371
307
  "title": self._title,
372
- "context_remaining_percent": self._context_remaining_percent,
308
+ **self._context_usage(),
373
309
  "turns": [_public_turn(turn) for turn in self._turns[-80:]],
374
310
  }
375
311
 
376
312
  def summary(self) -> "typing.Dict[str, object]":
377
313
  with self._lock:
378
314
  return {
315
+ "model": self._model,
379
316
  "running": bool(self._spinner_status),
380
317
  "spinner": self._spinner_status,
381
318
  "title": self._title,
382
319
  "turn_count": len(self._turns),
383
320
  "last_assistant": _last_assistant_text(self._turns),
384
- "context_remaining_percent": self._context_remaining_percent,
321
+ **self._context_usage(),
385
322
  }
386
323
 
387
- def _apply_runtime_event(self, event: "AgentEvent") -> None:
388
- kind = str(getattr(event, "kind", "") or "")
389
- payload = getattr(event, "payload", {})
390
- if not isinstance(payload, dict):
391
- payload = {}
392
- if kind == "token_count":
393
- self._update_context_window(payload.get("usage"))
324
+ def _apply_runtime_event(self, event: "Event") -> None:
325
+ if isinstance(event, SessionStateEvent):
326
+ state = event.state
327
+ self._model = state["model"]
328
+ self._rollout_path = state["rollout_path"]
329
+ self._recorded_rollout_path = state["recorded_rollout_path"]
330
+ self._input_request = state["input_request"]
331
+ self._accepts_input = state["accepts_input"]
332
+ self._max_context_window = state["max_context_window"]
333
+ self._auto_compact_token_limit = state["auto_compact_token_limit"]
334
+ self._usage_tokens = state["usage_tokens"]
335
+ if event.reason in {"attach", "history"}:
336
+ self.load_session_history(state["title"], completed_history(state))
337
+ active = state["active_turn"]
338
+ if active is not None:
339
+ self._apply_runtime_event(
340
+ TurnStartedEvent(
341
+ active["turn_id"],
342
+ tuple(active["user_texts"]),
343
+ active["submission_id"],
344
+ )
345
+ )
346
+ self._apply_runtime_event(
347
+ AssistantDeltaEvent(
348
+ active["assistant_text"],
349
+ active["turn_id"],
350
+ active["submission_id"],
351
+ )
352
+ )
353
+ elif event.reason == "title":
354
+ self.set_session_title(state["title"])
355
+ else:
356
+ self._set_title(state["title"])
357
+ return
358
+ if isinstance(event, CommandCompletedEvent):
359
+ if event.result["kind"] in {"title_changed", "resumed"}:
360
+ return
361
+ message = event.visualize()
362
+ if message:
363
+ self.write_line(message)
364
+ return
365
+ if isinstance(event, CommandFailedEvent):
366
+ self.show_error(event.visualize())
367
+ return
368
+ if isinstance(event, InputRequestedEvent):
369
+ self._input_request = event
370
+ return
371
+ if isinstance(event, InputResolvedEvent):
372
+ self._input_request = None
373
+ return
374
+ if isinstance(event, SessionClosedEvent):
375
+ self._accepts_input = False
376
+ self._spinner_status = ""
394
377
  return
395
- turn_id = str(payload.get("turn_id") or getattr(event, "turn_id", "") or "")
396
- submission_id = str(payload.get("submission_id") or turn_id or "")
378
+ if isinstance(event, InputQueuedEvent):
379
+ turn = self._ensure_turn(event.submission_id, "", "")
380
+ turn["prompt"] += ("\n" if turn["prompt"] else "") + event.prompt
381
+ turn["queue"] = event.queue
382
+ turn["sender"] = event.sender
383
+ return
384
+ if isinstance(event, TokenCountEvent):
385
+ self._usage_tokens = int(event.usage["total_tokens"])
386
+ return
387
+ if isinstance(event, (AutoCompactCompletedEvent, CompactCompletedEvent)):
388
+ self._usage_tokens = None
389
+ if not isinstance(event, TurnEvent):
390
+ return
391
+ turn_id = event.turn_id
392
+ submission_id = event.submission_id or turn_id
397
393
  turn = self._turns_by_submission_id.get(submission_id)
398
- if turn is None and turn_id and not submission_id:
399
- turn = self._turns_by_turn_id.get(turn_id)
400
394
 
401
- if kind == "turn_started":
402
- self._set_spinner_status(kind)
403
- prompt = payload.get("user_text") or "\n".join(
404
- str(item) for item in payload.get("user_texts", []) or []
405
- )
406
- if not self._title and str(prompt or "").strip():
407
- self._set_title(shorten_title(str(prompt or "")))
408
- turn = self._ensure_turn(submission_id, turn_id, str(prompt or ""))
395
+ if isinstance(event, TurnStartedEvent):
396
+ self._set_spinner_status(event.kind)
397
+ turn = self._ensure_turn(submission_id, turn_id, event.visualize())
409
398
  turn["status"] = "running"
410
399
  turn["thinking"] = ""
411
400
  turn["_thinking_active"] = False
412
401
  turn["error"] = ""
413
402
  return
414
403
 
415
- self._apply_spinner_event(kind, payload)
404
+ self._apply_spinner_event(event)
416
405
  if turn is None:
417
406
  return
418
407
 
419
- if kind == "assistant_delta":
408
+ if isinstance(event, AssistantDeltaEvent):
420
409
  turn["status"] = "responding"
421
- delta = str(payload.get("delta") or "")
410
+ delta = event.visualize()
422
411
  self._stream_buffer += delta
423
412
  if turn.get("_thinking_active"):
424
413
  turn["thinking"] = str(turn.get("thinking") or "") + delta
@@ -427,19 +416,19 @@ class WebSessionView:
427
416
  turn["_thinking_active"] = True
428
417
  return
429
418
 
430
- if kind == "tool_started":
419
+ if isinstance(event, ToolStartedEvent):
431
420
  turn["status"] = "tool"
432
- turn["tool_name"] = str(payload.get("tool_name") or "")
421
+ turn["tool_name"] = event.call.name
433
422
  turn["_thinking_active"] = False
434
423
  return
435
424
 
436
- if kind == "tool_completed":
425
+ if isinstance(event, ToolCompletedEvent):
437
426
  turn["_thinking_active"] = False
438
427
  turn["status"] = "running"
439
428
  return
440
429
 
441
- if kind == "turn_completed":
442
- response = str(payload.get("output_text") or "")
430
+ if isinstance(event, TurnCompletedEvent):
431
+ response = event.visualize()
443
432
  if response:
444
433
  turn["response"] = response
445
434
  elif turn.get("thinking"):
@@ -450,84 +439,76 @@ class WebSessionView:
450
439
  self._stream_buffer = ""
451
440
  return
452
441
 
453
- if kind in {"turn_failed", "submission_failed"}:
442
+ if isinstance(event, TurnFailedEvent):
454
443
  turn["status"] = "error"
455
- turn["error"] = str(payload.get("error") or kind)
444
+ turn["error"] = event.visualize()
456
445
  self._stream_buffer = ""
457
446
  return
458
447
 
459
- if kind in {"turn_interrupted", "submission_cancelled"}:
460
- if turn.get("thinking") and not turn.get("response"):
448
+ if isinstance(event, TurnInterruptedEvent):
449
+ if event.output_text:
450
+ turn["response"] = event.output_text
451
+ elif turn.get("thinking") and not turn.get("response"):
461
452
  turn["response"] = str(turn.get("thinking") or "")
462
453
  turn["thinking"] = ""
463
454
  turn["_thinking_active"] = False
464
455
  turn["status"] = "interrupted"
465
456
  self._stream_buffer = ""
466
457
 
467
- def _update_context_window(self, usage: "object") -> None:
468
- if self._context_window_tokens is None:
469
- return
470
- if not isinstance(usage, dict):
471
- self._context_remaining_percent = None
472
- return
473
- try:
474
- total_tokens = int(usage["total_tokens"])
475
- except (KeyError, TypeError, ValueError):
476
- self._context_remaining_percent = None
477
- return
478
- self._context_remaining_percent = percent_of_context_window_remaining(
479
- total_tokens,
480
- self._context_window_tokens,
481
- )
458
+ def _context_usage(self) -> "typing.Dict[str, object]":
459
+ limit = self._auto_compact_token_limit
460
+ if limit is None:
461
+ limit = self._max_context_window
462
+ if limit is None:
463
+ remaining_percent = None
464
+ elif self._usage_tokens is None:
465
+ remaining_percent = 100
466
+ elif self._usage_tokens >= limit:
467
+ remaining_percent = 0
468
+ else:
469
+ # Round up so zero means the actual threshold has been reached.
470
+ remaining_percent = min(
471
+ 100, ((limit - self._usage_tokens) * 100 + limit - 1) // limit
472
+ )
473
+ return {
474
+ "usage_tokens": self._usage_tokens,
475
+ "auto_compact_token_limit": self._auto_compact_token_limit,
476
+ "max_context_window": self._max_context_window,
477
+ "context_remaining_percent": remaining_percent,
478
+ }
482
479
 
483
- def _apply_spinner_event(
484
- self,
485
- kind: str,
486
- payload: "typing.Dict[str, object]",
487
- ) -> None:
488
- if kind == "assistant_delta":
480
+ def _apply_spinner_event(self, event: "TurnEvent") -> None:
481
+ if isinstance(event, AssistantDeltaEvent):
489
482
  self._set_spinner_status("talking")
490
483
  return
491
- if kind == "stream_error":
484
+ if isinstance(event, StreamErrorEvent):
492
485
  self._set_spinner_status("reconnecting")
493
486
  return
494
- if kind == "auto_compact_started":
487
+ if isinstance(event, (AutoCompactStartedEvent, CompactStartedEvent)):
495
488
  self._set_spinner_status("compacting")
496
489
  return
497
- if kind == "auto_compact_completed":
490
+ if isinstance(event, AutoCompactCompletedEvent):
498
491
  self._set_spinner_status("compacted")
499
492
  return
500
- if kind == "tool_started":
501
- tool_name = str(payload.get("tool_name") or "").strip()
502
- call = payload.get("call")
503
- if tool_name and isinstance(call, ToolCall):
504
- self._set_spinner_status(
505
- shorten_title(
506
- "calling {0}({1})".format(tool_name, call.arguments),
507
- limit=SPINNER_STATUS_PREVIEW_LIMIT,
508
- )
493
+ if isinstance(event, ToolStartedEvent):
494
+ self._set_spinner_status(
495
+ shorten_title(
496
+ event.visualize(),
497
+ limit=SPINNER_STATUS_PREVIEW_LIMIT,
509
498
  )
510
- elif tool_name:
511
- self._set_spinner_status("calling {0}".format(tool_name))
512
- else:
513
- self._set_spinner_status("calling provider tools")
514
- return
515
- if kind == "tool_completed":
516
- tool_name = str(payload.get("tool_name") or "").strip()
517
- if tool_name:
518
- self._set_spinner_status("called {0}".format(tool_name))
499
+ )
519
500
  return
520
- if kind in {"turn_completed", "turn_failed", "turn_interrupted"}:
521
- self._set_idle_spinner_status(payload)
501
+ if isinstance(event, ToolCompletedEvent):
502
+ self._set_spinner_status("called {0}".format(event.call.name))
522
503
  return
523
- if kind == "submission_failed":
524
- self._set_spinner_status("")
504
+ if isinstance(event, TerminalEvent):
505
+ self._set_idle_spinner_status(event)
525
506
 
526
507
  def _set_spinner_status(self, text: "typing.Union[str, None]") -> None:
527
508
  self._spinner_status = str(text or "").strip()
528
509
 
529
- def _set_idle_spinner_status(self, payload: "typing.Dict[str, object]") -> None:
530
- if background_work_count(payload) > 0:
510
+ def _set_idle_spinner_status(self, event: "TerminalEvent") -> None:
511
+ if (event.background_work_count or 0) > 0:
531
512
  self._set_spinner_status(IDLE_SLEEPING_STATUS)
532
513
  else:
533
514
  self._set_spinner_status("")
@@ -539,14 +520,14 @@ class WebSessionView:
539
520
  prompt: str,
540
521
  ) -> "typing.Dict[str, object]":
541
522
  submission_id = str(submission_id or "").strip()
542
- turn_id = str(turn_id or submission_id).strip()
523
+ turn_id = str(turn_id or "").strip()
543
524
  turn = self._turns_by_submission_id.get(submission_id)
544
525
  if turn is None and turn_id and not submission_id:
545
526
  turn = self._turns_by_turn_id.get(turn_id)
546
527
  if turn is None:
547
528
  turn = {
548
529
  "submission_id": submission_id,
549
- "turn_id": turn_id,
530
+ "turn_id": "",
550
531
  "prompt": prompt,
551
532
  "response": "",
552
533
  "thinking": "",
@@ -557,11 +538,13 @@ class WebSessionView:
557
538
  "queue": "steer",
558
539
  "sender": "web",
559
540
  }
560
- self._turns.append(turn)
561
541
  if submission_id:
562
542
  turn["submission_id"] = submission_id
563
543
  self._turns_by_submission_id[submission_id] = turn
564
544
  if turn_id:
545
+ if not turn["turn_id"]:
546
+ # Queue admission stays hidden until the turn actually starts.
547
+ self._turns.append(turn)
565
548
  turn["turn_id"] = turn_id
566
549
  self._turns_by_turn_id[turn_id] = turn
567
550
  if prompt:
@@ -619,58 +602,56 @@ class WebSessionView:
619
602
 
620
603
  loop.call_soon_threadsafe(publish)
621
604
 
622
- async def _publish(self, event: "typing.Dict[str, object]") -> None:
623
- self._publish_nowait(event)
624
-
625
605
 
626
606
  class WorkspaceInteractiveSession:
627
607
  def __init__(
628
608
  self,
629
- queue,
609
+ runtime,
630
610
  config_path: "typing.Union[str, None]" = None,
631
611
  ) -> None:
632
- self.queue = queue
612
+ self.runtime = runtime
633
613
  self.config_path = config_path
634
614
  self.view = WebSessionView()
635
- self._task: "typing.Union[asyncio.Task[int], None]" = None
615
+ self._frontend_id = None
636
616
 
637
617
  async def start(self) -> "WorkspaceInteractiveSession":
638
- if self._task is None:
639
- self._task = asyncio.create_task(
640
- run_interactive_session(
641
- self.queue,
642
- False,
643
- self.config_path,
644
- view=self.view,
645
- show_banner=False,
646
- )
647
- )
618
+ await self.runtime.start(self.config_path)
619
+ if self._frontend_id is None:
620
+ self._frontend_id = self.runtime.attach(self.view.handle_event)
648
621
  return self
649
622
 
650
623
  async def close(self) -> None:
651
- self.view.close()
652
- task = self._task
653
- if task is None:
654
- return
655
624
  try:
656
- await asyncio.wait_for(
657
- asyncio.shield(task),
658
- timeout=SESSION_CLOSE_TIMEOUT_SECONDS,
659
- )
660
- except asyncio.TimeoutError:
661
- cancel_current = getattr(self.queue, "cancel_current", None)
662
- if callable(cancel_current):
663
- cancel_current()
664
- task.cancel()
665
- await asyncio.gather(task, return_exceptions=True)
625
+ await self.runtime.close()
666
626
  finally:
667
- self._task = None
627
+ self.detach()
628
+
629
+ def detach(self):
630
+ if self._frontend_id is not None:
631
+ self.runtime.detach(self._frontend_id)
632
+ self._frontend_id = None
633
+ self.view.close()
634
+
635
+ async def submit(
636
+ self, prompt: str, sender: str = "web"
637
+ ) -> "typing.Dict[str, object]":
638
+ try:
639
+ receipt = await self.runtime.submit_input(prompt, sender)
640
+ except (ValueError, RuntimeError) as exc:
641
+ return {"ok": False, "error": str(exc), "snapshot": self.snapshot()}
642
+ return {
643
+ "ok": True,
644
+ "type": "submitted",
645
+ "submission_id": receipt.submission_id,
646
+ "snapshot": self.snapshot(),
647
+ }
668
648
 
669
- async def submit(self, prompt: str, sender: str = "web") -> "typing.Dict[str, object]":
670
- del sender
671
- result = await self.view.submit(prompt)
672
- result["snapshot"] = self.snapshot()
673
- return result
649
+ async def answer_input(self, request_id, answer):
650
+ try:
651
+ self.runtime.answer_input(request_id, answer)
652
+ except ValueError as exc:
653
+ return {"ok": False, "error": str(exc), "snapshot": self.snapshot()}
654
+ return {"ok": True, "type": "answered", "snapshot": self.snapshot()}
674
655
 
675
656
  def subscribe(self) -> "asyncio.Queue":
676
657
  return self.view.subscribe()
@@ -679,34 +660,23 @@ class WorkspaceInteractiveSession:
679
660
  self.view.unsubscribe(queue)
680
661
 
681
662
  def snapshot(self) -> "typing.Dict[str, object]":
682
- snapshot = self.view.snapshot()
683
- agent = getattr(self.queue, "_agent", None)
684
- snapshot["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
685
- return snapshot
663
+ return self.view.snapshot()
686
664
 
687
665
  def summary(self) -> "typing.Dict[str, object]":
688
- summary = self.view.summary()
689
- agent = getattr(self.queue, "_agent", None)
690
- summary["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
691
- return summary
666
+ return self.view.summary()
692
667
 
693
668
  def rollout_path(self) -> str:
694
- recorder = getattr(getattr(self.queue, "_agent", None), "_rollout_recorder", None)
695
- path = getattr(recorder, "rollout_path", None)
696
- return "" if path is None else str(path)
697
-
698
- async def restore_from_rollout(self, rollout_path: str, title: str = "") -> None:
699
- resumed = load_resumed_session_path(rollout_path, thread_name=title or None)
700
- agent = self.queue._agent
701
- agent.replace_history(resumed["history"])
702
- model_client = getattr(agent, "_model_client", None)
703
- if hasattr(model_client, "_session_id"):
704
- model_client._session_id = str(resumed["session_id"])
705
- agent.set_rollout_recorder(SessionRolloutRecorder.resume(resumed["rollout_path"]))
706
- self.view.load_session_history(
707
- str(title or resumed["title"]),
708
- tuple(resumed["turns"]),
709
- )
669
+ return self.view.snapshot()["rollout_path"]
670
+
671
+ async def restore_from_rollout(
672
+ self, rollout_path: str, title: str = "", fork: bool = False
673
+ ) -> None:
674
+ if rollout_path:
675
+ self.runtime.resume(rollout_path, title)
676
+ if fork:
677
+ self.runtime.fork()
678
+ else:
679
+ self.runtime.set_title(title)
710
680
 
711
681
 
712
682
  class ThreadedWorkspaceInteractiveSession:
@@ -722,7 +692,6 @@ class ThreadedWorkspaceInteractiveSession:
722
692
  self._thread: "typing.Union[threading.Thread, None]" = None
723
693
  self._worker_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
724
694
  self._ready = threading.Event()
725
- self._closed = threading.Event()
726
695
  self._startup_error: "typing.Union[BaseException, None]" = None
727
696
  self._session: "typing.Union[WorkspaceInteractiveSession, None]" = None
728
697
 
@@ -737,66 +706,76 @@ class ThreadedWorkspaceInteractiveSession:
737
706
  self._thread.start()
738
707
  await asyncio.to_thread(self._ready.wait)
739
708
  if self._startup_error is not None:
740
- raise RuntimeError("workspace session thread failed to start") from self._startup_error
709
+ raise RuntimeError(
710
+ "workspace session thread failed to start"
711
+ ) from self._startup_error
741
712
  return self
742
713
 
743
714
  def _thread_main(self) -> None:
744
715
  loop = asyncio.new_event_loop()
745
716
  self._worker_loop = loop
746
- self._view.attach_worker_loop(loop)
747
717
  asyncio.set_event_loop(loop)
748
718
  try:
749
719
  session = self._session_factory()
750
720
  session.view = self._view
751
721
  self._session = session
752
- loop.run_until_complete(session.start())
722
+ try:
723
+ loop.run_until_complete(session.start())
724
+ except BaseException:
725
+ loop.run_until_complete(session.close())
726
+ raise
753
727
  self._ready.set()
754
728
  loop.run_forever()
755
729
  except BaseException as exc:
756
730
  self._startup_error = exc
757
731
  self._ready.set()
758
732
  finally:
759
- session = self._session
760
- if session is not None:
761
- try:
762
- loop.run_until_complete(session.close())
763
- except BaseException:
764
- pass
765
733
  pending = asyncio.all_tasks(loop)
766
734
  for task in pending:
767
735
  task.cancel()
768
736
  if pending:
769
- loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
737
+ loop.run_until_complete(
738
+ asyncio.gather(*pending, return_exceptions=True)
739
+ )
770
740
  asyncio.set_event_loop(None)
771
741
  loop.close()
772
- self._closed.set()
773
742
 
774
743
  async def close(self) -> None:
775
744
  session = self._session
776
745
  loop = self._worker_loop
777
- if session is not None and loop is not None and loop.is_running():
778
- future = asyncio.run_coroutine_threadsafe(session.close(), loop)
779
- try:
780
- await asyncio.wait_for(
781
- asyncio.wrap_future(future),
782
- timeout=SESSION_CLOSE_TIMEOUT_SECONDS + 1.0,
746
+ try:
747
+ if session is not None and loop is not None and loop.is_running():
748
+ future = asyncio.wrap_future(
749
+ asyncio.run_coroutine_threadsafe(session.close(), loop)
783
750
  )
784
- except (asyncio.TimeoutError, RuntimeError):
785
- cancel_current = getattr(getattr(session, "queue", None), "cancel_current", None)
786
- if callable(cancel_current):
787
- cancel_current()
788
- if loop is not None and loop.is_running():
789
- loop.call_soon_threadsafe(loop.stop)
790
- thread = self._thread
791
- if thread is not None:
792
- await asyncio.to_thread(thread.join, SESSION_CLOSE_TIMEOUT_SECONDS + 1.0)
793
- self._thread = None
794
-
795
- async def submit(self, prompt: str, sender: str = "web") -> "typing.Dict[str, object]":
796
- del sender
797
- result = await self._view.submit(prompt)
798
- result["snapshot"] = self.snapshot()
799
- return result
751
+ try:
752
+ await asyncio.shield(future)
753
+ except asyncio.CancelledError:
754
+ await future
755
+ raise
756
+ finally:
757
+ if loop is not None and loop.is_running():
758
+ loop.call_soon_threadsafe(loop.stop)
759
+ thread = self._thread
760
+ if thread is not None:
761
+ await asyncio.to_thread(thread.join)
762
+ self._thread = None
763
+
764
+ async def submit(
765
+ self, prompt: str, sender: str = "web"
766
+ ) -> "typing.Dict[str, object]":
767
+ future = asyncio.run_coroutine_threadsafe(
768
+ self._session.submit(prompt, sender),
769
+ self._worker_loop,
770
+ )
771
+ return await asyncio.wrap_future(future)
772
+
773
+ async def answer_input(self, request_id, answer):
774
+ future = asyncio.run_coroutine_threadsafe(
775
+ self._session.answer_input(request_id, answer),
776
+ self._worker_loop,
777
+ )
778
+ return await asyncio.wrap_future(future)
800
779
 
801
780
  def subscribe(self) -> "asyncio.Queue":
802
781
  return self._view.subscribe()
@@ -805,34 +784,26 @@ class ThreadedWorkspaceInteractiveSession:
805
784
  self._view.unsubscribe(queue)
806
785
 
807
786
  def snapshot(self) -> "typing.Dict[str, object]":
808
- snapshot = self._view.snapshot()
809
- session = self._session
810
- queue = getattr(session, "queue", None)
811
- agent = getattr(queue, "_agent", None)
812
- snapshot["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
813
- return snapshot
787
+ return self._view.snapshot()
814
788
 
815
789
  def summary(self) -> "typing.Dict[str, object]":
816
- summary = self._view.summary()
817
- session = self._session
818
- queue = getattr(session, "queue", None)
819
- agent = getattr(queue, "_agent", None)
820
- summary["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
821
- return summary
790
+ return self._view.summary()
822
791
 
823
792
  def rollout_path(self) -> str:
824
793
  if self._session is None:
825
794
  return ""
826
795
  return self._session.rollout_path()
827
796
 
828
- async def restore_from_rollout(self, rollout_path: str, title: str = "") -> None:
797
+ async def restore_from_rollout(
798
+ self, rollout_path: str, title: str = "", fork: bool = False
799
+ ) -> None:
829
800
  session = self._session
830
801
  loop = self._worker_loop
831
802
  if session is None or loop is None:
832
803
  return
833
804
 
834
805
  future = asyncio.run_coroutine_threadsafe(
835
- session.restore_from_rollout(rollout_path, title=title),
806
+ session.restore_from_rollout(rollout_path, title=title, fork=fork),
836
807
  loop,
837
808
  )
838
809
  await asyncio.wrap_future(future)
@@ -856,7 +827,7 @@ def create_app(
856
827
 
857
828
 
858
829
  def create_multi_workspace_app(
859
- registry: 'WorkspaceRegistry',
830
+ registry: "WorkspaceRegistry",
860
831
  password: "typing.Union[str, None]" = None,
861
832
  ) -> FastAPI:
862
833
  app = _create_lifespan_app(registry.start, registry.close)
@@ -942,7 +913,9 @@ def create_multi_workspace_app(
942
913
  return await _new_session_response(entry.manager)
943
914
 
944
915
  @app.delete("/w/{workspace_id}/api/sessions/{session_id}")
945
- async def workspace_delete_session(workspace_id: str, session_id: str) -> JSONResponse:
916
+ async def workspace_delete_session(
917
+ workspace_id: str, session_id: str
918
+ ) -> JSONResponse:
946
919
  entry = _workspace_entry_or_404(registry, workspace_id)
947
920
  return await _delete_session_response(entry.manager, session_id)
948
921
 
@@ -963,8 +936,12 @@ def create_multi_workspace_app(
963
936
  return await _message_response(entry.manager, payload)
964
937
 
965
938
  @app.websocket("/w/{workspace_id}/ws/session")
966
- async def workspace_websocket_session(workspace_id: str, websocket: WebSocket) -> None:
967
- if not _auth_cookie_matches(auth_token, websocket.cookies.get(AUTH_COOKIE_NAME)):
939
+ async def workspace_websocket_session(
940
+ workspace_id: str, websocket: WebSocket
941
+ ) -> None:
942
+ if not _auth_cookie_matches(
943
+ auth_token, websocket.cookies.get(AUTH_COOKIE_NAME)
944
+ ):
968
945
  await websocket.close(code=1008)
969
946
  return
970
947
  try:
@@ -1004,20 +981,35 @@ def _install_auth(app: FastAPI, password: "typing.Union[str, None]") -> str:
1004
981
  {"ok": False, "error": "authentication required"},
1005
982
  status_code=401,
1006
983
  )
1007
- return RedirectResponse(url="/login", status_code=303)
984
+ target = path + ("?" + request.url.query if request.url.query else "")
985
+ return RedirectResponse(
986
+ url="/login?" + urlencode({"next": target}), status_code=303
987
+ )
1008
988
 
1009
989
  @app.get("/login")
1010
990
  async def login_page() -> HTMLResponse:
1011
991
  return _html_response(_render_login_shell())
1012
992
 
1013
993
  @app.post("/login")
1014
- async def login(payload: "typing.Dict[str, object]") -> JSONResponse:
1015
- if not secrets.compare_digest(str(payload.get("password") or ""), password_text):
994
+ async def login(
995
+ request: Request, payload: "typing.Dict[str, object]"
996
+ ) -> JSONResponse:
997
+ if not secrets.compare_digest(
998
+ str(payload.get("password") or ""), password_text
999
+ ):
1016
1000
  return JSONResponse(
1017
1001
  {"ok": False, "error": "invalid password"},
1018
1002
  status_code=401,
1019
1003
  )
1020
- response = JSONResponse({"ok": True})
1004
+ target = request.query_params.get("next", "/")
1005
+ if (
1006
+ not target.startswith("/")
1007
+ or target.startswith("//")
1008
+ or "\\" in target
1009
+ or any(ord(char) < 32 for char in target)
1010
+ ):
1011
+ target = "/"
1012
+ response = JSONResponse({"ok": True, "redirect": target})
1021
1013
  response.set_cookie(
1022
1014
  AUTH_COOKIE_NAME,
1023
1015
  token,
@@ -1095,13 +1087,14 @@ def _render_login_shell() -> str:
1095
1087
  form.addEventListener("submit", async function(event) {
1096
1088
  event.preventDefault();
1097
1089
  statusEl.textContent = "";
1098
- const response = await fetch("login", {
1090
+ const response = await fetch(window.location.pathname + window.location.search, {
1099
1091
  method: "POST",
1100
1092
  headers: {"Content-Type": "application/json"},
1101
1093
  body: JSON.stringify({password: passwordInput.value}),
1102
1094
  });
1103
1095
  if (response.ok) {
1104
- window.location.href = "/";
1096
+ const result = await response.json();
1097
+ window.location.href = result.redirect;
1105
1098
  return;
1106
1099
  }
1107
1100
  statusEl.textContent = "Invalid password";
@@ -1116,6 +1109,7 @@ def _create_lifespan_app(
1116
1109
  close: "typing.Callable[[], typing.Awaitable[None]]",
1117
1110
  ) -> FastAPI:
1118
1111
  if asynccontextmanager is not None:
1112
+
1119
1113
  @asynccontextmanager
1120
1114
  async def lifespan(_app):
1121
1115
  await start()
@@ -1135,6 +1129,7 @@ def _create_lifespan_app(
1135
1129
  @app.on_event("shutdown")
1136
1130
  async def shutdown() -> None:
1137
1131
  await close()
1132
+
1138
1133
  return app
1139
1134
 
1140
1135
 
@@ -1190,7 +1185,9 @@ def _install_workspace_routes(
1190
1185
  @app.websocket("/ws/session")
1191
1186
  async def websocket_session(websocket: WebSocket) -> None:
1192
1187
  auth_token = typing.cast(str, app.state.workspace_auth_token)
1193
- if not _auth_cookie_matches(auth_token, websocket.cookies.get(AUTH_COOKIE_NAME)):
1188
+ if not _auth_cookie_matches(
1189
+ auth_token, websocket.cookies.get(AUTH_COOKIE_NAME)
1190
+ ):
1194
1191
  await websocket.close(code=1008)
1195
1192
  return
1196
1193
  await _websocket_session_handler(manager, websocket)
@@ -1223,11 +1220,11 @@ def _websocket_backend_hint_response() -> JSONResponse:
1223
1220
  )
1224
1221
 
1225
1222
 
1226
- def _sessions_response(manager: 'WorkspaceSessionManager') -> JSONResponse:
1223
+ def _sessions_response(manager: "WorkspaceSessionManager") -> JSONResponse:
1227
1224
  return JSONResponse({"sessions": manager.list_sessions()})
1228
1225
 
1229
1226
 
1230
- async def _new_session_response(manager: 'WorkspaceSessionManager') -> JSONResponse:
1227
+ async def _new_session_response(manager: "WorkspaceSessionManager") -> JSONResponse:
1231
1228
  session_id = await manager.create_session()
1232
1229
  return JSONResponse(
1233
1230
  {
@@ -1240,7 +1237,7 @@ async def _new_session_response(manager: 'WorkspaceSessionManager') -> JSONRespo
1240
1237
 
1241
1238
 
1242
1239
  async def _delete_session_response(
1243
- manager: 'WorkspaceSessionManager',
1240
+ manager: "WorkspaceSessionManager",
1244
1241
  session_id: str,
1245
1242
  ) -> JSONResponse:
1246
1243
  try:
@@ -1253,7 +1250,7 @@ async def _delete_session_response(
1253
1250
 
1254
1251
 
1255
1252
  def _session_response(
1256
- manager: 'WorkspaceSessionManager',
1253
+ manager: "WorkspaceSessionManager",
1257
1254
  session_id: "typing.Union[str, None]" = None,
1258
1255
  ) -> JSONResponse:
1259
1256
  try:
@@ -1271,7 +1268,7 @@ def _session_response(
1271
1268
 
1272
1269
 
1273
1270
  async def _message_response(
1274
- manager: 'WorkspaceSessionManager',
1271
+ manager: "WorkspaceSessionManager",
1275
1272
  payload: "typing.Dict[str, object]",
1276
1273
  ) -> JSONResponse:
1277
1274
  session_id = str(payload.get("session_id") or "")
@@ -1279,7 +1276,13 @@ async def _message_response(
1279
1276
  link = manager.get(session_id or None)
1280
1277
  except KeyError:
1281
1278
  raise HTTPException(status_code=404, detail="session not found")
1282
- result = await link.submit(str(payload.get("prompt") or ""))
1279
+ if "request_id" in payload:
1280
+ result = await link.answer_input(payload["request_id"], payload.get("answer"))
1281
+ else:
1282
+ result = await link.submit(
1283
+ str(payload.get("prompt") or ""),
1284
+ sender=str(payload.get("sender") or "web"),
1285
+ )
1283
1286
  if isinstance(result, dict):
1284
1287
  result.setdefault("sessions", manager.list_sessions())
1285
1288
  status = 200 if result.get("ok") else 400
@@ -1287,7 +1290,7 @@ async def _message_response(
1287
1290
 
1288
1291
 
1289
1292
  async def _websocket_session_handler(
1290
- manager: 'WorkspaceSessionManager',
1293
+ manager: "WorkspaceSessionManager",
1291
1294
  websocket: WebSocket,
1292
1295
  ) -> None:
1293
1296
  await websocket.accept()
@@ -1308,7 +1311,7 @@ async def _websocket_session_handler(
1308
1311
  await websocket.send_json({"type": "error", "error": "invalid json"})
1309
1312
  continue
1310
1313
  action = str(payload.get("type") or payload.get("action") or "")
1311
- if action == "send":
1314
+ if action in {"send", "answer"}:
1312
1315
  target_session_id = str(payload.get("session_id") or session_id or "")
1313
1316
  try:
1314
1317
  target_link = manager.get(target_session_id or None)
@@ -1317,10 +1320,16 @@ async def _websocket_session_handler(
1317
1320
  {"type": "error", "error": "session not found"}
1318
1321
  )
1319
1322
  continue
1320
- result = await target_link.submit(
1321
- str(payload.get("prompt") or ""),
1322
- sender=str(payload.get("sender") or "web"),
1323
- )
1323
+ if action == "answer":
1324
+ result = await target_link.answer_input(
1325
+ payload.get("request_id"),
1326
+ payload.get("answer"),
1327
+ )
1328
+ else:
1329
+ result = await target_link.submit(
1330
+ str(payload.get("prompt") or ""),
1331
+ sender=str(payload.get("sender") or "web"),
1332
+ )
1324
1333
  await websocket.send_json({"type": "send_result", "result": result})
1325
1334
  elif action == "ping":
1326
1335
  await websocket.send_json({"type": "pong"})
@@ -1364,6 +1373,58 @@ def _public_turn(turn: "typing.Dict[str, object]") -> "typing.Dict[str, object]"
1364
1373
  )
1365
1374
 
1366
1375
 
1376
+ def _event_data(event: "Event") -> "typing.Dict[str, object]":
1377
+ payload = {
1378
+ item.name: getattr(event, item.name)
1379
+ for item in fields(event)
1380
+ if item.name not in {"turn_id", "submission_id"}
1381
+ }
1382
+ turn_id = ""
1383
+ if isinstance(event, TurnEvent):
1384
+ turn_id = event.turn_id
1385
+ if event.submission_id is not None:
1386
+ payload.update(submission_id=event.submission_id, turn_id=event.turn_id)
1387
+ elif isinstance(
1388
+ event, (CommandCompletedEvent, CommandFailedEvent, InputQueuedEvent)
1389
+ ):
1390
+ turn_id = event.submission_id
1391
+ if isinstance(event, InputQueuedEvent):
1392
+ payload["submission_id"] = event.submission_id
1393
+ if isinstance(event, TurnStartedEvent):
1394
+ payload["user_text"] = "\n".join(event.user_texts)
1395
+ if isinstance(event, (ToolStartedEvent, ToolCompletedEvent)):
1396
+ payload.update(tool_name=event.call.name, call_id=event.call.call_id)
1397
+ if isinstance(event, ToolCompletedEvent):
1398
+ payload["is_error"] = event.result.is_error
1399
+ if isinstance(event, ToolCalledEvent):
1400
+ payload = {name: value for name, value in payload.items() if value is not None}
1401
+ if isinstance(
1402
+ event,
1403
+ (
1404
+ CompactStartedEvent,
1405
+ CompactCompletedEvent,
1406
+ CompactFailedEvent,
1407
+ AutoCompactStartedEvent,
1408
+ AutoCompactCompletedEvent,
1409
+ AutoCompactFailedEvent,
1410
+ ),
1411
+ ):
1412
+ for name in ("total_tokens", "token_limit"):
1413
+ if payload[name] is None:
1414
+ del payload[name]
1415
+ if payload.get("pruned_tool_results") == 0:
1416
+ del payload["pruned_tool_results"]
1417
+ if isinstance(event, (CompactCompletedEvent, AutoCompactCompletedEvent)):
1418
+ payload["summary"] = event.summary
1419
+ if isinstance(event, TerminalEvent) and event.background_work_count is None:
1420
+ del payload["background_work_count"]
1421
+ if isinstance(event, InputRequestedEvent):
1422
+ payload["kind"] = payload.pop("request_kind")
1423
+ payload["text"] = event.visualize()
1424
+ payload = {name: value for name, value in payload.items() if value is not None}
1425
+ return {"kind": event.kind, "turn_id": turn_id, "payload": _json_safe(payload)}
1426
+
1427
+
1367
1428
  def _json_safe(value: object) -> "JSONValue":
1368
1429
  if value is None or isinstance(value, (bool, int, float, str)):
1369
1430
  return value
@@ -1371,6 +1432,8 @@ def _json_safe(value: object) -> "JSONValue":
1371
1432
  return [_json_safe(item) for item in value]
1372
1433
  if isinstance(value, dict):
1373
1434
  return {str(key): _json_safe(item) for key, item in value.items()}
1435
+ if isinstance(value, InputRequestedEvent):
1436
+ return _event_data(value)["payload"]
1374
1437
  if is_dataclass(value):
1375
1438
  return _json_safe(asdict(value))
1376
1439
  try:
@@ -1419,10 +1482,9 @@ def _board_asset_response(
1419
1482
  try:
1420
1483
  board_directory = board_path.parent.resolve()
1421
1484
  resolved_asset = (board_directory / asset_path).resolve()
1422
- within_board_directory = (
1423
- os.path.commonpath([str(board_directory), str(resolved_asset)])
1424
- == str(board_directory)
1425
- )
1485
+ within_board_directory = os.path.commonpath(
1486
+ [str(board_directory), str(resolved_asset)]
1487
+ ) == str(board_directory)
1426
1488
  except (OSError, RuntimeError, ValueError):
1427
1489
  within_board_directory = False
1428
1490
 
@@ -1444,9 +1506,9 @@ def _board_asset_response(
1444
1506
 
1445
1507
 
1446
1508
  def _workspace_entry_or_404(
1447
- registry: 'WorkspaceRegistry',
1509
+ registry: "WorkspaceRegistry",
1448
1510
  workspace_id: str,
1449
- ) -> 'WorkspaceEntry':
1511
+ ) -> "WorkspaceEntry":
1450
1512
  try:
1451
1513
  return registry.get(workspace_id)
1452
1514
  except (KeyError, ValueError):
@@ -1460,10 +1522,7 @@ def run_serve_cli(args: "argparse.Namespace") -> int:
1460
1522
  configure_loguru()
1461
1523
 
1462
1524
  definitions = load_workspace_definitions(args.workspace_config)
1463
- entries = [
1464
- _build_workspace_entry(definition, args)
1465
- for definition in definitions
1466
- ]
1525
+ entries = [_build_workspace_entry(definition, args) for definition in definitions]
1467
1526
  registry = WorkspaceRegistry(
1468
1527
  entries,
1469
1528
  config_path=args.workspace_config,
@@ -1495,10 +1554,10 @@ def run_serve_cli(args: "argparse.Namespace") -> int:
1495
1554
 
1496
1555
 
1497
1556
  def _build_workspace_entry(
1498
- definition: 'WorkspaceDefinition',
1557
+ definition: "WorkspaceDefinition",
1499
1558
  args: "argparse.Namespace",
1500
1559
  persist_callback: "typing.Union[typing.Callable[[], None], None]" = None,
1501
- ) -> 'WorkspaceEntry':
1560
+ ) -> "WorkspaceEntry":
1502
1561
  def build_session() -> "WorkspaceInteractiveSession":
1503
1562
  model = build_model(
1504
1563
  config_path=args.config,
@@ -1513,7 +1572,6 @@ def _build_workspace_entry(
1513
1572
  config_path=args.config,
1514
1573
  profile=args.profile,
1515
1574
  system_prompt=args.system_prompt,
1516
- session_mode="tui",
1517
1575
  extra_contextual_user_messages=(
1518
1576
  [_board_context_text(definition.board_path, definition.work_dir)]
1519
1577
  if definition.board_path is not None
@@ -1523,12 +1581,14 @@ def _build_workspace_entry(
1523
1581
  toolset=args.toolset,
1524
1582
  )
1525
1583
  return WorkspaceInteractiveSession(
1526
- build_cli_queue(agent),
1584
+ build_runtime(agent),
1527
1585
  config_path=args.config,
1528
1586
  )
1529
1587
 
1530
1588
  def session_factory() -> "ThreadedWorkspaceInteractiveSession":
1531
- return ThreadedWorkspaceInteractiveSession(build_session, asyncio.get_running_loop())
1589
+ return ThreadedWorkspaceInteractiveSession(
1590
+ build_session, asyncio.get_running_loop()
1591
+ )
1532
1592
 
1533
1593
  return WorkspaceEntry(
1534
1594
  definition=definition,
@@ -1578,12 +1638,9 @@ def _render_workspace_shell(
1578
1638
  board_label = str(board_path) if board_path is not None else "No board"
1579
1639
  cwd_label = str(work_dir or Path.cwd())
1580
1640
  page_title = str(title or "pycodex workspace")
1581
- template = (Path(__file__).with_name("workspace.html")).read_text(
1582
- encoding="utf-8"
1583
- )
1641
+ template = (Path(__file__).with_name("workspace.html")).read_text(encoding="utf-8")
1584
1642
  return (
1585
- template
1586
- .replace("__WORKSPACE_TITLE__", html.escape(page_title))
1643
+ template.replace("__WORKSPACE_TITLE__", html.escape(page_title))
1587
1644
  .replace("__BOARD_LABEL__", html.escape(board_label))
1588
1645
  .replace("__CWD_LABEL__", html.escape(cwd_label))
1589
1646
  )
@@ -1594,23 +1651,52 @@ def _render_workspaces_manager_shell() -> str:
1594
1651
 
1595
1652
 
1596
1653
  def _render_empty_board() -> str:
1597
- return """<!doctype html>
1598
- <html><head><meta charset="utf-8"><title>No board</title></head>
1599
- <body style="font:14px -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;padding:24px">
1600
- <h1>No board</h1>
1601
- <p>Add a workspace from the workspaces manager page.</p>
1602
- </body></html>"""
1654
+ return _render_board_placeholder(
1655
+ "No board",
1656
+ "No board connected",
1657
+ "Add a board from the workspaces page to see your work here.",
1658
+ )
1603
1659
 
1604
1660
 
1605
1661
  def _render_missing_board(board_path: Path) -> str:
1606
- escaped_path = html.escape(str(board_path))
1662
+ return _render_board_placeholder(
1663
+ "Board pending",
1664
+ "Your work will appear here",
1665
+ "Ask pycodex to create a page, a report, or a visual. "
1666
+ "This canvas updates as you work.",
1667
+ str(board_path),
1668
+ )
1669
+
1670
+
1671
+ def _render_board_placeholder(
1672
+ title: str, heading: str, description: str, path_label: str = ""
1673
+ ) -> str:
1607
1674
  return """<!doctype html>
1608
- <html><head><meta charset="utf-8"><title>Board pending</title></head>
1609
- <body style="font:14px -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;padding:24px">
1610
- <h1>Board pending</h1>
1611
- <p>The board file does not exist yet.</p>
1612
- <p><code>{0}</code></p>
1613
- </body></html>""".format(escaped_path)
1675
+ <html lang="en"><head><meta charset="utf-8">
1676
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1677
+ <title>{0}</title><style>
1678
+ * {{ box-sizing: border-box; }}
1679
+ body {{ margin: 0; min-height: 100dvh; display: grid; place-items: center;
1680
+ padding: 32px; background: #f8fbfd; color: #172630;
1681
+ font: 14px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }}
1682
+ main {{ max-width: 360px; text-align: center; }}
1683
+ svg {{ width: 40px; height: 40px; color: #aebfc9; margin-bottom: 20px; }}
1684
+ h1 {{ margin: 0 0 12px; font-size: 23px; font-weight: 500; line-height: 1.35;
1685
+ letter-spacing: -0.5px; }}
1686
+ p {{ margin: 0; color: #60727d; }}
1687
+ code {{ display: block; margin-top: 28px; font-size: 11px; color: #60727d;
1688
+ overflow-wrap: anywhere; }}
1689
+ </style></head><body><main>
1690
+ <svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1.25"
1691
+ aria-hidden="true"><rect x="3" y="3" width="26" height="26" rx="5"/>
1692
+ <path d="M3 11h26M11 11v18"/></svg>
1693
+ <h1>{1}</h1><p>{2}</p><code>{3}</code>
1694
+ </main></body></html>""".format(
1695
+ html.escape(title),
1696
+ html.escape(heading),
1697
+ html.escape(description),
1698
+ html.escape(path_label),
1699
+ )
1614
1700
 
1615
1701
 
1616
1702
  def main(argv: "typing.Union[typing.Sequence[str], None]" = None) -> int: