python-codex 0.2.6__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 +18 -14
  2. pycodex/agent.py +468 -462
  3. pycodex/bootstrap.py +417 -0
  4. pycodex/cli.py +236 -436
  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 +329 -252
  12. pycodex/model_metadata.py +19 -7
  13. pycodex/portable.py +90 -52
  14. pycodex/portable_server.py +32 -24
  15. pycodex/prompts/models.json +235 -803
  16. pycodex/protocol.py +177 -137
  17. pycodex/runtime.py +579 -174
  18. pycodex/runtime_services.py +204 -157
  19. pycodex/tools/__init__.py +4 -1
  20. pycodex/tools/apply_patch_tool.py +69 -48
  21. pycodex/tools/base_tool.py +89 -42
  22. pycodex/tools/clock_tool.py +201 -0
  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 +13 -13
  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 +50 -66
  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/utils/image_utils.py +76 -0
  52. pycodex/utils/random_ids.py +1 -2
  53. pycodex/utils/session_persist.py +263 -161
  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 +25 -22
  61. responses_server/messages_api.py +96 -49
  62. responses_server/payload_processors.py +25 -19
  63. responses_server/server.py +11 -11
  64. responses_server/session_store.py +14 -11
  65. responses_server/stream_router.py +196 -107
  66. responses_server/tools/custom_adapter.py +17 -16
  67. responses_server/tools/web_search.py +39 -36
  68. responses_server/trajectory_dump.py +51 -13
  69. workspace_server/__main__.py +0 -1
  70. workspace_server/app.py +470 -384
  71. workspace_server/workspace.html +859 -232
  72. workspace_server/workspaces.html +94 -95
  73. workspace_server/workspaces.py +168 -100
  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 -553
  80. python_codex-0.2.6.dist-info/METADATA +0 -441
  81. python_codex-0.2.6.dist-info/RECORD +0 -91
  82. {python_codex-0.2.6.dist-info → python_codex-0.3.0.dist-info}/WHEEL +0 -0
  83. {python_codex-0.2.6.dist-info → python_codex-0.3.0.dist-info}/entry_points.txt +0 -0
  84. {python_codex-0.2.6.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,21 +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_LISTENING_STATUS,
36
- percent_of_context_window_remaining,
60
+ from pycodex.utils.event_helpers import (
61
+ completed_history,
37
62
  shorten_title,
38
- tool_summary,
39
63
  )
64
+
40
65
  from .workspaces import (
41
66
  WorkspaceDefinition,
42
67
  WorkspaceEntry,
@@ -45,8 +70,6 @@ from .workspaces import (
45
70
  load_workspace_definitions,
46
71
  session_snapshot,
47
72
  )
48
- import typing
49
-
50
73
 
51
74
  JSONValue = typing.Union[
52
75
  None,
@@ -93,6 +116,12 @@ def build_parser() -> "argparse.ArgumentParser":
93
116
  default=None,
94
117
  help="Optional base instructions override passed to the model.",
95
118
  )
119
+ parser.add_argument(
120
+ "--toolset",
121
+ nargs="*",
122
+ default=None,
123
+ help="Builtin tool names for all sessions; an empty list disables tools.",
124
+ )
96
125
  parser.add_argument(
97
126
  "--timeout-seconds",
98
127
  type=float,
@@ -134,93 +163,41 @@ def parse_listen(target: str) -> "typing.Tuple[str, int]":
134
163
 
135
164
  SessionFactory = typing.Callable[[], object]
136
165
  ThreadedSessionFactory = typing.Callable[[], "WorkspaceInteractiveSession"]
137
- SESSION_CLOSE_TIMEOUT_SECONDS = 2.0
138
166
  SPINNER_STATUS_PREVIEW_LIMIT = 180
139
167
  AUTH_COOKIE_NAME = "pycodex_ws_auth"
140
168
 
141
169
 
142
170
  class WebSessionView:
143
171
  def __init__(self) -> None:
144
- self._input_queue: "asyncio.Queue" = asyncio.Queue()
145
172
  self._subscribers: "typing.Set[asyncio.Queue]" = set()
146
173
  self._events: "typing.List[typing.Dict[str, object]]" = []
147
174
  self._turns: "typing.List[typing.Dict[str, object]]" = []
148
175
  self._turns_by_submission_id: "typing.Dict[str, typing.Dict[str, object]]" = {}
149
176
  self._turns_by_turn_id: "typing.Dict[str, typing.Dict[str, object]]" = {}
150
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
151
183
  self._spinner_status = ""
152
184
  self._stream_buffer = ""
153
- self._context_window_tokens: "typing.Union[int, None]" = None
154
- self._context_remaining_percent: "typing.Union[int, None]" = None
155
- 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
156
188
  self._server_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
157
- self._worker_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
158
189
  self._lock = threading.RLock()
159
190
 
160
191
  def attach_server_loop(self, loop: "asyncio.AbstractEventLoop") -> None:
161
192
  self._server_loop = loop
162
193
 
163
- def attach_worker_loop(self, loop: "asyncio.AbstractEventLoop") -> None:
164
- self._worker_loop = loop
165
-
166
- async def submit(self, prompt: str) -> "typing.Dict[str, object]":
167
- prompt = str(prompt or "").strip()
168
- if not prompt:
169
- return {"ok": False, "error": "prompt is empty"}
170
- await self._put_input(prompt)
171
- await self._publish(
172
- {
173
- "type": "input",
174
- "prompt": prompt,
175
- "snapshot": self.snapshot(),
176
- }
177
- )
178
- return {"ok": True, "type": "submitted", "snapshot": self.snapshot()}
179
-
180
- async def _put_input(self, item: object) -> None:
181
- worker_loop = self._worker_loop
182
- try:
183
- running_loop = asyncio.get_running_loop()
184
- except RuntimeError:
185
- running_loop = None
186
- if worker_loop is None or worker_loop is running_loop:
187
- await self._input_queue.put(item)
188
- return
189
- future = asyncio.run_coroutine_threadsafe(self._input_queue.put(item), worker_loop)
190
- await asyncio.wrap_future(future)
191
-
192
- async def poll_prompt(self, prompt: "typing.Union[str, None]" = None) -> "typing.Union[str, None]":
193
- del prompt
194
- if self._closed and self._input_queue.empty():
195
- raise EOFError()
196
- try:
197
- item = self._input_queue.get_nowait()
198
- except asyncio.QueueEmpty:
199
- return None
200
- if item is None:
201
- raise EOFError()
202
- return str(item)
203
-
204
- async def get_prompt(self, prompt: "typing.Union[str, None]" = None) -> "str":
205
- if prompt:
206
- self.write_line(prompt)
207
- item = await self._input_queue.get()
208
- if item is None:
209
- raise EOFError()
210
- return str(item)
211
-
212
- def handle_event(self, event: "AgentEvent") -> None:
194
+ def handle_event(self, event: "Event") -> None:
213
195
  with self._lock:
214
196
  self._apply_runtime_event(event)
215
- payload = {
216
- "type": "event",
217
- "kind": str(getattr(event, "kind", "")),
218
- "turn_id": str(getattr(event, "turn_id", "")),
219
- "payload": _json_safe(getattr(event, "payload", {})),
220
- "snapshot": self.snapshot(),
221
- }
222
- if payload["kind"] == "tool_completed":
223
- 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()
224
201
  self._publish_nowait(payload)
225
202
 
226
203
  def finish_stream(self) -> None:
@@ -254,23 +231,6 @@ class WebSessionView:
254
231
  event = {"type": "snapshot", "snapshot": self.snapshot()}
255
232
  self._publish_nowait(event)
256
233
 
257
- def show_history(self) -> None:
258
- assistant_turns = [turn for turn in self._turns if turn.get("kind") != "control"]
259
- if not assistant_turns:
260
- self.write_line("No history yet.")
261
- return
262
- lines = ["Session: {0}".format(self._title or "untitled")]
263
- for index, turn in enumerate(assistant_turns, start=1):
264
- prompt = str(turn.get("prompt") or "")
265
- response = str(turn.get("response") or turn.get("thinking") or "")
266
- lines.append("[{0}]U> {1}".format(index, prompt))
267
- if response:
268
- lines.append("[{0}]A> {1}".format(index, response))
269
- self.write_line("\n".join(lines))
270
-
271
- def show_title(self) -> None:
272
- self.write_line("Session: {0}".format(self._title or "untitled"))
273
-
274
234
  def set_session_title(self, title: str) -> None:
275
235
  with self._lock:
276
236
  self._set_title(title)
@@ -281,12 +241,6 @@ class WebSessionView:
281
241
  }
282
242
  self._publish_nowait(event)
283
243
 
284
- def show_resumed_session(self, title: str) -> None:
285
- with self._lock:
286
- self._set_title(title)
287
- event = {"type": "snapshot", "snapshot": self.snapshot()}
288
- self._publish_nowait(event)
289
-
290
244
  def load_session_history(
291
245
  self,
292
246
  title: "typing.Union[str, None]",
@@ -301,7 +255,9 @@ class WebSessionView:
301
255
  self._events = []
302
256
  for prompt, response in history:
303
257
  submission_id = uuid7_string()
304
- 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
+ )
305
261
  turn["response"] = str(response or "")
306
262
  turn["status"] = "completed"
307
263
  turn["queue"] = "history"
@@ -309,22 +265,6 @@ class WebSessionView:
309
265
  event = {"type": "snapshot", "snapshot": self.snapshot()}
310
266
  self._publish_nowait(event)
311
267
 
312
- def show_steer_queued(self, turn_id: str, prompt: str) -> None:
313
- del turn_id, prompt
314
-
315
- def schedule_steer_inserted(self, turn_id: str, prompt: str) -> None:
316
- del turn_id, prompt
317
-
318
- def set_context_window_tokens(
319
- self,
320
- context_window_tokens: "typing.Union[int, None]",
321
- ) -> None:
322
- with self._lock:
323
- self._context_window_tokens = context_window_tokens
324
- self._context_remaining_percent = (
325
- 100 if context_window_tokens is not None else None
326
- )
327
-
328
268
  def subscribe(self) -> "asyncio.Queue":
329
269
  queue: "asyncio.Queue" = asyncio.Queue()
330
270
  with self._lock:
@@ -343,14 +283,8 @@ class WebSessionView:
343
283
 
344
284
  def close(self) -> None:
345
285
  with self._lock:
346
- self._closed = True
347
286
  subscribers = tuple(self._subscribers)
348
287
  self._subscribers.clear()
349
- worker_loop = self._worker_loop
350
- if worker_loop is None:
351
- self._input_queue.put_nowait(None)
352
- else:
353
- asyncio.run_coroutine_threadsafe(self._input_queue.put(None), worker_loop)
354
288
  self._publish_to_queues(subscribers, None)
355
289
 
356
290
  def snapshot(self) -> "typing.Dict[str, object]":
@@ -360,58 +294,120 @@ class WebSessionView:
360
294
  "status": self._spinner_status,
361
295
  "status_kind": "spinner" if self._spinner_status else "idle",
362
296
  "spinner": self._spinner_status,
363
- "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,
364
307
  "title": self._title,
365
- "context_remaining_percent": self._context_remaining_percent,
308
+ **self._context_usage(),
366
309
  "turns": [_public_turn(turn) for turn in self._turns[-80:]],
367
310
  }
368
311
 
369
312
  def summary(self) -> "typing.Dict[str, object]":
370
313
  with self._lock:
371
314
  return {
315
+ "model": self._model,
372
316
  "running": bool(self._spinner_status),
373
317
  "spinner": self._spinner_status,
374
318
  "title": self._title,
375
319
  "turn_count": len(self._turns),
376
320
  "last_assistant": _last_assistant_text(self._turns),
377
- "context_remaining_percent": self._context_remaining_percent,
321
+ **self._context_usage(),
378
322
  }
379
323
 
380
- def _apply_runtime_event(self, event: "AgentEvent") -> None:
381
- kind = str(getattr(event, "kind", "") or "")
382
- payload = getattr(event, "payload", {})
383
- if not isinstance(payload, dict):
384
- payload = {}
385
- if kind == "token_count":
386
- 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
387
373
  return
388
- turn_id = str(payload.get("turn_id") or getattr(event, "turn_id", "") or "")
389
- submission_id = str(payload.get("submission_id") or turn_id or "")
374
+ if isinstance(event, SessionClosedEvent):
375
+ self._accepts_input = False
376
+ self._spinner_status = ""
377
+ return
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
390
393
  turn = self._turns_by_submission_id.get(submission_id)
391
- if turn is None and turn_id and not submission_id:
392
- turn = self._turns_by_turn_id.get(turn_id)
393
394
 
394
- if kind == "turn_started":
395
- self._set_spinner_status(kind)
396
- prompt = payload.get("user_text") or "\n".join(
397
- str(item) for item in payload.get("user_texts", []) or []
398
- )
399
- if not self._title and str(prompt or "").strip():
400
- self._set_title(shorten_title(str(prompt or "")))
401
- 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())
402
398
  turn["status"] = "running"
