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