codex-autorunner 1.2.0__py3-none-any.whl → 1.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 (67) hide show
  1. codex_autorunner/bootstrap.py +26 -5
  2. codex_autorunner/core/about_car.py +12 -12
  3. codex_autorunner/core/config.py +178 -61
  4. codex_autorunner/core/context_awareness.py +1 -0
  5. codex_autorunner/core/filesystem.py +24 -0
  6. codex_autorunner/core/flows/controller.py +50 -12
  7. codex_autorunner/core/flows/runtime.py +8 -3
  8. codex_autorunner/core/hub.py +293 -16
  9. codex_autorunner/core/lifecycle_events.py +44 -5
  10. codex_autorunner/core/pma_context.py +188 -1
  11. codex_autorunner/core/pma_delivery.py +81 -0
  12. codex_autorunner/core/pma_dispatches.py +224 -0
  13. codex_autorunner/core/pma_lane_worker.py +122 -0
  14. codex_autorunner/core/pma_queue.py +167 -18
  15. codex_autorunner/core/pma_reactive.py +91 -0
  16. codex_autorunner/core/pma_safety.py +58 -0
  17. codex_autorunner/core/pma_sink.py +104 -0
  18. codex_autorunner/core/pma_transcripts.py +183 -0
  19. codex_autorunner/core/safe_paths.py +117 -0
  20. codex_autorunner/housekeeping.py +77 -23
  21. codex_autorunner/integrations/agents/codex_backend.py +18 -12
  22. codex_autorunner/integrations/agents/wiring.py +2 -0
  23. codex_autorunner/integrations/app_server/client.py +31 -0
  24. codex_autorunner/integrations/app_server/supervisor.py +3 -0
  25. codex_autorunner/integrations/telegram/adapter.py +1 -1
  26. codex_autorunner/integrations/telegram/config.py +1 -1
  27. codex_autorunner/integrations/telegram/constants.py +1 -1
  28. codex_autorunner/integrations/telegram/handlers/commands/execution.py +16 -15
  29. codex_autorunner/integrations/telegram/handlers/commands/files.py +5 -8
  30. codex_autorunner/integrations/telegram/handlers/commands/github.py +10 -6
  31. codex_autorunner/integrations/telegram/handlers/commands/shared.py +9 -8
  32. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +85 -2
  33. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +29 -8
  34. codex_autorunner/integrations/telegram/handlers/messages.py +8 -2
  35. codex_autorunner/integrations/telegram/helpers.py +30 -2
  36. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +54 -3
  37. codex_autorunner/static/archive.js +274 -81
  38. codex_autorunner/static/archiveApi.js +21 -0
  39. codex_autorunner/static/constants.js +1 -1
  40. codex_autorunner/static/docChatCore.js +2 -0
  41. codex_autorunner/static/hub.js +59 -0
  42. codex_autorunner/static/index.html +70 -54
  43. codex_autorunner/static/notificationBell.js +173 -0
  44. codex_autorunner/static/notifications.js +187 -36
  45. codex_autorunner/static/pma.js +96 -35
  46. codex_autorunner/static/styles.css +431 -4
  47. codex_autorunner/static/terminalManager.js +22 -3
  48. codex_autorunner/static/utils.js +5 -1
  49. codex_autorunner/surfaces/cli/cli.py +206 -129
  50. codex_autorunner/surfaces/cli/template_repos.py +157 -0
  51. codex_autorunner/surfaces/web/app.py +193 -5
  52. codex_autorunner/surfaces/web/routes/archive.py +197 -0
  53. codex_autorunner/surfaces/web/routes/file_chat.py +115 -87
  54. codex_autorunner/surfaces/web/routes/flows.py +125 -67
  55. codex_autorunner/surfaces/web/routes/pma.py +638 -57
  56. codex_autorunner/surfaces/web/schemas.py +11 -0
  57. codex_autorunner/tickets/agent_pool.py +6 -1
  58. codex_autorunner/tickets/outbox.py +27 -14
  59. codex_autorunner/tickets/replies.py +4 -10
  60. codex_autorunner/tickets/runner.py +1 -0
  61. codex_autorunner/workspace/paths.py +8 -3
  62. {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/METADATA +1 -1
  63. {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/RECORD +67 -57
  64. {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/WHEEL +0 -0
  65. {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/entry_points.txt +0 -0
  66. {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/licenses/LICENSE +0 -0
  67. {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/top_level.txt +0 -0
@@ -8,6 +8,7 @@ import asyncio
8
8
  import hashlib
9
9
  import json
10
10
  import logging
11
+ import uuid
11
12
  from datetime import datetime, timezone
12
13
  from pathlib import Path
13
14
  from typing import Any, Optional
@@ -38,12 +39,26 @@ from ....core.pma_context import (
38
39
  format_pma_prompt,
39
40
  load_pma_prompt,
40
41
  )
42
+ from ....core.pma_delivery import deliver_pma_output_to_active_sink
43
+ from ....core.pma_dispatches import (
44
+ find_pma_dispatch_path,
45
+ list_pma_dispatches,
46
+ list_pma_dispatches_for_turn,
47
+ resolve_pma_dispatch,
48
+ )
49
+ from ....core.pma_lane_worker import PmaLaneWorker
41
50
  from ....core.pma_lifecycle import PmaLifecycleRouter
42
51
  from ....core.pma_queue import PmaQueue, QueueItemState
43
52
  from ....core.pma_safety import PmaSafetyChecker, PmaSafetyConfig
53
+ from ....core.pma_sink import PmaActiveSinkStore
44
54
  from ....core.pma_state import PmaStateStore
55
+ from ....core.pma_transcripts import PmaTranscriptStore
45
56
  from ....core.time_utils import now_iso
46
57
  from ....core.utils import atomic_write
58
+ from ....integrations.telegram.adapter import chunk_message
59
+ from ....integrations.telegram.config import DEFAULT_STATE_FILE
60
+ from ....integrations.telegram.constants import TELEGRAM_MAX_MESSAGE_LENGTH
61
+ from ....integrations.telegram.state import OutboxRecord, TelegramStateStore
47
62
  from .agents import _available_agents, _serialize_model_catalog
48
63
  from .shared import SSE_HEADERS
49
64
 
@@ -69,8 +84,7 @@ def build_pma_routes() -> APIRouter:
69
84
  pma_audit_log: Optional[PmaAuditLog] = None
70
85
  pma_queue: Optional[PmaQueue] = None
71
86
  pma_queue_root: Optional[Path] = None
72
- lane_workers: dict[str, asyncio.Task] = {}
73
- lane_cancel_events: dict[str, asyncio.Event] = {}
87
+ lane_workers: dict[str, PmaLaneWorker] = {}
74
88
  item_futures: dict[str, asyncio.Future[dict[str, Any]]] = {}
75
89
 
76
90
  def _normalize_optional_text(value: Any) -> Optional[str]:
@@ -127,6 +141,12 @@ def build_pma_routes() -> APIRouter:
127
141
  def _get_safety_checker(request: Request) -> PmaSafetyChecker:
128
142
  nonlocal pma_safety_checker, pma_safety_root, pma_audit_log
129
143
  hub_root = request.app.state.config.root
144
+ supervisor = getattr(request.app.state, "hub_supervisor", None)
145
+ if supervisor is not None:
146
+ try:
147
+ return supervisor.get_pma_safety_checker()
148
+ except Exception:
149
+ pass
130
150
  if pma_safety_checker is None or pma_safety_root != hub_root:
131
151
  raw = getattr(request.app.state.config, "raw", {})
132
152
  pma_config = raw.get("pma", {}) if isinstance(raw, dict) else {}
@@ -160,6 +180,116 @@ def build_pma_routes() -> APIRouter:
160
180
  pma_queue_root = hub_root
161
181
  return pma_queue
162
182
 
183
+ def _resolve_telegram_state_path(request: Request) -> Path:
184
+ hub_root = request.app.state.config.root
185
+ raw = getattr(request.app.state.config, "raw", {})
186
+ telegram_cfg = raw.get("telegram_bot") if isinstance(raw, dict) else {}
187
+ if not isinstance(telegram_cfg, dict):
188
+ telegram_cfg = {}
189
+ state_file = telegram_cfg.get("state_file")
190
+ if not isinstance(state_file, str) or not state_file.strip():
191
+ state_file = DEFAULT_STATE_FILE
192
+ state_path = Path(state_file)
193
+ if not state_path.is_absolute():
194
+ state_path = (hub_root / state_path).resolve()
195
+ return state_path
196
+
197
+ async def _deliver_to_active_sink(
198
+ *,
199
+ request: Request,
200
+ result: dict[str, Any],
201
+ current: dict[str, Any],
202
+ lifecycle_event: Optional[dict[str, Any]],
203
+ turn_id: Optional[str] = None,
204
+ ) -> None:
205
+ if not lifecycle_event:
206
+ return
207
+ status = result.get("status") or "error"
208
+ if status != "ok":
209
+ return
210
+ assistant_text = _resolve_transcript_text(result)
211
+ if not assistant_text.strip():
212
+ return
213
+
214
+ hub_root = request.app.state.config.root
215
+ if not isinstance(turn_id, str) or not turn_id:
216
+ turn_id = _resolve_transcript_turn_id(result, current)
217
+ state_path = _resolve_telegram_state_path(request)
218
+ await deliver_pma_output_to_active_sink(
219
+ hub_root=hub_root,
220
+ assistant_text=assistant_text,
221
+ turn_id=turn_id,
222
+ lifecycle_event=lifecycle_event,
223
+ telegram_state_path=state_path,
224
+ )
225
+
226
+ async def _deliver_dispatches_to_active_sink(
227
+ *,
228
+ request: Request,
229
+ turn_id: Optional[str],
230
+ ) -> None:
231
+ if not isinstance(turn_id, str) or not turn_id:
232
+ return
233
+ hub_root = request.app.state.config.root
234
+ dispatches = list_pma_dispatches_for_turn(hub_root, turn_id)
235
+ if not dispatches:
236
+ return
237
+
238
+ sink_store = PmaActiveSinkStore(hub_root)
239
+ sink = sink_store.load()
240
+ if not isinstance(sink, dict) or sink.get("kind") != "telegram":
241
+ return
242
+
243
+ chat_id = sink.get("chat_id")
244
+ thread_id = sink.get("thread_id")
245
+ if not isinstance(chat_id, int):
246
+ return
247
+ if thread_id is not None and not isinstance(thread_id, int):
248
+ thread_id = None
249
+
250
+ state_path = _resolve_telegram_state_path(request)
251
+ store = TelegramStateStore(state_path)
252
+ try:
253
+ for dispatch in dispatches:
254
+ title = dispatch.title or "PMA dispatch"
255
+ priority = dispatch.priority or "info"
256
+ header = f"**PMA dispatch** ({priority})\n{title}"
257
+ body = dispatch.body.strip()
258
+ link_lines = []
259
+ for link in dispatch.links:
260
+ label = link.get("label", "")
261
+ href = link.get("href", "")
262
+ if label and href:
263
+ link_lines.append(f"- {label}: {href}")
264
+ details = "\n".join(
265
+ line for line in [body, "\n".join(link_lines)] if line
266
+ ).strip()
267
+ message = header
268
+ if details:
269
+ message = f"{header}\n\n{details}"
270
+
271
+ chunks = chunk_message(
272
+ message, max_len=TELEGRAM_MAX_MESSAGE_LENGTH, with_numbering=True
273
+ )
274
+ for idx, chunk in enumerate(chunks, 1):
275
+ record_id = f"pma-dispatch:{dispatch.dispatch_id}:{idx}"
276
+ record = OutboxRecord(
277
+ record_id=record_id,
278
+ chat_id=chat_id,
279
+ thread_id=thread_id,
280
+ reply_to_message_id=None,
281
+ placeholder_message_id=None,
282
+ text=chunk,
283
+ created_at=now_iso(),
284
+ operation="send",
285
+ outbox_key=record_id,
286
+ )
287
+ await store.enqueue_outbox(record)
288
+ except Exception:
289
+ logger.exception("Failed to enqueue PMA dispatch to Telegram outbox")
290
+ finally:
291
+ await store.close()
292
+
163
293
  async def _persist_state(store: Optional[PmaStateStore]) -> None:
164
294
  if store is None:
165
295
  return
@@ -207,6 +337,104 @@ def build_pma_routes() -> APIRouter:
207
337
  "finished_at": now_iso(),
208
338
  }
209
339
 
340
+ def _resolve_transcript_turn_id(
341
+ result: dict[str, Any], current: dict[str, Any]
342
+ ) -> str:
343
+ for candidate in (
344
+ result.get("turn_id"),
345
+ current.get("turn_id"),
346
+ current.get("client_turn_id"),
347
+ ):
348
+ if isinstance(candidate, str) and candidate.strip():
349
+ return candidate.strip()
350
+ return f"local-{uuid.uuid4()}"
351
+
352
+ def _resolve_transcript_text(result: dict[str, Any]) -> str:
353
+ message = result.get("message")
354
+ if isinstance(message, str) and message.strip():
355
+ return message
356
+ detail = result.get("detail")
357
+ if isinstance(detail, str) and detail.strip():
358
+ return detail
359
+ return ""
360
+
361
+ def _build_transcript_metadata(
362
+ *,
363
+ result: dict[str, Any],
364
+ current: dict[str, Any],
365
+ prompt_message: Optional[str],
366
+ lifecycle_event: Optional[dict[str, Any]],
367
+ model: Optional[str],
368
+ reasoning: Optional[str],
369
+ duration_ms: Optional[int],
370
+ finished_at: str,
371
+ ) -> dict[str, Any]:
372
+ trigger = "lifecycle_event" if lifecycle_event else "user_prompt"
373
+ metadata: dict[str, Any] = {
374
+ "status": result.get("status") or "error",
375
+ "agent": current.get("agent"),
376
+ "thread_id": result.get("thread_id") or current.get("thread_id"),
377
+ "turn_id": _resolve_transcript_turn_id(result, current),
378
+ "client_turn_id": current.get("client_turn_id") or "",
379
+ "lane_id": current.get("lane_id") or "",
380
+ "trigger": trigger,
381
+ "model": model,
382
+ "reasoning": reasoning,
383
+ "started_at": current.get("started_at"),
384
+ "finished_at": finished_at,
385
+ "duration_ms": duration_ms,
386
+ "user_prompt": prompt_message or "",
387
+ }
388
+ if lifecycle_event:
389
+ metadata["lifecycle_event"] = dict(lifecycle_event)
390
+ metadata["event_id"] = lifecycle_event.get("event_id")
391
+ metadata["event_type"] = lifecycle_event.get("event_type")
392
+ metadata["repo_id"] = lifecycle_event.get("repo_id")
393
+ metadata["run_id"] = lifecycle_event.get("run_id")
394
+ metadata["event_timestamp"] = lifecycle_event.get("timestamp")
395
+ return metadata
396
+
397
+ async def _persist_transcript(
398
+ *,
399
+ request: Request,
400
+ result: dict[str, Any],
401
+ current: dict[str, Any],
402
+ prompt_message: Optional[str],
403
+ lifecycle_event: Optional[dict[str, Any]],
404
+ model: Optional[str],
405
+ reasoning: Optional[str],
406
+ duration_ms: Optional[int],
407
+ finished_at: str,
408
+ ) -> Optional[dict[str, Any]]:
409
+ hub_root = request.app.state.config.root
410
+ store = PmaTranscriptStore(hub_root)
411
+ assistant_text = _resolve_transcript_text(result)
412
+ metadata = _build_transcript_metadata(
413
+ result=result,
414
+ current=current,
415
+ prompt_message=prompt_message,
416
+ lifecycle_event=lifecycle_event,
417
+ model=model,
418
+ reasoning=reasoning,
419
+ duration_ms=duration_ms,
420
+ finished_at=finished_at,
421
+ )
422
+ try:
423
+ pointer = store.write_transcript(
424
+ turn_id=metadata["turn_id"],
425
+ metadata=metadata,
426
+ assistant_text=assistant_text,
427
+ )
428
+ except Exception:
429
+ logger.exception("Failed to write PMA transcript")
430
+ return None
431
+ return {
432
+ "turn_id": pointer.turn_id,
433
+ "metadata_path": pointer.metadata_path,
434
+ "content_path": pointer.content_path,
435
+ "created_at": pointer.created_at,
436
+ }
437
+
210
438
  async def _get_interrupt_event() -> asyncio.Event:
211
439
  nonlocal pma_event
212
440
  async with pma_lock:
@@ -265,6 +493,10 @@ def build_pma_routes() -> APIRouter:
265
493
  *,
266
494
  request: Request,
267
495
  store: Optional[PmaStateStore] = None,
496
+ prompt_message: Optional[str] = None,
497
+ lifecycle_event: Optional[dict[str, Any]] = None,
498
+ model: Optional[str] = None,
499
+ reasoning: Optional[str] = None,
268
500
  ) -> None:
269
501
  nonlocal pma_current, pma_last_result, pma_active, pma_event
270
502
  async with pma_lock:
@@ -277,6 +509,7 @@ def build_pma_routes() -> APIRouter:
277
509
  status = result.get("status") or "error"
278
510
  started_at = current_snapshot.get("started_at")
279
511
  duration_ms = None
512
+ finished_at = now_iso()
280
513
  if started_at:
281
514
  try:
282
515
  start_dt = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
@@ -286,6 +519,23 @@ def build_pma_routes() -> APIRouter:
286
519
  except Exception:
287
520
  pass
288
521
 
522
+ transcript_pointer = await _persist_transcript(
523
+ request=request,
524
+ result=result,
525
+ current=current_snapshot,
526
+ prompt_message=prompt_message,
527
+ lifecycle_event=lifecycle_event,
528
+ model=model,
529
+ reasoning=reasoning,
530
+ duration_ms=duration_ms,
531
+ finished_at=finished_at,
532
+ )
533
+ if transcript_pointer is not None:
534
+ pma_last_result = dict(pma_last_result or {})
535
+ pma_last_result["transcript"] = transcript_pointer
536
+ if not pma_last_result.get("turn_id"):
537
+ pma_last_result["turn_id"] = transcript_pointer.get("turn_id")
538
+
289
539
  log_event(
290
540
  logger,
291
541
  logging.INFO,
@@ -316,11 +566,33 @@ def build_pma_routes() -> APIRouter:
316
566
  status=status,
317
567
  error=result.get("detail") if status == "error" else None,
318
568
  )
569
+
570
+ delivery_turn_id = None
571
+ if isinstance(pma_last_result, dict):
572
+ candidate = pma_last_result.get("turn_id")
573
+ if isinstance(candidate, str) and candidate:
574
+ delivery_turn_id = candidate
575
+ await _deliver_to_active_sink(
576
+ request=request,
577
+ result=result,
578
+ current=current_snapshot,
579
+ lifecycle_event=lifecycle_event,
580
+ turn_id=delivery_turn_id,
581
+ )
582
+ await _deliver_dispatches_to_active_sink(
583
+ request=request,
584
+ turn_id=delivery_turn_id,
585
+ )
319
586
  _get_safety_checker(request).record_chat_result(
320
587
  agent=current_snapshot.get("agent") or "",
321
588
  status=status,
322
589
  error=result.get("detail") if status == "error" else None,
323
590
  )
591
+ if lifecycle_event:
592
+ _get_safety_checker(request).record_reactive_result(
593
+ status=status,
594
+ error=result.get("detail") if status == "error" else None,
595
+ )
324
596
 
325
597
  await _persist_state(store)
326
598
 
@@ -376,43 +648,45 @@ def build_pma_routes() -> APIRouter:
376
648
  }
377
649
 
378
650
  async def _ensure_lane_worker(lane_id: str, request: Request) -> None:
379
- nonlocal lane_workers, lane_cancel_events
380
- if lane_id in lane_workers and not lane_workers[lane_id].done():
651
+ nonlocal lane_workers
652
+ existing = lane_workers.get(lane_id)
653
+ if existing is not None and existing.is_running:
381
654
  return
382
655
 
383
- cancel_event = asyncio.Event()
384
- lane_cancel_events[lane_id] = cancel_event
656
+ async def _on_result(item, result: dict[str, Any]) -> None:
657
+ result_future = item_futures.get(item.item_id)
658
+ if result_future and not result_future.done():
659
+ result_future.set_result(result)
660
+ item_futures.pop(item.item_id, None)
385
661
 
386
- async def lane_worker():
387
- queue = _get_pma_queue(request)
388
- await queue.replay_pending(lane_id)
389
- while not cancel_event.is_set():
390
- item = await queue.dequeue(lane_id)
391
- if item is None:
392
- await queue.wait_for_lane_item(lane_id, cancel_event)
393
- continue
662
+ queue = _get_pma_queue(request)
663
+ worker = PmaLaneWorker(
664
+ lane_id,
665
+ queue,
666
+ lambda item: _execute_queue_item(item, request),
667
+ log=logger,
668
+ on_result=_on_result,
669
+ )
670
+ lane_workers[lane_id] = worker
671
+ await worker.start()
394
672
 
395
- if cancel_event.is_set():
396
- await queue.fail_item(item, "cancelled by lane stop")
397
- continue
673
+ async def _stop_lane_worker(lane_id: str) -> None:
674
+ worker = lane_workers.get(lane_id)
675
+ if worker is None:
676
+ return
677
+ await worker.stop()
678
+ lane_workers.pop(lane_id, None)
398
679
 
399
- result_future = item_futures.get(item.item_id)
400
- try:
401
- result = await _execute_queue_item(item, request)
402
- await queue.complete_item(item, result)
403
- if result_future and not result_future.done():
404
- result_future.set_result(result)
405
- except Exception as exc:
406
- logger.exception("Failed to process queue item %s", item.item_id)
407
- error_result = {"status": "error", "detail": str(exc)}
408
- await queue.fail_item(item, str(exc))
409
- if result_future and not result_future.done():
410
- result_future.set_result(error_result)
411
- finally:
412
- item_futures.pop(item.item_id, None)
413
-
414
- task = asyncio.create_task(lane_worker())
415
- lane_workers[lane_id] = task
680
+ class _AppRequest:
681
+ def __init__(self, app: Any) -> None:
682
+ self.app = app
683
+
684
+ async def _ensure_lane_worker_for_app(app: Any, lane_id: str) -> None:
685
+ await _ensure_lane_worker(lane_id, _AppRequest(app))
686
+
687
+ async def _stop_lane_worker_for_app(app: Any, lane_id: str) -> None:
688
+ _ = app
689
+ await _stop_lane_worker(lane_id)
416
690
 
417
691
  async def _execute_queue_item(item: Any, request: Request) -> dict[str, Any]:
418
692
  hub_root = request.app.state.config.root
@@ -423,6 +697,9 @@ def build_pma_routes() -> APIRouter:
423
697
  agent = payload.get("agent")
424
698
  model = _normalize_optional_text(payload.get("model"))
425
699
  reasoning = _normalize_optional_text(payload.get("reasoning"))
700
+ lifecycle_event = payload.get("lifecycle_event")
701
+ if not isinstance(lifecycle_event, dict):
702
+ lifecycle_event = None
426
703
 
427
704
  store = _get_state_store(request)
428
705
  agents, available_default = _available_agents(request)
@@ -479,14 +756,30 @@ def build_pma_routes() -> APIRouter:
479
756
  "client_turn_id": client_turn_id or "",
480
757
  }
481
758
  if started:
482
- await _finalize_result(error_result, request=request, store=store)
759
+ await _finalize_result(
760
+ error_result,
761
+ request=request,
762
+ store=store,
763
+ prompt_message=message,
764
+ lifecycle_event=lifecycle_event,
765
+ model=model,
766
+ reasoning=reasoning,
767
+ )
483
768
  return error_result
484
769
 
485
770
  interrupt_event = await _get_interrupt_event()
486
771
  if interrupt_event.is_set():
487
772
  result = {"status": "interrupted", "detail": "PMA chat interrupted"}
488
773
  if started:
489
- await _finalize_result(result, request=request, store=store)
774
+ await _finalize_result(
775
+ result,
776
+ request=request,
777
+ store=store,
778
+ prompt_message=message,
779
+ lifecycle_event=lifecycle_event,
780
+ model=model,
781
+ reasoning=reasoning,
782
+ )
490
783
  return result
491
784
 
492
785
  meta_future: asyncio.Future[tuple[str, str]] = (
@@ -541,7 +834,15 @@ def build_pma_routes() -> APIRouter:
541
834
  if opencode is None:
542
835
  result = {"status": "error", "detail": "OpenCode unavailable"}
543
836
  if started:
544
- await _finalize_result(result, request=request, store=store)
837
+ await _finalize_result(
838
+ result,
839
+ request=request,
840
+ store=store,
841
+ prompt_message=message,
842
+ lifecycle_event=lifecycle_event,
843
+ model=model,
844
+ reasoning=reasoning,
845
+ )
545
846
  return result
546
847
  result = await _execute_opencode(
547
848
  opencode,
@@ -559,7 +860,15 @@ def build_pma_routes() -> APIRouter:
559
860
  if supervisor is None or events is None:
560
861
  result = {"status": "error", "detail": "App-server unavailable"}
561
862
  if started:
562
- await _finalize_result(result, request=request, store=store)
863
+ await _finalize_result(
864
+ result,
865
+ request=request,
866
+ store=store,
867
+ prompt_message=message,
868
+ lifecycle_event=lifecycle_event,
869
+ model=model,
870
+ reasoning=reasoning,
871
+ )
563
872
  return result
564
873
  result = await _execute_app_server(
565
874
  supervisor,
@@ -580,12 +889,28 @@ def build_pma_routes() -> APIRouter:
580
889
  "detail": str(exc),
581
890
  "client_turn_id": client_turn_id or "",
582
891
  }
583
- await _finalize_result(error_result, request=request, store=store)
892
+ await _finalize_result(
893
+ error_result,
894
+ request=request,
895
+ store=store,
896
+ prompt_message=message,
897
+ lifecycle_event=lifecycle_event,
898
+ model=model,
899
+ reasoning=reasoning,
900
+ )
584
901
  raise
585
902
 
586
903
  result = dict(result or {})
587
904
  result["client_turn_id"] = client_turn_id or ""
588
- await _finalize_result(result, request=request, store=store)
905
+ await _finalize_result(
906
+ result,
907
+ request=request,
908
+ store=store,
909
+ prompt_message=message,
910
+ lifecycle_event=lifecycle_event,
911
+ model=model,
912
+ reasoning=reasoning,
913
+ )
589
914
  return result
590
915
 
591
916
  @router.get("/active")
@@ -626,6 +951,28 @@ def build_pma_routes() -> APIRouter:
626
951
  current = {}
627
952
  return {"active": active, "current": current, "last_result": last_result}
628
953
 
954
+ @router.get("/history")
955
+ def list_pma_history(request: Request, limit: int = 50) -> dict[str, Any]:
956
+ pma_config = _get_pma_config(request)
957
+ if not pma_config.get("enabled", True):
958
+ raise HTTPException(status_code=404, detail="PMA is disabled")
959
+ hub_root = request.app.state.config.root
960
+ store = PmaTranscriptStore(hub_root)
961
+ entries = store.list_recent(limit=limit)
962
+ return {"entries": entries}
963
+
964
+ @router.get("/history/{turn_id}")
965
+ def get_pma_history(turn_id: str, request: Request) -> dict[str, Any]:
966
+ pma_config = _get_pma_config(request)
967
+ if not pma_config.get("enabled", True):
968
+ raise HTTPException(status_code=404, detail="PMA is disabled")
969
+ hub_root = request.app.state.config.root
970
+ store = PmaTranscriptStore(hub_root)
971
+ transcript = store.read_transcript(turn_id)
972
+ if not transcript:
973
+ raise HTTPException(status_code=404, detail="Transcript not found")
974
+ return transcript
975
+
629
976
  @router.get("/agents")
630
977
  def list_pma_agents(request: Request) -> dict[str, Any]:
631
978
  pma_config = _get_pma_config(request)
@@ -969,6 +1316,10 @@ def build_pma_routes() -> APIRouter:
969
1316
  )
970
1317
 
971
1318
  hub_root = request.app.state.config.root
1319
+ try:
1320
+ PmaActiveSinkStore(hub_root).set_web()
1321
+ except Exception:
1322
+ logger.exception("Failed to update PMA active sink for web")
972
1323
  queue = _get_pma_queue(request)
973
1324
 
974
1325
  lane_id = "pma:default"
@@ -1045,8 +1396,7 @@ def build_pma_routes() -> APIRouter:
1045
1396
  if result.status != "ok":
1046
1397
  raise HTTPException(status_code=500, detail=result.error)
1047
1398
 
1048
- if lane_id in lane_cancel_events:
1049
- lane_cancel_events[lane_id].set()
1399
+ await _stop_lane_worker(lane_id)
1050
1400
 
1051
1401
  await _interrupt_active(request, reason="Lane stopped", source="user_request")
1052
1402
 
@@ -1464,11 +1814,18 @@ def build_pma_routes() -> APIRouter:
1464
1814
  reset = bool(body.get("reset", False))
1465
1815
 
1466
1816
  pma_dir = hub_root / ".codex-autorunner" / "pma"
1467
- active_context_path = pma_dir / "active_context.md"
1468
- context_log_path = pma_dir / "context_log.md"
1817
+ docs_dir = _pma_docs_dir(hub_root)
1818
+ docs_dir.mkdir(parents=True, exist_ok=True)
1819
+ active_context_path = docs_dir / "active_context.md"
1820
+ context_log_path = docs_dir / "context_log.md"
1821
+ legacy_active_context_path = pma_dir / "active_context.md"
1822
+ legacy_context_log_path = pma_dir / "context_log.md"
1469
1823
 
1470
1824
  try:
1471
- active_content = active_context_path.read_text(encoding="utf-8")
1825
+ if active_context_path.exists():
1826
+ active_content = active_context_path.read_text(encoding="utf-8")
1827
+ else:
1828
+ active_content = legacy_active_context_path.read_text(encoding="utf-8")
1472
1829
  except Exception as exc:
1473
1830
  raise HTTPException(
1474
1831
  status_code=500, detail=f"Failed to read active_context.md: {exc}"
@@ -1490,6 +1847,9 @@ def build_pma_routes() -> APIRouter:
1490
1847
  try:
1491
1848
  with context_log_path.open("a", encoding="utf-8") as f:
1492
1849
  f.write(snapshot_content)
1850
+ if legacy_context_log_path.exists():
1851
+ with legacy_context_log_path.open("a", encoding="utf-8") as f:
1852
+ f.write(snapshot_content)
1493
1853
  except Exception as exc:
1494
1854
  raise HTTPException(
1495
1855
  status_code=500, detail=f"Failed to append context_log.md: {exc}"
@@ -1498,6 +1858,10 @@ def build_pma_routes() -> APIRouter:
1498
1858
  if reset:
1499
1859
  try:
1500
1860
  atomic_write(active_context_path, pma_active_context_content())
1861
+ if legacy_active_context_path.exists():
1862
+ atomic_write(
1863
+ legacy_active_context_path, pma_active_context_content()
1864
+ )
1501
1865
  except Exception as exc:
1502
1866
  raise HTTPException(
1503
1867
  status_code=500, detail=f"Failed to reset active_context.md: {exc}"
@@ -1539,6 +1903,55 @@ def build_pma_routes() -> APIRouter:
1539
1903
  "prompt.md": pma_prompt_content,
1540
1904
  }
1541
1905
 
1906
+ def _pma_docs_dir(hub_root: Path) -> Path:
1907
+ return hub_root / ".codex-autorunner" / "pma" / "docs"
1908
+
1909
+ def _pma_legacy_doc_path(hub_root: Path, name: str) -> Path:
1910
+ return hub_root / ".codex-autorunner" / "pma" / name
1911
+
1912
+ def _normalize_doc_name(name: str) -> str:
1913
+ try:
1914
+ return sanitize_filename(name)
1915
+ except ValueError as exc:
1916
+ raise HTTPException(
1917
+ status_code=400, detail=f"Invalid doc name: {name}"
1918
+ ) from exc
1919
+
1920
+ def _sorted_doc_names(docs_dir: Path) -> list[str]:
1921
+ names: set[str] = set()
1922
+ if docs_dir.exists():
1923
+ try:
1924
+ for path in docs_dir.iterdir():
1925
+ if not path.is_file():
1926
+ continue
1927
+ if path.name.startswith("."):
1928
+ continue
1929
+ names.add(path.name)
1930
+ except OSError:
1931
+ pass
1932
+ ordered: list[str] = []
1933
+ for doc_name in PMA_DOC_ORDER:
1934
+ if doc_name in names:
1935
+ ordered.append(doc_name)
1936
+ remaining = sorted(name for name in names if name not in ordered)
1937
+ ordered.extend(remaining)
1938
+ return ordered
1939
+
1940
+ def _write_doc_history(
1941
+ hub_root: Path, doc_name: str, content: str
1942
+ ) -> Optional[Path]:
1943
+ docs_dir = _pma_docs_dir(hub_root)
1944
+ history_root = docs_dir / "_history" / doc_name
1945
+ try:
1946
+ history_root.mkdir(parents=True, exist_ok=True)
1947
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
1948
+ history_path = history_root / f"{timestamp}.md"
1949
+ atomic_write(history_path, content)
1950
+ return history_path
1951
+ except Exception:
1952
+ logger.exception("Failed to write PMA doc history for %s", doc_name)
1953
+ return None
1954
+
1542
1955
  @router.get("/docs/default/{name}")
1543
1956
  def get_pma_doc_default(name: str, request: Request) -> dict[str, str]:
1544
1957
  pma_config = _get_pma_config(request)
@@ -1557,10 +1970,16 @@ def build_pma_routes() -> APIRouter:
1557
1970
  if not pma_config.get("enabled", True):
1558
1971
  raise HTTPException(status_code=404, detail="PMA is disabled")
1559
1972
  hub_root = request.app.state.config.root
1560
- pma_dir = hub_root / ".codex-autorunner" / "pma"
1973
+ try:
1974
+ ensure_pma_docs(hub_root)
1975
+ except Exception as exc:
1976
+ raise HTTPException(
1977
+ status_code=500, detail=f"Failed to ensure PMA docs: {exc}"
1978
+ ) from exc
1979
+ docs_dir = _pma_docs_dir(hub_root)
1561
1980
  result: list[dict[str, Any]] = []
1562
- for doc_name in PMA_DOC_ORDER:
1563
- doc_path = pma_dir / doc_name
1981
+ for doc_name in _sorted_doc_names(docs_dir):
1982
+ doc_path = docs_dir / doc_name
1564
1983
  entry: dict[str, Any] = {"name": doc_name}
1565
1984
  if doc_path.exists():
1566
1985
  entry["exists"] = True
@@ -1591,13 +2010,16 @@ def build_pma_routes() -> APIRouter:
1591
2010
  pma_config = _get_pma_config(request)
1592
2011
  if not pma_config.get("enabled", True):
1593
2012
  raise HTTPException(status_code=404, detail="PMA is disabled")
1594
- if name not in PMA_DOC_SET:
1595
- raise HTTPException(status_code=400, detail=f"Unknown doc name: {name}")
2013
+ name = _normalize_doc_name(name)
1596
2014
  hub_root = request.app.state.config.root
1597
- pma_dir = hub_root / ".codex-autorunner" / "pma"
1598
- doc_path = pma_dir / name
2015
+ docs_dir = _pma_docs_dir(hub_root)
2016
+ doc_path = docs_dir / name
1599
2017
  if not doc_path.exists():
1600
- raise HTTPException(status_code=404, detail=f"Doc not found: {name}")
2018
+ legacy_path = _pma_legacy_doc_path(hub_root, name)
2019
+ if legacy_path.exists():
2020
+ doc_path = legacy_path
2021
+ else:
2022
+ raise HTTPException(status_code=404, detail=f"Doc not found: {name}")
1601
2023
  try:
1602
2024
  content = doc_path.read_text(encoding="utf-8")
1603
2025
  except Exception as exc:
@@ -1613,7 +2035,15 @@ def build_pma_routes() -> APIRouter:
1613
2035
  pma_config = _get_pma_config(request)
1614
2036
  if not pma_config.get("enabled", True):
1615
2037
  raise HTTPException(status_code=404, detail="PMA is disabled")
1616
- if name not in PMA_DOC_SET:
2038
+ name = _normalize_doc_name(name)
2039
+ hub_root = request.app.state.config.root
2040
+ pma_dir = hub_root / ".codex-autorunner" / "pma"
2041
+ docs_dir = _pma_docs_dir(hub_root)
2042
+ if (
2043
+ name not in PMA_DOC_SET
2044
+ and not (docs_dir / name).exists()
2045
+ and not (pma_dir / name).exists()
2046
+ ):
1617
2047
  raise HTTPException(status_code=400, detail=f"Unknown doc name: {name}")
1618
2048
  content = body.get("content", "")
1619
2049
  if not isinstance(content, str):
@@ -1623,16 +2053,21 @@ def build_pma_routes() -> APIRouter:
1623
2053
  raise HTTPException(
1624
2054
  status_code=413, detail=f"Content too large (max {MAX_DOC_SIZE} bytes)"
1625
2055
  )
1626
- hub_root = request.app.state.config.root
1627
- pma_dir = hub_root / ".codex-autorunner" / "pma"
1628
2056
  pma_dir.mkdir(parents=True, exist_ok=True)
1629
- doc_path = pma_dir / name
2057
+ docs_dir.mkdir(parents=True, exist_ok=True)
2058
+ doc_path = docs_dir / name
1630
2059
  try:
1631
2060
  atomic_write(doc_path, content)
1632
2061
  except Exception as exc:
1633
2062
  raise HTTPException(
1634
2063
  status_code=500, detail=f"Failed to write doc: {exc}"
1635
2064
  ) from exc
2065
+ try:
2066
+ if name in PMA_DOC_SET or (pma_dir / name).exists():
2067
+ atomic_write(pma_dir / name, content)
2068
+ except Exception:
2069
+ logger.exception("Failed to update legacy PMA doc %s", name)
2070
+ _write_doc_history(hub_root, name, content)
1636
2071
  details = {
1637
2072
  "name": name,
1638
2073
  "size": len(content.encode("utf-8")),
@@ -1646,6 +2081,152 @@ def build_pma_routes() -> APIRouter:
1646
2081
  )
1647
2082
  return {"name": name, "status": "ok"}
1648
2083
 
2084
+ @router.get("/docs/history/{name}")
2085
+ def list_pma_doc_history(
2086
+ name: str, request: Request, limit: int = 50
2087
+ ) -> dict[str, Any]:
2088
+ pma_config = _get_pma_config(request)
2089
+ if not pma_config.get("enabled", True):
2090
+ raise HTTPException(status_code=404, detail="PMA is disabled")
2091
+ name = _normalize_doc_name(name)
2092
+ hub_root = request.app.state.config.root
2093
+ docs_dir = _pma_docs_dir(hub_root)
2094
+ history_dir = docs_dir / "_history" / name
2095
+ entries: list[dict[str, Any]] = []
2096
+ if history_dir.exists():
2097
+ try:
2098
+ for path in sorted(
2099
+ (p for p in history_dir.iterdir() if p.is_file()),
2100
+ key=lambda p: p.name,
2101
+ reverse=True,
2102
+ ):
2103
+ if len(entries) >= limit:
2104
+ break
2105
+ try:
2106
+ stat = path.stat()
2107
+ entries.append(
2108
+ {
2109
+ "id": path.name,
2110
+ "size": stat.st_size,
2111
+ "mtime": datetime.fromtimestamp(
2112
+ stat.st_mtime, tz=timezone.utc
2113
+ ).isoformat(),
2114
+ }
2115
+ )
2116
+ except OSError:
2117
+ continue
2118
+ except OSError:
2119
+ pass
2120
+ return {"name": name, "entries": entries}
2121
+
2122
+ @router.get("/docs/history/{name}/{version_id}")
2123
+ def get_pma_doc_history(
2124
+ name: str, version_id: str, request: Request
2125
+ ) -> dict[str, str]:
2126
+ pma_config = _get_pma_config(request)
2127
+ if not pma_config.get("enabled", True):
2128
+ raise HTTPException(status_code=404, detail="PMA is disabled")
2129
+ name = _normalize_doc_name(name)
2130
+ version_id = _normalize_doc_name(version_id)
2131
+ hub_root = request.app.state.config.root
2132
+ docs_dir = _pma_docs_dir(hub_root)
2133
+ history_path = docs_dir / "_history" / name / version_id
2134
+ if not history_path.exists():
2135
+ raise HTTPException(status_code=404, detail="History entry not found")
2136
+ try:
2137
+ content = history_path.read_text(encoding="utf-8")
2138
+ except Exception as exc:
2139
+ raise HTTPException(
2140
+ status_code=500, detail=f"Failed to read history entry: {exc}"
2141
+ ) from exc
2142
+ return {"name": name, "version_id": version_id, "content": content}
2143
+
2144
+ @router.get("/dispatches")
2145
+ def list_pma_dispatches_endpoint(
2146
+ request: Request, include_resolved: bool = False, limit: int = 100
2147
+ ) -> dict[str, Any]:
2148
+ pma_config = _get_pma_config(request)
2149
+ if not pma_config.get("enabled", True):
2150
+ raise HTTPException(status_code=404, detail="PMA is disabled")
2151
+ hub_root = request.app.state.config.root
2152
+ dispatches = list_pma_dispatches(
2153
+ hub_root, include_resolved=include_resolved, limit=limit
2154
+ )
2155
+ return {
2156
+ "items": [
2157
+ {
2158
+ "id": item.dispatch_id,
2159
+ "title": item.title,
2160
+ "body": item.body,
2161
+ "priority": item.priority,
2162
+ "links": item.links,
2163
+ "created_at": item.created_at,
2164
+ "resolved_at": item.resolved_at,
2165
+ "source_turn_id": item.source_turn_id,
2166
+ }
2167
+ for item in dispatches
2168
+ ]
2169
+ }
2170
+
2171
+ @router.get("/dispatches/{dispatch_id}")
2172
+ def get_pma_dispatch(dispatch_id: str, request: Request) -> dict[str, Any]:
2173
+ pma_config = _get_pma_config(request)
2174
+ if not pma_config.get("enabled", True):
2175
+ raise HTTPException(status_code=404, detail="PMA is disabled")
2176
+ hub_root = request.app.state.config.root
2177
+ path = find_pma_dispatch_path(hub_root, dispatch_id)
2178
+ if not path:
2179
+ raise HTTPException(status_code=404, detail="Dispatch not found")
2180
+ # Use list helper to normalize output
2181
+ items = list_pma_dispatches(hub_root, include_resolved=True)
2182
+ match = next((item for item in items if item.dispatch_id == dispatch_id), None)
2183
+ if not match:
2184
+ raise HTTPException(status_code=404, detail="Dispatch not found")
2185
+ return {
2186
+ "dispatch": {
2187
+ "id": match.dispatch_id,
2188
+ "title": match.title,
2189
+ "body": match.body,
2190
+ "priority": match.priority,
2191
+ "links": match.links,
2192
+ "created_at": match.created_at,
2193
+ "resolved_at": match.resolved_at,
2194
+ "source_turn_id": match.source_turn_id,
2195
+ }
2196
+ }
2197
+
2198
+ @router.post("/dispatches/{dispatch_id}/resolve")
2199
+ def resolve_pma_dispatch_endpoint(
2200
+ dispatch_id: str, request: Request
2201
+ ) -> dict[str, Any]:
2202
+ pma_config = _get_pma_config(request)
2203
+ if not pma_config.get("enabled", True):
2204
+ raise HTTPException(status_code=404, detail="PMA is disabled")
2205
+ hub_root = request.app.state.config.root
2206
+ path = find_pma_dispatch_path(hub_root, dispatch_id)
2207
+ if not path:
2208
+ raise HTTPException(status_code=404, detail="Dispatch not found")
2209
+ dispatch, errors = resolve_pma_dispatch(path)
2210
+ if errors or dispatch is None:
2211
+ raise HTTPException(
2212
+ status_code=500,
2213
+ detail="Failed to resolve dispatch: " + "; ".join(errors),
2214
+ )
2215
+ return {
2216
+ "dispatch": {
2217
+ "id": dispatch.dispatch_id,
2218
+ "title": dispatch.title,
2219
+ "body": dispatch.body,
2220
+ "priority": dispatch.priority,
2221
+ "links": dispatch.links,
2222
+ "created_at": dispatch.created_at,
2223
+ "resolved_at": dispatch.resolved_at,
2224
+ "source_turn_id": dispatch.source_turn_id,
2225
+ }
2226
+ }
2227
+
2228
+ router._pma_start_lane_worker = _ensure_lane_worker_for_app
2229
+ router._pma_stop_lane_worker = _stop_lane_worker_for_app
1649
2230
  return router
1650
2231
 
1651
2232