403
399
  turn["thinking"] = ""
404
400
  turn["_thinking_active"] = False
405
401
  turn["error"] = ""
406
402
  return
407
403
 
408
- self._apply_spinner_event(kind, payload)
404
+ self._apply_spinner_event(event)
409
405
  if turn is None:
410
406
  return
411
407
 
412
- if kind == "assistant_delta":
408
+ if isinstance(event, AssistantDeltaEvent):
413
409
  turn["status"] = "responding"
414
- delta = str(payload.get("delta") or "")
410
+ delta = event.visualize()
415
411
  self._stream_buffer += delta
416
412
  if turn.get("_thinking_active"):
417
413
  turn["thinking"] = str(turn.get("thinking") or "") + delta
@@ -420,19 +416,19 @@ class WebSessionView:
420
416
  turn["_thinking_active"] = True
421
417
  return
422
418
 
423
- if kind == "tool_started":
419
+ if isinstance(event, ToolStartedEvent):
424
420
  turn["status"] = "tool"
425
- turn["tool_name"] = str(payload.get("tool_name") or "")
421
+ turn["tool_name"] = event.call.name
426
422
  turn["_thinking_active"] = False
427
423
  return
428
424
 
429
- if kind == "tool_completed":
425
+ if isinstance(event, ToolCompletedEvent):
430
426
  turn["_thinking_active"] = False
431
427
  turn["status"] = "running"
432
428
  return
433
429
 
434
- if kind == "turn_completed":
435
- response = str(payload.get("output_text") or "")
430
+ if isinstance(event, TurnCompletedEvent):
431
+ response = event.visualize()
436
432
  if response:
437
433
  turn["response"] = response
438
434
  elif turn.get("thinking"):
@@ -443,89 +439,77 @@ class WebSessionView:
443
439
  self._stream_buffer = ""
444
440
  return
445
441
 
446
- if kind in {"turn_failed", "submission_failed"}:
442
+ if isinstance(event, TurnFailedEvent):
447
443
  turn["status"] = "error"
448
- turn["error"] = str(payload.get("error") or kind)
444
+ turn["error"] = event.visualize()
449
445
  self._stream_buffer = ""
450
446
  return
451
447
 
452
- if kind in {"turn_interrupted", "submission_cancelled"}:
453
- 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"):
454
452
  turn["response"] = str(turn.get("thinking") or "")
455
453
  turn["thinking"] = ""
456
454
  turn["_thinking_active"] = False
457
455
  turn["status"] = "interrupted"
458
456
  self._stream_buffer = ""
459
457
 
460
- def _update_context_window(self, usage: "object") -> None:
461
- if self._context_window_tokens is None:
462
- return
463
- if not isinstance(usage, dict):
464
- self._context_remaining_percent = None
465
- return
466
- try:
467
- total_tokens = int(usage["total_tokens"])
468
- except (KeyError, TypeError, ValueError):
469
- self._context_remaining_percent = None
470
- return
471
- self._context_remaining_percent = percent_of_context_window_remaining(
472
- total_tokens,
473
- self._context_window_tokens,
474
- )
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
+ }
475
479
 
476
- def _apply_spinner_event(
477
- self,
478
- kind: str,
479
- payload: "typing.Dict[str, object]",
480
- ) -> None:
481
- if kind == "assistant_delta":
480
+ def _apply_spinner_event(self, event: "TurnEvent") -> None:
481
+ if isinstance(event, AssistantDeltaEvent):
482
482
  self._set_spinner_status("talking")
483
483
  return
484
- if kind == "stream_error":
484
+ if isinstance(event, StreamErrorEvent):
485
485
  self._set_spinner_status("reconnecting")
486
486
  return
487
- if kind == "auto_compact_started":
487
+ if isinstance(event, (AutoCompactStartedEvent, CompactStartedEvent)):
488
488
  self._set_spinner_status("compacting")
489
489
  return
490
- if kind == "auto_compact_completed":
490
+ if isinstance(event, AutoCompactCompletedEvent):
491
491
  self._set_spinner_status("compacted")
492
492
  return
493
- if kind == "tool_started":
494
- tool_name = str(payload.get("tool_name") or "").strip()
495
- call = payload.get("call")
496
- if tool_name and isinstance(call, ToolCall):
497
- self._set_spinner_status(
498
- shorten_title(
499
- "calling {0}({1})".format(tool_name, call.arguments),
500
- limit=SPINNER_STATUS_PREVIEW_LIMIT,
501
- )
493
+ if isinstance(event, ToolStartedEvent):
494
+ self._set_spinner_status(
495
+ shorten_title(
496
+ event.visualize(),
497
+ limit=SPINNER_STATUS_PREVIEW_LIMIT,
502
498
  )
503
- elif tool_name:
504
- self._set_spinner_status("calling {0}".format(tool_name))
505
- else:
506
- self._set_spinner_status("calling provider tools")
507
- return
508
- if kind == "tool_completed":
509
- tool_name = str(payload.get("tool_name") or "").strip()
510
- if tool_name:
511
- self._set_spinner_status("called {0}".format(tool_name))
499
+ )
512
500
  return
513
- if kind in {"turn_completed", "turn_failed", "turn_interrupted"}:
514
- self._set_idle_spinner_status(payload)
501
+ if isinstance(event, ToolCompletedEvent):
502
+ self._set_spinner_status("called {0}".format(event.call.name))
515
503
  return
516
- if kind == "submission_failed":
517
- self._set_spinner_status("")
504
+ if isinstance(event, TerminalEvent):
505
+ self._set_idle_spinner_status(event)
518
506
 
519
507
  def _set_spinner_status(self, text: "typing.Union[str, None]") -> None:
520
508
  self._spinner_status = str(text or "").strip()
521
509
 
522
- def _set_idle_spinner_status(self, payload: "typing.Dict[str, object]") -> None:
523
- try:
524
- background_work_count = int(payload.get("background_exec_count", 0))
525
- except (TypeError, ValueError):
526
- background_work_count = 0
527
- if background_work_count > 0:
528
- self._set_spinner_status(IDLE_LISTENING_STATUS)
510
+ def _set_idle_spinner_status(self, event: "TerminalEvent") -> None:
511
+ if (event.background_work_count or 0) > 0:
512
+ self._set_spinner_status(IDLE_SLEEPING_STATUS)
529
513
  else:
530
514
  self._set_spinner_status("")
531
515
 
@@ -536,14 +520,14 @@ class WebSessionView:
536
520
  prompt: str,
537
521
  ) -> "typing.Dict[str, object]":
538
522
  submission_id = str(submission_id or "").strip()
539
- turn_id = str(turn_id or submission_id).strip()
523
+ turn_id = str(turn_id or "").strip()
540
524
  turn = self._turns_by_submission_id.get(submission_id)
541
525
  if turn is None and turn_id and not submission_id:
542
526
  turn = self._turns_by_turn_id.get(turn_id)
543
527
  if turn is None:
544
528
  turn = {
545
529
  "submission_id": submission_id,
546
- "turn_id": turn_id,
530
+ "turn_id": "",
547
531
  "prompt": prompt,
548
532
  "response": "",
549
533
  "thinking": "",
@@ -554,11 +538,13 @@ class WebSessionView:
554
538
  "queue": "steer",
555
539
  "sender": "web",
556
540
  }
557
- self._turns.append(turn)
558
541
  if submission_id:
559
542
  turn["submission_id"] = submission_id
560
543
  self._turns_by_submission_id[submission_id] = turn
561
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)
562
548
  turn["turn_id"] = turn_id
563
549
  self._turns_by_turn_id[turn_id] = turn
564
550
  if prompt:
@@ -616,58 +602,56 @@ class WebSessionView:
616
602
 
617
603
  loop.call_soon_threadsafe(publish)
618
604
 
619
- async def _publish(self, event: "typing.Dict[str, object]") -> None:
620
- self._publish_nowait(event)
621
-
622
605
 
623
606
  class WorkspaceInteractiveSession:
624
607
  def __init__(
625
608
  self,
626
- queue,
609
+ runtime,
627
610
  config_path: "typing.Union[str, None]" = None,
628
611
  ) -> None:
629
- self.queue = queue
612
+ self.runtime = runtime
630
613
  self.config_path = config_path
631
614
  self.view = WebSessionView()
632
- self._task: "typing.Union[asyncio.Task[int], None]" = None
615
+ self._frontend_id = None
633
616
 
634
617
  async def start(self) -> "WorkspaceInteractiveSession":
635
- if self._task is None:
636
- self._task = asyncio.create_task(
637
- run_interactive_session(
638
- self.queue,
639
- False,
640
- self.config_path,
641
- view=self.view,
642
- show_banner=False,
643
- )
644
- )
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)
645
621
  return self
646
622
 
647
623
  async def close(self) -> None:
648
- self.view.close()
649
- task = self._task
650
- if task is None:
651
- return
652
624
  try:
653
- await asyncio.wait_for(
654
- asyncio.shield(task),
655
- timeout=SESSION_CLOSE_TIMEOUT_SECONDS,
656
- )
657
- except asyncio.TimeoutError:
658
- cancel_current = getattr(self.queue, "cancel_current", None)
659
- if callable(cancel_current):
660
- cancel_current()
661
- task.cancel()
662
- await asyncio.gather(task, return_exceptions=True)
625
+ await self.runtime.close()
663
626
  finally:
664
- 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
+ }
665
648
 
666
- async def submit(self, prompt: str, sender: str = "web") -> "typing.Dict[str, object]":
667
- del sender
668
- result = await self.view.submit(prompt)
669
- result["snapshot"] = self.snapshot()
670
- 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()}
671
655
 
672
656
  def subscribe(self) -> "asyncio.Queue":
673
657
  return self.view.subscribe()
@@ -676,34 +660,23 @@ class WorkspaceInteractiveSession:
676
660
  self.view.unsubscribe(queue)
677
661
 
678
662
  def snapshot(self) -> "typing.Dict[str, object]":
679
- snapshot = self.view.snapshot()
680
- agent = getattr(self.queue, "_agent", None)
681
- snapshot["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
682
- return snapshot
663
+ return self.view.snapshot()
683
664
 
684
665
  def summary(self) -> "typing.Dict[str, object]":
685
- summary = self.view.summary()
686
- agent = getattr(self.queue, "_agent", None)
687
- summary["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
688
- return summary
666
+ return self.view.summary()
689
667
 
690
668
  def rollout_path(self) -> str:
691
- recorder = getattr(getattr(self.queue, "_agent", None), "_rollout_recorder", None)
692
- path = getattr(recorder, "rollout_path", None)
693
- return "" if path is None else str(path)
694
-
695
- async def restore_from_rollout(self, rollout_path: str, title: str = "") -> None:
696
- resumed = load_resumed_session_path(rollout_path, thread_name=title or None)
697
- agent = self.queue._agent
698
- agent.replace_history(resumed["history"])
699
- model_client = getattr(agent, "_model_client", None)
700
- if hasattr(model_client, "_session_id"):
701
- model_client._session_id = str(resumed["session_id"])
702
- agent.set_rollout_recorder(SessionRolloutRecorder.resume(resumed["rollout_path"]))
703
- self.view.load_session_history(
704
- str(title or resumed["title"]),
705
- tuple(resumed["turns"]),
706
- )
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)
707
680
 
708
681
 
709
682
  class ThreadedWorkspaceInteractiveSession:
@@ -719,7 +692,6 @@ class ThreadedWorkspaceInteractiveSession:
719
692
  self._thread: "typing.Union[threading.Thread, None]" = None
720
693
  self._worker_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
721
694
  self._ready = threading.Event()
722
- self._closed = threading.Event()
723
695
  self._startup_error: "typing.Union[BaseException, None]" = None
724
696
  self._session: "typing.Union[WorkspaceInteractiveSession, None]" = None
725
697
 
@@ -734,66 +706,76 @@ class ThreadedWorkspaceInteractiveSession:
734
706
  self._thread.start()
735
707
  await asyncio.to_thread(self._ready.wait)
736
708
  if self._startup_error is not None:
737
- 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
738
712
  return self
739
713
 
740
714
  def _thread_main(self) -> None:
741
715
  loop = asyncio.new_event_loop()
742
716
  self._worker_loop = loop
743
- self._view.attach_worker_loop(loop)
744
717
  asyncio.set_event_loop(loop)
745
718
  try:
746
719
  session = self._session_factory()
747
720
  session.view = self._view
748
721
  self._session = session
749
- 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
750
727
  self._ready.set()
751
728
  loop.run_forever()
752
729
  except BaseException as exc:
753
730
  self._startup_error = exc
754
731
  self._ready.set()
755
732
  finally:
756
- session = self._session
757
- if session is not None:
758
- try:
759
- loop.run_until_complete(session.close())
760
- except BaseException:
761
- pass
762
733
  pending = asyncio.all_tasks(loop)
763
734
  for task in pending:
764
735
  task.cancel()
765
736
  if pending:
766
- loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
737
+ loop.run_until_complete(
738
+ asyncio.gather(*pending, return_exceptions=True)
739
+ )
767
740
  asyncio.set_event_loop(None)
768
741
  loop.close()
769
- self._closed.set()
770
742
 
771
743
  async def close(self) -> None:
772
744
  session = self._session
773
745
  loop = self._worker_loop
774
- if session is not None and loop is not None and loop.is_running():
775
- future = asyncio.run_coroutine_threadsafe(session.close(), loop)
776
- try:
777
- await asyncio.wait_for(
778
- asyncio.wrap_future(future),
779
- 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)
780
750
  )
781
- except (asyncio.TimeoutError, RuntimeError):
782
- cancel_current = getattr(getattr(session, "queue", None), "cancel_current", None)
783
- if callable(cancel_current):
784
- cancel_current()
785
- if loop is not None and loop.is_running():
786
- loop.call_soon_threadsafe(loop.stop)
787
- thread = self._thread
788
- if thread is not None:
789
- await asyncio.to_thread(thread.join, SESSION_CLOSE_TIMEOUT_SECONDS + 1.0)
790
- self._thread = None
791
-
792
- async def submit(self, prompt: str, sender: str = "web") -> "typing.Dict[str, object]":
793
- del sender
794
- result = await self._view.submit(prompt)
795
- result["snapshot"] = self.snapshot()
796
- 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)
797
779
 
798
780
  def subscribe(self) -> "asyncio.Queue":
799
781
  return self._view.subscribe()
@@ -802,34 +784,26 @@ class ThreadedWorkspaceInteractiveSession:
802
784
  self._view.unsubscribe(queue)
803
785
 
804
786
  def snapshot(self) -> "typing.Dict[str, object]":
805
- snapshot = self._view.snapshot()
806
- session = self._session
807
- queue = getattr(session, "queue", None)
808
- agent = getattr(queue, "_agent", None)
809
- snapshot["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
810
- return snapshot
787
+ return self._view.snapshot()
811
788
 
812
789
  def summary(self) -> "typing.Dict[str, object]":
813
- summary = self._view.summary()
814
- session = self._session
815
- queue = getattr(session, "queue", None)
816
- agent = getattr(queue, "_agent", None)
817
- summary["model"] = getattr(getattr(agent, "_model_client", None), "model", "pycodex")
818
- return summary
790
+ return self._view.summary()
819
791
 
820
792
  def rollout_path(self) -> str:
821
793
  if self._session is None:
822
794
  return ""
823
795
  return self._session.rollout_path()
824
796
 
825
- 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:
826
800
  session = self._session
827
801
  loop = self._worker_loop
828
802
  if session is None or loop is None:
829
803
  return
830
804
 
831
805
  future = asyncio.run_coroutine_threadsafe(
832
- session.restore_from_rollout(rollout_path, title=title),
806
+ session.restore_from_rollout(rollout_path, title=title, fork=fork),
833
807
  loop,
834
808
  )
835
809
  await asyncio.wrap_future(future)
@@ -853,7 +827,7 @@ def create_app(
853
827
 
854
828
 
855
829
  def create_multi_workspace_app(
856
- registry: 'WorkspaceRegistry',
830
+ registry: "WorkspaceRegistry",
857
831
  password: "typing.Union[str, None]" = None,
858
832
  ) -> FastAPI:
859
833
  app = _create_lifespan_app(registry.start, registry.close)
@@ -877,11 +851,7 @@ def create_multi_workspace_app(
877
851
  entry = await registry.add_workspace(
878
852
  str(payload.get("name") or ""),
879
853
  work_dir=str(payload.get("dir") or "./"),
880
- board=(
881
- None
882
- if payload.get("board") in (None, "")
883
- else str(payload.get("board"))
884
- ),
854
+ board=payload.get("board"),
885
855
  )
886
856
  except ValueError as exc:
887
857
  return JSONResponse({"ok": False, "error": str(exc)}, status_code=400)
@@ -943,7 +913,9 @@ def create_multi_workspace_app(
943
913
  return await _new_session_response(entry.manager)
944
914
 
945
915
  @app.delete("/w/{workspace_id}/api/sessions/{session_id}")
946
- 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:
947
919
  entry = _workspace_entry_or_404(registry, workspace_id)
948
920
  return await _delete_session_response(entry.manager, session_id)
949
921
 
@@ -964,8 +936,12 @@ def create_multi_workspace_app(
964
936
  return await _message_response(entry.manager, payload)
965
937
 
966
938
  @app.websocket("/w/{workspace_id}/ws/session")
967
- async def workspace_websocket_session(workspace_id: str, websocket: WebSocket) -> None:
968
- 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
+ ):
969
945
  await websocket.close(code=1008)
970
946
  return
971
947
  try:
@@ -1005,20 +981,35 @@ def _install_auth(app: FastAPI, password: "typing.Union[str, None]") -> str:
1005
981
  {"ok": False, "error": "authentication required"},
1006
982
  status_code=401,
1007
983
  )
1008
- 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
+ )
1009
988
 
1010
989
  @app.get("/login")
1011
990
  async def login_page() -> HTMLResponse:
1012
991
  return _html_response(_render_login_shell())
1013
992
 
1014
993
  @app.post("/login")
1015
- async def login(payload: "typing.Dict[str, object]") -> JSONResponse:
1016
- 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
+ ):
1017
1000
  return JSONResponse(
1018
1001
  {"ok": False, "error": "invalid password"},
1019
1002
  status_code=401,
1020
1003
  )
1021
- 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})
1022
1013
  response.set_cookie(
1023
1014
  AUTH_COOKIE_NAME,
1024
1015
  token,
@@ -1096,13 +1087,14 @@ def _render_login_shell() -> str:
1096
1087
  form.addEventListener("submit", async function(event) {
1097
1088
  event.preventDefault();
1098
1089
  statusEl.textContent = "";
1099
- const response = await fetch("login", {
1090
+ const response = await fetch(window.location.pathname + window.location.search, {
1100
1091
  method: "POST",
1101
1092
  headers: {"Content-Type": "application/json"},
1102
1093
  body: JSON.stringify({password: passwordInput.value}),
1103
1094
  });
1104
1095
  if (response.ok) {
1105
- window.location.href = "/";
1096
+ const result = await response.json();
1097
+ window.location.href = result.redirect;
1106
1098
  return;
1107
1099
  }
1108
1100
  statusEl.textContent = "Invalid password";
@@ -1117,6 +1109,7 @@ def _create_lifespan_app(
1117
1109
  close: "typing.Callable[[], typing.Awaitable[None]]",
1118
1110
  ) -> FastAPI:
1119
1111
  if asynccontextmanager is not None:
1112
+
1120
1113
  @asynccontextmanager
1121
1114
  async def lifespan(_app):
1122
1115
  await start()
@@ -1136,6 +1129,7 @@ def _create_lifespan_app(
1136
1129
  @app.on_event("shutdown")
1137
1130
  async def shutdown() -> None:
1138
1131
  await close()
1132
+
1139
1133
  return app
1140
1134
 
1141
1135
 
@@ -1191,7 +1185,9 @@ def _install_workspace_routes(
1191
1185
  @app.websocket("/ws/session")
1192
1186
  async def websocket_session(websocket: WebSocket) -> None:
1193
1187
  auth_token = typing.cast(str, app.state.workspace_auth_token)
1194
- 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
+ ):
1195
1191
  await websocket.close(code=1008)
1196
1192
  return
1197
1193
  await _websocket_session_handler(manager, websocket)
@@ -1224,11 +1220,11 @@ def _websocket_backend_hint_response() -> JSONResponse:
1224
1220
  )
1225
1221
 
1226
1222
 
1227
- def _sessions_response(manager: 'WorkspaceSessionManager') -> JSONResponse:
1223
+ def _sessions_response(manager: "WorkspaceSessionManager") -> JSONResponse:
1228
1224
  return JSONResponse({"sessions": manager.list_sessions()})
1229
1225
 
1230
1226
 
1231
- async def _new_session_response(manager: 'WorkspaceSessionManager') -> JSONResponse:
1227
+ async def _new_session_response(manager: "WorkspaceSessionManager") -> JSONResponse:
1232
1228
  session_id = await manager.create_session()
1233
1229
  return JSONResponse(
1234
1230
  {
@@ -1241,7 +1237,7 @@ async def _new_session_response(manager: 'WorkspaceSessionManager') -> JSONRespo
1241
1237
 
1242
1238
 
1243
1239
  async def _delete_session_response(
1244
- manager: 'WorkspaceSessionManager',
1240
+ manager: "WorkspaceSessionManager",
1245
1241
  session_id: str,
1246
1242
  ) -> JSONResponse:
1247
1243
  try:
@@ -1254,7 +1250,7 @@ async def _delete_session_response(
1254
1250
 
1255
1251
 
1256
1252
  def _session_response(
1257
- manager: 'WorkspaceSessionManager',
1253
+ manager: "WorkspaceSessionManager",
1258
1254
  session_id: "typing.Union[str, None]" = None,
1259
1255
  ) -> JSONResponse:
1260
1256
  try:
@@ -1272,7 +1268,7 @@ def _session_response(
1272
1268
 
1273
1269
 
1274
1270
  async def _message_response(
1275
- manager: 'WorkspaceSessionManager',
1271
+ manager: "WorkspaceSessionManager",
1276
1272
  payload: "typing.Dict[str, object]",
1277
1273
  ) -> JSONResponse:
1278
1274
  session_id = str(payload.get("session_id") or "")
@@ -1280,7 +1276,13 @@ async def _message_response(
1280
1276
  link = manager.get(session_id or None)
1281
1277
  except KeyError:
1282
1278
  raise HTTPException(status_code=404, detail="session not found")
1283
- 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
+ )
1284
1286
  if isinstance(result, dict):
1285
1287
  result.setdefault("sessions", manager.list_sessions())
1286
1288
  status = 200 if result.get("ok") else 400
@@ -1288,7 +1290,7 @@ async def _message_response(
1288
1290
 
1289
1291
 
1290
1292
  async def _websocket_session_handler(
1291
- manager: 'WorkspaceSessionManager',
1293
+ manager: "WorkspaceSessionManager",
1292
1294
  websocket: WebSocket,
1293
1295
  ) -> None:
1294
1296
  await websocket.accept()
@@ -1309,7 +1311,7 @@ async def _websocket_session_handler(
1309
1311
  await websocket.send_json({"type": "error", "error": "invalid json"})
1310
1312
  continue
1311
1313
  action = str(payload.get("type") or payload.get("action") or "")
1312
- if action == "send":
1314
+ if action in {"send", "answer"}:
1313
1315
  target_session_id = str(payload.get("session_id") or session_id or "")
1314
1316
  try:
1315
1317
  target_link = manager.get(target_session_id or None)
@@ -1318,10 +1320,16 @@ async def _websocket_session_handler(
1318
1320
  {"type": "error", "error": "session not found"}
1319
1321
  )
1320
1322
  continue
1321
- result = await target_link.submit(
1322
- str(payload.get("prompt") or ""),
1323
- sender=str(payload.get("sender") or "web"),
1324
- )
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
+ )
1325
1333
  await websocket.send_json({"type": "send_result", "result": result})
1326
1334
  elif action == "ping":
1327
1335
  await websocket.send_json({"type": "pong"})
@@ -1365,6 +1373,58 @@ def _public_turn(turn: "typing.Dict[str, object]") -> "typing.Dict[str, object]"
1365
1373
  )
1366
1374
 
1367
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
+
1368
1428
  def _json_safe(value: object) -> "JSONValue":
1369
1429
  if value is None or isinstance(value, (bool, int, float, str)):
1370
1430
  return value
@@ -1372,6 +1432,8 @@ def _json_safe(value: object) -> "JSONValue":
1372
1432
  return [_json_safe(item) for item in value]
1373
1433
  if isinstance(value, dict):
1374
1434
  return {str(key): _json_safe(item) for key, item in value.items()}
1435
+ if isinstance(value, InputRequestedEvent):
1436
+ return _event_data(value)["payload"]
1375
1437
  if is_dataclass(value):
1376
1438
  return _json_safe(asdict(value))
1377
1439
  try:
@@ -1420,10 +1482,9 @@ def _board_asset_response(
1420
1482
  try:
1421
1483
  board_directory = board_path.parent.resolve()
1422
1484
  resolved_asset = (board_directory / asset_path).resolve()
1423
- within_board_directory = (
1424
- os.path.commonpath([str(board_directory), str(resolved_asset)])
1425
- == str(board_directory)
1426
- )
1485
+ within_board_directory = os.path.commonpath(
1486
+ [str(board_directory), str(resolved_asset)]
1487
+ ) == str(board_directory)
1427
1488
  except (OSError, RuntimeError, ValueError):
1428
1489
  within_board_directory = False
1429
1490
 
@@ -1445,9 +1506,9 @@ def _board_asset_response(
1445
1506
 
1446
1507
 
1447
1508
  def _workspace_entry_or_404(
1448
- registry: 'WorkspaceRegistry',
1509
+ registry: "WorkspaceRegistry",
1449
1510
  workspace_id: str,
1450
- ) -> 'WorkspaceEntry':
1511
+ ) -> "WorkspaceEntry":
1451
1512
  try:
1452
1513
  return registry.get(workspace_id)
1453
1514
  except (KeyError, ValueError):
@@ -1461,10 +1522,7 @@ def run_serve_cli(args: "argparse.Namespace") -> int:
1461
1522
  configure_loguru()
1462
1523
 
1463
1524
  definitions = load_workspace_definitions(args.workspace_config)
1464
- entries = [
1465
- _build_workspace_entry(definition, args)
1466
- for definition in definitions
1467
- ]
1525
+ entries = [_build_workspace_entry(definition, args) for definition in definitions]
1468
1526
  registry = WorkspaceRegistry(
1469
1527
  entries,
1470
1528
  config_path=args.workspace_config,
@@ -1496,10 +1554,10 @@ def run_serve_cli(args: "argparse.Namespace") -> int:
1496
1554
 
1497
1555
 
1498
1556
  def _build_workspace_entry(
1499
- definition: 'WorkspaceDefinition',
1557
+ definition: "WorkspaceDefinition",
1500
1558
  args: "argparse.Namespace",
1501
1559
  persist_callback: "typing.Union[typing.Callable[[], None], None]" = None,
1502
- ) -> 'WorkspaceEntry':
1560
+ ) -> "WorkspaceEntry":
1503
1561
  def build_session() -> "WorkspaceInteractiveSession":
1504
1562
  model = build_model(
1505
1563
  config_path=args.config,
@@ -1514,21 +1572,23 @@ def _build_workspace_entry(
1514
1572
  config_path=args.config,
1515
1573
  profile=args.profile,
1516
1574
  system_prompt=args.system_prompt,
1517
- session_mode="tui",
1518
1575
  extra_contextual_user_messages=(
1519
1576
  [_board_context_text(definition.board_path, definition.work_dir)]
1520
1577
  if definition.board_path is not None
1521
1578
  else []
1522
1579
  ),
1523
1580
  cwd=definition.work_dir,
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: