codex-autorunner 1.1.0__py3-none-any.whl → 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. codex_autorunner/agents/opencode/client.py +113 -4
  2. codex_autorunner/agents/opencode/supervisor.py +4 -0
  3. codex_autorunner/agents/registry.py +17 -7
  4. codex_autorunner/bootstrap.py +219 -1
  5. codex_autorunner/core/__init__.py +17 -1
  6. codex_autorunner/core/about_car.py +114 -1
  7. codex_autorunner/core/app_server_threads.py +6 -0
  8. codex_autorunner/core/config.py +236 -1
  9. codex_autorunner/core/context_awareness.py +38 -0
  10. codex_autorunner/core/docs.py +0 -122
  11. codex_autorunner/core/filebox.py +265 -0
  12. codex_autorunner/core/flows/controller.py +71 -1
  13. codex_autorunner/core/flows/reconciler.py +4 -1
  14. codex_autorunner/core/flows/runtime.py +22 -0
  15. codex_autorunner/core/flows/store.py +61 -9
  16. codex_autorunner/core/flows/transition.py +23 -16
  17. codex_autorunner/core/flows/ux_helpers.py +18 -3
  18. codex_autorunner/core/flows/worker_process.py +32 -6
  19. codex_autorunner/core/hub.py +198 -41
  20. codex_autorunner/core/lifecycle_events.py +253 -0
  21. codex_autorunner/core/path_utils.py +2 -1
  22. codex_autorunner/core/pma_audit.py +224 -0
  23. codex_autorunner/core/pma_context.py +496 -0
  24. codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
  25. codex_autorunner/core/pma_lifecycle.py +527 -0
  26. codex_autorunner/core/pma_queue.py +367 -0
  27. codex_autorunner/core/pma_safety.py +221 -0
  28. codex_autorunner/core/pma_state.py +115 -0
  29. codex_autorunner/core/ports/agent_backend.py +2 -5
  30. codex_autorunner/core/ports/run_event.py +1 -4
  31. codex_autorunner/core/prompt.py +0 -80
  32. codex_autorunner/core/prompts.py +56 -172
  33. codex_autorunner/core/redaction.py +0 -4
  34. codex_autorunner/core/review_context.py +11 -9
  35. codex_autorunner/core/runner_controller.py +35 -33
  36. codex_autorunner/core/runner_state.py +147 -0
  37. codex_autorunner/core/runtime.py +829 -0
  38. codex_autorunner/core/sqlite_utils.py +13 -4
  39. codex_autorunner/core/state.py +7 -10
  40. codex_autorunner/core/state_roots.py +5 -0
  41. codex_autorunner/core/templates/__init__.py +39 -0
  42. codex_autorunner/core/templates/git_mirror.py +234 -0
  43. codex_autorunner/core/templates/provenance.py +56 -0
  44. codex_autorunner/core/templates/scan_cache.py +120 -0
  45. codex_autorunner/core/ticket_linter_cli.py +17 -0
  46. codex_autorunner/core/ticket_manager_cli.py +154 -92
  47. codex_autorunner/core/time_utils.py +11 -0
  48. codex_autorunner/core/types.py +18 -0
  49. codex_autorunner/core/utils.py +34 -6
  50. codex_autorunner/flows/review/service.py +23 -25
  51. codex_autorunner/flows/ticket_flow/definition.py +43 -1
  52. codex_autorunner/integrations/agents/__init__.py +2 -0
  53. codex_autorunner/integrations/agents/backend_orchestrator.py +18 -0
  54. codex_autorunner/integrations/agents/codex_backend.py +19 -8
  55. codex_autorunner/integrations/agents/runner.py +3 -8
  56. codex_autorunner/integrations/agents/wiring.py +8 -0
  57. codex_autorunner/integrations/telegram/doctor.py +228 -6
  58. codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
  59. codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
  60. codex_autorunner/integrations/telegram/handlers/commands/flows.py +346 -58
  61. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
  62. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +202 -45
  63. codex_autorunner/integrations/telegram/handlers/commands_spec.py +18 -7
  64. codex_autorunner/integrations/telegram/handlers/messages.py +26 -1
  65. codex_autorunner/integrations/telegram/helpers.py +1 -3
  66. codex_autorunner/integrations/telegram/runtime.py +9 -4
  67. codex_autorunner/integrations/telegram/service.py +30 -0
  68. codex_autorunner/integrations/telegram/state.py +38 -0
  69. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +10 -4
  70. codex_autorunner/integrations/telegram/transport.py +10 -3
  71. codex_autorunner/integrations/templates/__init__.py +27 -0
  72. codex_autorunner/integrations/templates/scan_agent.py +312 -0
  73. codex_autorunner/server.py +2 -2
  74. codex_autorunner/static/agentControls.js +21 -5
  75. codex_autorunner/static/app.js +115 -11
  76. codex_autorunner/static/chatUploads.js +137 -0
  77. codex_autorunner/static/docChatCore.js +185 -13
  78. codex_autorunner/static/fileChat.js +68 -40
  79. codex_autorunner/static/fileboxUi.js +159 -0
  80. codex_autorunner/static/hub.js +46 -81
  81. codex_autorunner/static/index.html +303 -24
  82. codex_autorunner/static/messages.js +82 -4
  83. codex_autorunner/static/notifications.js +255 -0
  84. codex_autorunner/static/pma.js +1167 -0
  85. codex_autorunner/static/settings.js +3 -0
  86. codex_autorunner/static/streamUtils.js +57 -0
  87. codex_autorunner/static/styles.css +9125 -6742
  88. codex_autorunner/static/templateReposSettings.js +225 -0
  89. codex_autorunner/static/ticketChatActions.js +165 -3
  90. codex_autorunner/static/ticketChatStream.js +17 -119
  91. codex_autorunner/static/ticketEditor.js +41 -13
  92. codex_autorunner/static/ticketTemplates.js +798 -0
  93. codex_autorunner/static/tickets.js +69 -19
  94. codex_autorunner/static/turnEvents.js +27 -0
  95. codex_autorunner/static/turnResume.js +33 -0
  96. codex_autorunner/static/utils.js +28 -0
  97. codex_autorunner/static/workspace.js +258 -44
  98. codex_autorunner/static/workspaceFileBrowser.js +6 -4
  99. codex_autorunner/surfaces/cli/cli.py +1465 -155
  100. codex_autorunner/surfaces/cli/pma_cli.py +817 -0
  101. codex_autorunner/surfaces/web/app.py +253 -49
  102. codex_autorunner/surfaces/web/routes/__init__.py +4 -0
  103. codex_autorunner/surfaces/web/routes/analytics.py +29 -22
  104. codex_autorunner/surfaces/web/routes/file_chat.py +317 -36
  105. codex_autorunner/surfaces/web/routes/filebox.py +227 -0
  106. codex_autorunner/surfaces/web/routes/flows.py +219 -29
  107. codex_autorunner/surfaces/web/routes/messages.py +70 -39
  108. codex_autorunner/surfaces/web/routes/pma.py +1652 -0
  109. codex_autorunner/surfaces/web/routes/repos.py +1 -1
  110. codex_autorunner/surfaces/web/routes/shared.py +0 -3
  111. codex_autorunner/surfaces/web/routes/templates.py +634 -0
  112. codex_autorunner/surfaces/web/runner_manager.py +2 -2
  113. codex_autorunner/surfaces/web/schemas.py +70 -18
  114. codex_autorunner/tickets/agent_pool.py +27 -0
  115. codex_autorunner/tickets/files.py +33 -16
  116. codex_autorunner/tickets/lint.py +50 -0
  117. codex_autorunner/tickets/models.py +3 -0
  118. codex_autorunner/tickets/outbox.py +41 -5
  119. codex_autorunner/tickets/runner.py +350 -69
  120. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/METADATA +15 -19
  121. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/RECORD +125 -94
  122. codex_autorunner/core/adapter_utils.py +0 -21
  123. codex_autorunner/core/engine.py +0 -3302
  124. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
  125. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
  126. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
  127. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1652 @@
1
+ """
2
+ Hub-level PMA routes (chat + models + events).
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import hashlib
9
+ import json
10
+ import logging
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any, Optional
14
+
15
+ from fastapi import APIRouter, HTTPException, Request
16
+ from fastapi.responses import FileResponse, StreamingResponse
17
+ from starlette.datastructures import UploadFile
18
+
19
+ from ....agents.codex.harness import CodexHarness
20
+ from ....agents.opencode.harness import OpenCodeHarness
21
+ from ....agents.opencode.supervisor import OpenCodeSupervisorError
22
+ from ....agents.registry import validate_agent_id
23
+ from ....bootstrap import (
24
+ ensure_pma_docs,
25
+ pma_about_content,
26
+ pma_active_context_content,
27
+ pma_agents_content,
28
+ pma_context_log_content,
29
+ pma_prompt_content,
30
+ )
31
+ from ....core.app_server_threads import PMA_KEY, PMA_OPENCODE_KEY
32
+ from ....core.filebox import sanitize_filename
33
+ from ....core.logging_utils import log_event
34
+ from ....core.pma_audit import PmaActionType, PmaAuditLog
35
+ from ....core.pma_context import (
36
+ PMA_MAX_TEXT,
37
+ build_hub_snapshot,
38
+ format_pma_prompt,
39
+ load_pma_prompt,
40
+ )
41
+ from ....core.pma_lifecycle import PmaLifecycleRouter
42
+ from ....core.pma_queue import PmaQueue, QueueItemState
43
+ from ....core.pma_safety import PmaSafetyChecker, PmaSafetyConfig
44
+ from ....core.pma_state import PmaStateStore
45
+ from ....core.time_utils import now_iso
46
+ from ....core.utils import atomic_write
47
+ from .agents import _available_agents, _serialize_model_catalog
48
+ from .shared import SSE_HEADERS
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+ PMA_TIMEOUT_SECONDS = 28800
53
+ PMA_CONTEXT_SNAPSHOT_MAX_BYTES = 200_000
54
+ PMA_CONTEXT_LOG_SOFT_LIMIT_BYTES = 5_000_000
55
+ PMA_BULK_DELETE_SAMPLE_LIMIT = 10
56
+
57
+
58
+ def build_pma_routes() -> APIRouter:
59
+ router = APIRouter(prefix="/hub/pma")
60
+ pma_lock = asyncio.Lock()
61
+ pma_event: Optional[asyncio.Event] = None
62
+ pma_active = False
63
+ pma_current: Optional[dict[str, Any]] = None
64
+ pma_last_result: Optional[dict[str, Any]] = None
65
+ pma_state_store: Optional[PmaStateStore] = None
66
+ pma_state_root: Optional[Path] = None
67
+ pma_safety_checker: Optional[PmaSafetyChecker] = None
68
+ pma_safety_root: Optional[Path] = None
69
+ pma_audit_log: Optional[PmaAuditLog] = None
70
+ pma_queue: Optional[PmaQueue] = None
71
+ pma_queue_root: Optional[Path] = None
72
+ lane_workers: dict[str, asyncio.Task] = {}
73
+ lane_cancel_events: dict[str, asyncio.Event] = {}
74
+ item_futures: dict[str, asyncio.Future[dict[str, Any]]] = {}
75
+
76
+ def _normalize_optional_text(value: Any) -> Optional[str]:
77
+ if not isinstance(value, str):
78
+ return None
79
+ value = value.strip()
80
+ return value or None
81
+
82
+ def _get_pma_config(request: Request) -> dict[str, Any]:
83
+ raw = getattr(request.app.state.config, "raw", {})
84
+ pma_config = raw.get("pma", {}) if isinstance(raw, dict) else {}
85
+ if not isinstance(pma_config, dict):
86
+ pma_config = {}
87
+ return {
88
+ "enabled": bool(pma_config.get("enabled", True)),
89
+ "default_agent": _normalize_optional_text(pma_config.get("default_agent")),
90
+ "model": _normalize_optional_text(pma_config.get("model")),
91
+ "reasoning": _normalize_optional_text(pma_config.get("reasoning")),
92
+ "active_context_max_lines": int(
93
+ pma_config.get("active_context_max_lines", 200)
94
+ ),
95
+ "max_text_chars": int(pma_config.get("max_text_chars", 800)),
96
+ }
97
+
98
+ def _build_idempotency_key(
99
+ *,
100
+ lane_id: str,
101
+ agent: Optional[str],
102
+ model: Optional[str],
103
+ reasoning: Optional[str],
104
+ client_turn_id: Optional[str],
105
+ message: str,
106
+ ) -> str:
107
+ payload = {
108
+ "lane_id": lane_id,
109
+ "agent": agent,
110
+ "model": model,
111
+ "reasoning": reasoning,
112
+ "client_turn_id": client_turn_id,
113
+ "message": message,
114
+ }
115
+ raw = json.dumps(payload, sort_keys=True, default=str, ensure_ascii=True)
116
+ digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()
117
+ return f"pma:{digest}"
118
+
119
+ def _get_state_store(request: Request) -> PmaStateStore:
120
+ nonlocal pma_state_store, pma_state_root
121
+ hub_root = request.app.state.config.root
122
+ if pma_state_store is None or pma_state_root != hub_root:
123
+ pma_state_store = PmaStateStore(hub_root)
124
+ pma_state_root = hub_root
125
+ return pma_state_store
126
+
127
+ def _get_safety_checker(request: Request) -> PmaSafetyChecker:
128
+ nonlocal pma_safety_checker, pma_safety_root, pma_audit_log
129
+ hub_root = request.app.state.config.root
130
+ if pma_safety_checker is None or pma_safety_root != hub_root:
131
+ raw = getattr(request.app.state.config, "raw", {})
132
+ pma_config = raw.get("pma", {}) if isinstance(raw, dict) else {}
133
+ safety_config = PmaSafetyConfig(
134
+ dedup_window_seconds=pma_config.get("dedup_window_seconds", 300),
135
+ max_duplicate_actions=pma_config.get("max_duplicate_actions", 3),
136
+ rate_limit_window_seconds=pma_config.get(
137
+ "rate_limit_window_seconds", 60
138
+ ),
139
+ max_actions_per_window=pma_config.get("max_actions_per_window", 20),
140
+ circuit_breaker_threshold=pma_config.get(
141
+ "circuit_breaker_threshold", 5
142
+ ),
143
+ circuit_breaker_cooldown_seconds=pma_config.get(
144
+ "circuit_breaker_cooldown_seconds", 600
145
+ ),
146
+ enable_dedup=pma_config.get("enable_dedup", True),
147
+ enable_rate_limit=pma_config.get("enable_rate_limit", True),
148
+ enable_circuit_breaker=pma_config.get("enable_circuit_breaker", True),
149
+ )
150
+ pma_audit_log = PmaAuditLog(hub_root)
151
+ pma_safety_checker = PmaSafetyChecker(hub_root, config=safety_config)
152
+ pma_safety_root = hub_root
153
+ return pma_safety_checker
154
+
155
+ def _get_pma_queue(request: Request) -> PmaQueue:
156
+ nonlocal pma_queue, pma_queue_root
157
+ hub_root = request.app.state.config.root
158
+ if pma_queue is None or pma_queue_root != hub_root:
159
+ pma_queue = PmaQueue(hub_root)
160
+ pma_queue_root = hub_root
161
+ return pma_queue
162
+
163
+ async def _persist_state(store: Optional[PmaStateStore]) -> None:
164
+ if store is None:
165
+ return
166
+ async with pma_lock:
167
+ state = {
168
+ "version": 1,
169
+ "active": bool(pma_active),
170
+ "current": dict(pma_current or {}),
171
+ "last_result": dict(pma_last_result or {}),
172
+ "updated_at": now_iso(),
173
+ }
174
+ try:
175
+ store.save(state)
176
+ except Exception:
177
+ logger.exception("Failed to persist PMA state")
178
+
179
+ def _truncate_text(value: Any, limit: int) -> str:
180
+ if not isinstance(value, str):
181
+ value = "" if value is None else str(value)
182
+ if len(value) <= limit:
183
+ return value
184
+ return value[: max(0, limit - 3)] + "..."
185
+
186
+ def _format_last_result(
187
+ result: dict[str, Any], current: dict[str, Any]
188
+ ) -> dict[str, Any]:
189
+ status = result.get("status") or "error"
190
+ message = result.get("message")
191
+ detail = result.get("detail")
192
+ text = message if isinstance(message, str) and message else detail
193
+ summary = _truncate_text(text or "", PMA_MAX_TEXT)
194
+ return {
195
+ "status": status,
196
+ "message": summary,
197
+ "detail": (
198
+ _truncate_text(detail or "", PMA_MAX_TEXT)
199
+ if isinstance(detail, str)
200
+ else None
201
+ ),
202
+ "client_turn_id": result.get("client_turn_id") or "",
203
+ "agent": current.get("agent"),
204
+ "thread_id": result.get("thread_id") or current.get("thread_id"),
205
+ "turn_id": result.get("turn_id") or current.get("turn_id"),
206
+ "started_at": current.get("started_at"),
207
+ "finished_at": now_iso(),
208
+ }
209
+
210
+ async def _get_interrupt_event() -> asyncio.Event:
211
+ nonlocal pma_event
212
+ async with pma_lock:
213
+ if pma_event is None or pma_event.is_set():
214
+ pma_event = asyncio.Event()
215
+ return pma_event
216
+
217
+ async def _set_active(
218
+ active: bool, *, store: Optional[PmaStateStore] = None
219
+ ) -> None:
220
+ nonlocal pma_active
221
+ async with pma_lock:
222
+ pma_active = active
223
+ await _persist_state(store)
224
+
225
+ async def _begin_turn(
226
+ client_turn_id: Optional[str],
227
+ *,
228
+ store: Optional[PmaStateStore] = None,
229
+ lane_id: Optional[str] = None,
230
+ ) -> bool:
231
+ nonlocal pma_active, pma_current
232
+ async with pma_lock:
233
+ if pma_active:
234
+ return False
235
+ pma_active = True
236
+ pma_current = {
237
+ "client_turn_id": client_turn_id or "",
238
+ "status": "starting",
239
+ "agent": None,
240
+ "thread_id": None,
241
+ "turn_id": None,
242
+ "lane_id": lane_id or "",
243
+ "started_at": now_iso(),
244
+ }
245
+ await _persist_state(store)
246
+ return True
247
+
248
+ async def _clear_interrupt_event() -> None:
249
+ nonlocal pma_event
250
+ async with pma_lock:
251
+ pma_event = None
252
+
253
+ async def _update_current(
254
+ *, store: Optional[PmaStateStore] = None, **updates: Any
255
+ ) -> None:
256
+ nonlocal pma_current
257
+ async with pma_lock:
258
+ if pma_current is None:
259
+ pma_current = {}
260
+ pma_current.update(updates)
261
+ await _persist_state(store)
262
+
263
+ async def _finalize_result(
264
+ result: dict[str, Any],
265
+ *,
266
+ request: Request,
267
+ store: Optional[PmaStateStore] = None,
268
+ ) -> None:
269
+ nonlocal pma_current, pma_last_result, pma_active, pma_event
270
+ async with pma_lock:
271
+ current_snapshot = dict(pma_current or {})
272
+ pma_last_result = _format_last_result(result or {}, current_snapshot)
273
+ pma_current = None
274
+ pma_active = False
275
+ pma_event = None
276
+
277
+ status = result.get("status") or "error"
278
+ started_at = current_snapshot.get("started_at")
279
+ duration_ms = None
280
+ if started_at:
281
+ try:
282
+ start_dt = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
283
+ duration_ms = int(
284
+ (datetime.now(timezone.utc) - start_dt).total_seconds() * 1000
285
+ )
286
+ except Exception:
287
+ pass
288
+
289
+ log_event(
290
+ logger,
291
+ logging.INFO,
292
+ "pma.turn.completed",
293
+ status=status,
294
+ duration_ms=duration_ms,
295
+ agent=current_snapshot.get("agent"),
296
+ client_turn_id=current_snapshot.get("client_turn_id"),
297
+ thread_id=pma_last_result.get("thread_id"),
298
+ turn_id=pma_last_result.get("turn_id"),
299
+ error=result.get("detail") if status == "error" else None,
300
+ )
301
+
302
+ if status == "ok":
303
+ action_type = PmaActionType.CHAT_COMPLETED
304
+ elif status == "interrupted":
305
+ action_type = PmaActionType.CHAT_INTERRUPTED
306
+ else:
307
+ action_type = PmaActionType.CHAT_FAILED
308
+
309
+ _get_safety_checker(request).record_action(
310
+ action_type=action_type,
311
+ agent=current_snapshot.get("agent"),
312
+ thread_id=pma_last_result.get("thread_id"),
313
+ turn_id=pma_last_result.get("turn_id"),
314
+ client_turn_id=current_snapshot.get("client_turn_id"),
315
+ details={"status": status, "duration_ms": duration_ms},
316
+ status=status,
317
+ error=result.get("detail") if status == "error" else None,
318
+ )
319
+ _get_safety_checker(request).record_chat_result(
320
+ agent=current_snapshot.get("agent") or "",
321
+ status=status,
322
+ error=result.get("detail") if status == "error" else None,
323
+ )
324
+
325
+ await _persist_state(store)
326
+
327
+ async def _get_current_snapshot() -> dict[str, Any]:
328
+ async with pma_lock:
329
+ return dict(pma_current or {})
330
+
331
+ async def _interrupt_active(
332
+ request: Request, *, reason: str, source: str = "unknown"
333
+ ) -> dict[str, Any]:
334
+ event = await _get_interrupt_event()
335
+ event.set()
336
+ current = await _get_current_snapshot()
337
+ agent_id = (current.get("agent") or "").strip().lower()
338
+ thread_id = current.get("thread_id")
339
+ turn_id = current.get("turn_id")
340
+ client_turn_id = current.get("client_turn_id")
341
+ hub_root = request.app.state.config.root
342
+
343
+ log_event(
344
+ logger,
345
+ logging.INFO,
346
+ "pma.turn.interrupted",
347
+ agent=agent_id or None,
348
+ client_turn_id=client_turn_id or None,
349
+ thread_id=thread_id,
350
+ turn_id=turn_id,
351
+ reason=reason,
352
+ source=source,
353
+ )
354
+
355
+ if agent_id == "opencode":
356
+ supervisor = getattr(request.app.state, "opencode_supervisor", None)
357
+ if supervisor is not None and thread_id:
358
+ harness = OpenCodeHarness(supervisor)
359
+ await harness.interrupt(hub_root, thread_id, turn_id)
360
+ else:
361
+ supervisor = getattr(request.app.state, "app_server_supervisor", None)
362
+ events = getattr(request.app.state, "app_server_events", None)
363
+ if supervisor is not None and events is not None and thread_id and turn_id:
364
+ harness = CodexHarness(supervisor, events)
365
+ try:
366
+ await harness.interrupt(hub_root, thread_id, turn_id)
367
+ except Exception:
368
+ logger.exception("Failed to interrupt Codex turn")
369
+ return {
370
+ "status": "ok",
371
+ "interrupted": bool(event.is_set()),
372
+ "detail": reason,
373
+ "agent": agent_id or None,
374
+ "thread_id": thread_id,
375
+ "turn_id": turn_id,
376
+ }
377
+
378
+ 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():
381
+ return
382
+
383
+ cancel_event = asyncio.Event()
384
+ lane_cancel_events[lane_id] = cancel_event
385
+
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
394
+
395
+ if cancel_event.is_set():
396
+ await queue.fail_item(item, "cancelled by lane stop")
397
+ continue
398
+
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
416
+
417
+ async def _execute_queue_item(item: Any, request: Request) -> dict[str, Any]:
418
+ hub_root = request.app.state.config.root
419
+ payload = item.payload
420
+
421
+ client_turn_id = payload.get("client_turn_id")
422
+ message = payload.get("message", "")
423
+ agent = payload.get("agent")
424
+ model = _normalize_optional_text(payload.get("model"))
425
+ reasoning = _normalize_optional_text(payload.get("reasoning"))
426
+
427
+ store = _get_state_store(request)
428
+ agents, available_default = _available_agents(request)
429
+ available_ids = {entry.get("id") for entry in agents if isinstance(entry, dict)}
430
+ defaults = _get_pma_config(request)
431
+
432
+ def _resolve_default_agent() -> str:
433
+ configured_default = defaults.get("default_agent")
434
+ try:
435
+ candidate = validate_agent_id(configured_default or "")
436
+ except ValueError:
437
+ candidate = None
438
+ if candidate and candidate in available_ids:
439
+ return candidate
440
+ return available_default
441
+
442
+ try:
443
+ agent_id = validate_agent_id(agent or "")
444
+ except ValueError:
445
+ agent_id = _resolve_default_agent()
446
+
447
+ safety_checker = _get_safety_checker(request)
448
+ safety_check = safety_checker.check_chat_start(
449
+ agent_id, message, client_turn_id
450
+ )
451
+ if not safety_check.allowed:
452
+ detail = safety_check.reason or "PMA action blocked by safety check"
453
+ if safety_check.details:
454
+ detail = f"{detail}: {safety_check.details}"
455
+ return {"status": "error", "detail": detail}
456
+
457
+ started = await _begin_turn(
458
+ client_turn_id, store=store, lane_id=getattr(item, "lane_id", None)
459
+ )
460
+ if not started:
461
+ logger.warning("PMA turn started while another was active")
462
+
463
+ if not model and defaults.get("model"):
464
+ model = defaults["model"]
465
+ if not reasoning and defaults.get("reasoning"):
466
+ reasoning = defaults["reasoning"]
467
+
468
+ try:
469
+ prompt_base = load_pma_prompt(hub_root)
470
+ supervisor = getattr(request.app.state, "hub_supervisor", None)
471
+ snapshot = await build_hub_snapshot(supervisor, hub_root=hub_root)
472
+ prompt = format_pma_prompt(
473
+ prompt_base, snapshot, message, hub_root=hub_root
474
+ )
475
+ except Exception as exc:
476
+ error_result = {
477
+ "status": "error",
478
+ "detail": str(exc),
479
+ "client_turn_id": client_turn_id or "",
480
+ }
481
+ if started:
482
+ await _finalize_result(error_result, request=request, store=store)
483
+ return error_result
484
+
485
+ interrupt_event = await _get_interrupt_event()
486
+ if interrupt_event.is_set():
487
+ result = {"status": "interrupted", "detail": "PMA chat interrupted"}
488
+ if started:
489
+ await _finalize_result(result, request=request, store=store)
490
+ return result
491
+
492
+ meta_future: asyncio.Future[tuple[str, str]] = (
493
+ asyncio.get_running_loop().create_future()
494
+ )
495
+
496
+ async def _meta(thread_id: str, turn_id: str) -> None:
497
+ await _update_current(
498
+ store=store,
499
+ client_turn_id=client_turn_id or "",
500
+ status="running",
501
+ agent=agent_id,
502
+ thread_id=thread_id,
503
+ turn_id=turn_id,
504
+ )
505
+
506
+ safety_checker.record_action(
507
+ action_type=PmaActionType.CHAT_STARTED,
508
+ agent=agent_id,
509
+ thread_id=thread_id,
510
+ turn_id=turn_id,
511
+ client_turn_id=client_turn_id,
512
+ details={"message": message[:200]},
513
+ )
514
+
515
+ log_event(
516
+ logger,
517
+ logging.INFO,
518
+ "pma.turn.started",
519
+ agent=agent_id,
520
+ client_turn_id=client_turn_id or None,
521
+ thread_id=thread_id,
522
+ turn_id=turn_id,
523
+ )
524
+ if not meta_future.done():
525
+ meta_future.set_result((thread_id, turn_id))
526
+
527
+ supervisor = getattr(request.app.state, "app_server_supervisor", None)
528
+ events = getattr(request.app.state, "app_server_events", None)
529
+ opencode = getattr(request.app.state, "opencode_supervisor", None)
530
+ registry = getattr(request.app.state, "app_server_threads", None)
531
+ stall_timeout_seconds = None
532
+ try:
533
+ stall_timeout_seconds = (
534
+ request.app.state.config.opencode.session_stall_timeout_seconds
535
+ )
536
+ except Exception:
537
+ stall_timeout_seconds = None
538
+
539
+ try:
540
+ if agent_id == "opencode":
541
+ if opencode is None:
542
+ result = {"status": "error", "detail": "OpenCode unavailable"}
543
+ if started:
544
+ await _finalize_result(result, request=request, store=store)
545
+ return result
546
+ result = await _execute_opencode(
547
+ opencode,
548
+ hub_root,
549
+ prompt,
550
+ interrupt_event,
551
+ model=model,
552
+ reasoning=reasoning,
553
+ thread_registry=registry,
554
+ thread_key=PMA_OPENCODE_KEY,
555
+ stall_timeout_seconds=stall_timeout_seconds,
556
+ on_meta=_meta,
557
+ )
558
+ else:
559
+ if supervisor is None or events is None:
560
+ result = {"status": "error", "detail": "App-server unavailable"}
561
+ if started:
562
+ await _finalize_result(result, request=request, store=store)
563
+ return result
564
+ result = await _execute_app_server(
565
+ supervisor,
566
+ events,
567
+ hub_root,
568
+ prompt,
569
+ interrupt_event,
570
+ model=model,
571
+ reasoning=reasoning,
572
+ thread_registry=registry,
573
+ thread_key=PMA_KEY,
574
+ on_meta=_meta,
575
+ )
576
+ except Exception as exc:
577
+ if started:
578
+ error_result = {
579
+ "status": "error",
580
+ "detail": str(exc),
581
+ "client_turn_id": client_turn_id or "",
582
+ }
583
+ await _finalize_result(error_result, request=request, store=store)
584
+ raise
585
+
586
+ result = dict(result or {})
587
+ result["client_turn_id"] = client_turn_id or ""
588
+ await _finalize_result(result, request=request, store=store)
589
+ return result
590
+
591
+ @router.get("/active")
592
+ async def pma_active_status(
593
+ request: Request, client_turn_id: Optional[str] = None
594
+ ) -> dict[str, Any]:
595
+ pma_config = _get_pma_config(request)
596
+ if not pma_config.get("enabled", True):
597
+ raise HTTPException(status_code=404, detail="PMA is disabled")
598
+ async with pma_lock:
599
+ current = dict(pma_current or {})
600
+ last_result = dict(pma_last_result or {})
601
+ active = bool(pma_active)
602
+ store = _get_state_store(request)
603
+ disk_state = store.load(ensure_exists=True)
604
+ if isinstance(disk_state, dict):
605
+ disk_current = (
606
+ disk_state.get("current")
607
+ if isinstance(disk_state.get("current"), dict)
608
+ else {}
609
+ )
610
+ disk_last = (
611
+ disk_state.get("last_result")
612
+ if isinstance(disk_state.get("last_result"), dict)
613
+ else {}
614
+ )
615
+ if not current and disk_current:
616
+ current = dict(disk_current)
617
+ if not last_result and disk_last:
618
+ last_result = dict(disk_last)
619
+ if not active and disk_state.get("active"):
620
+ active = True
621
+ if client_turn_id:
622
+ # If caller is asking about a specific client turn id, only return the matching last result.
623
+ if last_result.get("client_turn_id") != client_turn_id:
624
+ last_result = {}
625
+ if current.get("client_turn_id") != client_turn_id:
626
+ current = {}
627
+ return {"active": active, "current": current, "last_result": last_result}
628
+
629
+ @router.get("/agents")
630
+ def list_pma_agents(request: Request) -> dict[str, Any]:
631
+ pma_config = _get_pma_config(request)
632
+ if not pma_config.get("enabled", True):
633
+ raise HTTPException(status_code=404, detail="PMA is disabled")
634
+ if (
635
+ getattr(request.app.state, "app_server_supervisor", None) is None
636
+ and getattr(request.app.state, "opencode_supervisor", None) is None
637
+ ):
638
+ raise HTTPException(status_code=404, detail="PMA unavailable")
639
+ agents, default_agent = _available_agents(request)
640
+ defaults = _get_pma_config(request)
641
+ payload: dict[str, Any] = {"agents": agents, "default": default_agent}
642
+ if defaults.get("model") or defaults.get("reasoning"):
643
+ payload["defaults"] = {
644
+ key: value
645
+ for key, value in {
646
+ "model": defaults.get("model"),
647
+ "reasoning": defaults.get("reasoning"),
648
+ }.items()
649
+ if value
650
+ }
651
+ return payload
652
+
653
+ @router.get("/audit/recent")
654
+ def get_pma_audit_log(request: Request, limit: int = 100):
655
+ pma_config = _get_pma_config(request)
656
+ if not pma_config.get("enabled", True):
657
+ raise HTTPException(status_code=404, detail="PMA is disabled")
658
+ safety_checker = _get_safety_checker(request)
659
+ entries = safety_checker._audit_log.list_recent(limit=limit)
660
+ return {
661
+ "entries": [
662
+ {
663
+ "entry_id": e.entry_id,
664
+ "action_type": e.action_type.value,
665
+ "timestamp": e.timestamp,
666
+ "agent": e.agent,
667
+ "thread_id": e.thread_id,
668
+ "turn_id": e.turn_id,
669
+ "client_turn_id": e.client_turn_id,
670
+ "details": e.details,
671
+ "status": e.status,
672
+ "error": e.error,
673
+ "fingerprint": e.fingerprint,
674
+ }
675
+ for e in entries
676
+ ]
677
+ }
678
+
679
+ @router.get("/safety/stats")
680
+ def get_pma_safety_stats(request: Request):
681
+ pma_config = _get_pma_config(request)
682
+ if not pma_config.get("enabled", True):
683
+ raise HTTPException(status_code=404, detail="PMA is disabled")
684
+ safety_checker = _get_safety_checker(request)
685
+ return safety_checker.get_stats()
686
+
687
+ @router.get("/agents/{agent}/models")
688
+ async def list_pma_agent_models(agent: str, request: Request):
689
+ pma_config = _get_pma_config(request)
690
+ if not pma_config.get("enabled", True):
691
+ raise HTTPException(status_code=404, detail="PMA is disabled")
692
+ agent_id = (agent or "").strip().lower()
693
+ hub_root = request.app.state.config.root
694
+ if agent_id == "codex":
695
+ supervisor = request.app.state.app_server_supervisor
696
+ events = request.app.state.app_server_events
697
+ if supervisor is None:
698
+ raise HTTPException(status_code=404, detail="Codex harness unavailable")
699
+ codex_harness = CodexHarness(supervisor, events)
700
+ catalog = await codex_harness.model_catalog(hub_root)
701
+ return _serialize_model_catalog(catalog)
702
+ if agent_id == "opencode":
703
+ supervisor = getattr(request.app.state, "opencode_supervisor", None)
704
+ if supervisor is None:
705
+ raise HTTPException(
706
+ status_code=404, detail="OpenCode harness unavailable"
707
+ )
708
+ try:
709
+ opencode_harness = OpenCodeHarness(supervisor)
710
+ catalog = await opencode_harness.model_catalog(hub_root)
711
+ return _serialize_model_catalog(catalog)
712
+ except OpenCodeSupervisorError as exc:
713
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
714
+ except Exception as exc:
715
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
716
+ raise HTTPException(status_code=404, detail="Unknown agent")
717
+
718
+ async def _execute_app_server(
719
+ supervisor: Any,
720
+ events: Any,
721
+ hub_root: Path,
722
+ prompt: str,
723
+ interrupt_event: asyncio.Event,
724
+ *,
725
+ model: Optional[str] = None,
726
+ reasoning: Optional[str] = None,
727
+ thread_registry: Optional[Any] = None,
728
+ thread_key: Optional[str] = None,
729
+ on_meta: Optional[Any] = None,
730
+ ) -> dict[str, Any]:
731
+ client = await supervisor.get_client(hub_root)
732
+
733
+ thread_id = None
734
+ if thread_registry is not None and thread_key:
735
+ thread_id = thread_registry.get_thread_id(thread_key)
736
+ if thread_id:
737
+ try:
738
+ await client.thread_resume(thread_id)
739
+ except Exception:
740
+ thread_id = None
741
+
742
+ if not thread_id:
743
+ thread = await client.thread_start(str(hub_root))
744
+ thread_id = thread.get("id")
745
+ if not isinstance(thread_id, str) or not thread_id:
746
+ raise HTTPException(
747
+ status_code=502, detail="App-server did not return a thread id"
748
+ )
749
+ if thread_registry is not None and thread_key:
750
+ thread_registry.set_thread_id(thread_key, thread_id)
751
+
752
+ turn_kwargs: dict[str, Any] = {}
753
+ if model:
754
+ turn_kwargs["model"] = model
755
+ if reasoning:
756
+ turn_kwargs["effort"] = reasoning
757
+
758
+ handle = await client.turn_start(
759
+ thread_id,
760
+ prompt,
761
+ approval_policy="on-request",
762
+ sandbox_policy="dangerFullAccess",
763
+ **turn_kwargs,
764
+ )
765
+ codex_harness = CodexHarness(supervisor, events)
766
+ if on_meta is not None:
767
+ try:
768
+ maybe = on_meta(thread_id, handle.turn_id)
769
+ if asyncio.iscoroutine(maybe):
770
+ await maybe
771
+ except Exception:
772
+ logger.exception("pma meta callback failed")
773
+
774
+ if interrupt_event.is_set():
775
+ try:
776
+ await codex_harness.interrupt(hub_root, thread_id, handle.turn_id)
777
+ except Exception:
778
+ logger.exception("Failed to interrupt Codex turn")
779
+ return {"status": "interrupted", "detail": "PMA chat interrupted"}
780
+
781
+ turn_task = asyncio.create_task(handle.wait(timeout=None))
782
+ timeout_task = asyncio.create_task(asyncio.sleep(PMA_TIMEOUT_SECONDS))
783
+ interrupt_task = asyncio.create_task(interrupt_event.wait())
784
+ try:
785
+ done, _ = await asyncio.wait(
786
+ {turn_task, timeout_task, interrupt_task},
787
+ return_when=asyncio.FIRST_COMPLETED,
788
+ )
789
+ if timeout_task in done:
790
+ try:
791
+ await codex_harness.interrupt(hub_root, thread_id, handle.turn_id)
792
+ except Exception:
793
+ logger.exception("Failed to interrupt Codex turn")
794
+ turn_task.cancel()
795
+ return {"status": "error", "detail": "PMA chat timed out"}
796
+ if interrupt_task in done:
797
+ try:
798
+ await codex_harness.interrupt(hub_root, thread_id, handle.turn_id)
799
+ except Exception:
800
+ logger.exception("Failed to interrupt Codex turn")
801
+ turn_task.cancel()
802
+ return {"status": "interrupted", "detail": "PMA chat interrupted"}
803
+ turn_result = await turn_task
804
+ finally:
805
+ timeout_task.cancel()
806
+ interrupt_task.cancel()
807
+
808
+ if getattr(turn_result, "errors", None):
809
+ errors = turn_result.errors
810
+ raise HTTPException(status_code=502, detail=errors[-1] if errors else "")
811
+
812
+ output = "\n".join(getattr(turn_result, "agent_messages", []) or []).strip()
813
+ raw_events = getattr(turn_result, "raw_events", []) or []
814
+ return {
815
+ "status": "ok",
816
+ "message": output,
817
+ "thread_id": thread_id,
818
+ "turn_id": handle.turn_id,
819
+ "raw_events": raw_events,
820
+ }
821
+
822
+ async def _execute_opencode(
823
+ supervisor: Any,
824
+ hub_root: Path,
825
+ prompt: str,
826
+ interrupt_event: asyncio.Event,
827
+ *,
828
+ model: Optional[str] = None,
829
+ reasoning: Optional[str] = None,
830
+ thread_registry: Optional[Any] = None,
831
+ thread_key: Optional[str] = None,
832
+ stall_timeout_seconds: Optional[float] = None,
833
+ on_meta: Optional[Any] = None,
834
+ part_handler: Optional[Any] = None,
835
+ ) -> dict[str, Any]:
836
+ from ....agents.opencode.runtime import (
837
+ PERMISSION_ALLOW,
838
+ build_turn_id,
839
+ collect_opencode_output,
840
+ extract_session_id,
841
+ parse_message_response,
842
+ split_model_id,
843
+ )
844
+
845
+ client = await supervisor.get_client(hub_root)
846
+ session_id = None
847
+ if thread_registry is not None and thread_key:
848
+ session_id = thread_registry.get_thread_id(thread_key)
849
+ if not session_id:
850
+ session = await client.create_session(directory=str(hub_root))
851
+ session_id = extract_session_id(session, allow_fallback_id=True)
852
+ if not isinstance(session_id, str) or not session_id:
853
+ raise HTTPException(
854
+ status_code=502, detail="OpenCode did not return a session id"
855
+ )
856
+ if thread_registry is not None and thread_key:
857
+ thread_registry.set_thread_id(thread_key, session_id)
858
+ if on_meta is not None:
859
+ try:
860
+ maybe = on_meta(session_id, build_turn_id(session_id))
861
+ if asyncio.iscoroutine(maybe):
862
+ await maybe
863
+ except Exception:
864
+ logger.exception("pma meta callback failed")
865
+
866
+ opencode_harness = OpenCodeHarness(supervisor)
867
+ if interrupt_event.is_set():
868
+ await opencode_harness.interrupt(hub_root, session_id, None)
869
+ return {"status": "interrupted", "detail": "PMA chat interrupted"}
870
+
871
+ model_payload = split_model_id(model)
872
+ await supervisor.mark_turn_started(hub_root)
873
+
874
+ ready_event = asyncio.Event()
875
+ output_task = asyncio.create_task(
876
+ collect_opencode_output(
877
+ client,
878
+ session_id=session_id,
879
+ workspace_path=str(hub_root),
880
+ model_payload=model_payload,
881
+ permission_policy=PERMISSION_ALLOW,
882
+ question_policy="auto_first_option",
883
+ should_stop=interrupt_event.is_set,
884
+ ready_event=ready_event,
885
+ part_handler=part_handler,
886
+ stall_timeout_seconds=stall_timeout_seconds,
887
+ )
888
+ )
889
+ try:
890
+ await asyncio.wait_for(ready_event.wait(), timeout=2.0)
891
+ except asyncio.TimeoutError:
892
+ pass
893
+
894
+ prompt_task = asyncio.create_task(
895
+ client.prompt_async(
896
+ session_id,
897
+ message=prompt,
898
+ model=model_payload,
899
+ variant=reasoning,
900
+ )
901
+ )
902
+ timeout_task = asyncio.create_task(asyncio.sleep(PMA_TIMEOUT_SECONDS))
903
+ interrupt_task = asyncio.create_task(interrupt_event.wait())
904
+ try:
905
+ prompt_response = None
906
+ try:
907
+ prompt_response = await prompt_task
908
+ except Exception as exc:
909
+ interrupt_event.set()
910
+ output_task.cancel()
911
+ await opencode_harness.interrupt(hub_root, session_id, None)
912
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
913
+
914
+ done, _ = await asyncio.wait(
915
+ {output_task, timeout_task, interrupt_task},
916
+ return_when=asyncio.FIRST_COMPLETED,
917
+ )
918
+ if timeout_task in done:
919
+ output_task.cancel()
920
+ await opencode_harness.interrupt(hub_root, session_id, None)
921
+ return {"status": "error", "detail": "PMA chat timed out"}
922
+ if interrupt_task in done:
923
+ output_task.cancel()
924
+ await opencode_harness.interrupt(hub_root, session_id, None)
925
+ return {"status": "interrupted", "detail": "PMA chat interrupted"}
926
+ output_result = await output_task
927
+ if (not output_result.text) and prompt_response is not None:
928
+ fallback = parse_message_response(prompt_response)
929
+ if fallback.text:
930
+ output_result = type(output_result)(
931
+ text=fallback.text, error=fallback.error
932
+ )
933
+ finally:
934
+ timeout_task.cancel()
935
+ interrupt_task.cancel()
936
+ await supervisor.mark_turn_finished(hub_root)
937
+
938
+ if output_result.error:
939
+ raise HTTPException(status_code=502, detail=output_result.error)
940
+ return {
941
+ "status": "ok",
942
+ "message": output_result.text,
943
+ "thread_id": session_id,
944
+ "turn_id": build_turn_id(session_id),
945
+ }
946
+
947
+ @router.post("/chat")
948
+ async def pma_chat(request: Request):
949
+ pma_config = _get_pma_config(request)
950
+ if not pma_config.get("enabled", True):
951
+ raise HTTPException(status_code=404, detail="PMA is disabled")
952
+ body = await request.json()
953
+ message = (body.get("message") or "").strip()
954
+ stream = bool(body.get("stream", False))
955
+ agent = _normalize_optional_text(body.get("agent"))
956
+ model = _normalize_optional_text(body.get("model"))
957
+ reasoning = _normalize_optional_text(body.get("reasoning"))
958
+ client_turn_id = (body.get("client_turn_id") or "").strip() or None
959
+
960
+ if not message:
961
+ raise HTTPException(status_code=400, detail="message is required")
962
+ max_text_chars = int(pma_config.get("max_text_chars", 0) or 0)
963
+ if max_text_chars > 0 and len(message) > max_text_chars:
964
+ raise HTTPException(
965
+ status_code=400,
966
+ detail=(
967
+ "message exceeds max_text_chars " f"({max_text_chars} characters)"
968
+ ),
969
+ )
970
+
971
+ hub_root = request.app.state.config.root
972
+ queue = _get_pma_queue(request)
973
+
974
+ lane_id = "pma:default"
975
+ idempotency_key = _build_idempotency_key(
976
+ lane_id=lane_id,
977
+ agent=agent,
978
+ model=model,
979
+ reasoning=reasoning,
980
+ client_turn_id=client_turn_id,
981
+ message=message,
982
+ )
983
+
984
+ payload = {
985
+ "message": message,
986
+ "agent": agent,
987
+ "model": model,
988
+ "reasoning": reasoning,
989
+ "client_turn_id": client_turn_id,
990
+ "stream": stream,
991
+ "hub_root": str(hub_root),
992
+ }
993
+
994
+ item, dupe_reason = await queue.enqueue(lane_id, idempotency_key, payload)
995
+ if dupe_reason:
996
+ logger.info("Duplicate PMA turn: %s", dupe_reason)
997
+
998
+ if item.state == QueueItemState.DEDUPED:
999
+ return {
1000
+ "status": "ok",
1001
+ "message": "Duplicate request - already processing",
1002
+ "deduped": True,
1003
+ }
1004
+
1005
+ result_future = asyncio.get_running_loop().create_future()
1006
+ item_futures[item.item_id] = result_future
1007
+
1008
+ await _ensure_lane_worker(lane_id, request)
1009
+
1010
+ try:
1011
+ result = await asyncio.wait_for(result_future, timeout=PMA_TIMEOUT_SECONDS)
1012
+ except asyncio.TimeoutError:
1013
+ return {"status": "error", "detail": "PMA chat timed out"}
1014
+ except Exception:
1015
+ logger.exception("PMA chat error")
1016
+ return {
1017
+ "status": "error",
1018
+ "detail": "An error occurred processing your request",
1019
+ }
1020
+
1021
+ return result
1022
+
1023
+ @router.post("/interrupt")
1024
+ async def pma_interrupt(request: Request) -> dict[str, Any]:
1025
+ pma_config = _get_pma_config(request)
1026
+ if not pma_config.get("enabled", True):
1027
+ raise HTTPException(status_code=404, detail="PMA is disabled")
1028
+ return await _interrupt_active(
1029
+ request, reason="PMA chat interrupted", source="user_request"
1030
+ )
1031
+
1032
+ @router.post("/stop")
1033
+ async def pma_stop(request: Request) -> dict[str, Any]:
1034
+ pma_config = _get_pma_config(request)
1035
+ if not pma_config.get("enabled", True):
1036
+ raise HTTPException(status_code=404, detail="PMA is disabled")
1037
+
1038
+ body = await request.json() if request.headers.get("content-type") else {}
1039
+ lane_id = (body.get("lane_id") or "pma:default").strip()
1040
+ hub_root = request.app.state.config.root
1041
+ lifecycle_router = PmaLifecycleRouter(hub_root)
1042
+
1043
+ result = await lifecycle_router.stop(lane_id=lane_id)
1044
+
1045
+ if result.status != "ok":
1046
+ raise HTTPException(status_code=500, detail=result.error)
1047
+
1048
+ if lane_id in lane_cancel_events:
1049
+ lane_cancel_events[lane_id].set()
1050
+
1051
+ await _interrupt_active(request, reason="Lane stopped", source="user_request")
1052
+
1053
+ return {
1054
+ "status": result.status,
1055
+ "message": result.message,
1056
+ "artifact_path": (
1057
+ str(result.artifact_path) if result.artifact_path else None
1058
+ ),
1059
+ "details": result.details,
1060
+ }
1061
+
1062
+ @router.post("/new")
1063
+ async def new_pma_session(request: Request) -> dict[str, Any]:
1064
+ pma_config = _get_pma_config(request)
1065
+ if not pma_config.get("enabled", True):
1066
+ raise HTTPException(status_code=404, detail="PMA is disabled")
1067
+
1068
+ body = await request.json()
1069
+ agent = _normalize_optional_text(body.get("agent"))
1070
+ lane_id = (body.get("lane_id") or "pma:default").strip()
1071
+
1072
+ hub_root = request.app.state.config.root
1073
+ lifecycle_router = PmaLifecycleRouter(hub_root)
1074
+
1075
+ result = await lifecycle_router.new(agent=agent, lane_id=lane_id)
1076
+
1077
+ if result.status != "ok":
1078
+ raise HTTPException(status_code=500, detail=result.error)
1079
+
1080
+ return {
1081
+ "status": result.status,
1082
+ "message": result.message,
1083
+ "artifact_path": (
1084
+ str(result.artifact_path) if result.artifact_path else None
1085
+ ),
1086
+ "details": result.details,
1087
+ }
1088
+
1089
+ @router.post("/reset")
1090
+ async def reset_pma_session(request: Request) -> dict[str, Any]:
1091
+ pma_config = _get_pma_config(request)
1092
+ if not pma_config.get("enabled", True):
1093
+ raise HTTPException(status_code=404, detail="PMA is disabled")
1094
+
1095
+ body = await request.json() if request.headers.get("content-type") else {}
1096
+ raw_agent = (body.get("agent") or "").strip().lower()
1097
+ agent = raw_agent or None
1098
+
1099
+ hub_root = request.app.state.config.root
1100
+ lifecycle_router = PmaLifecycleRouter(hub_root)
1101
+
1102
+ result = await lifecycle_router.reset(agent=agent)
1103
+
1104
+ if result.status != "ok":
1105
+ raise HTTPException(status_code=500, detail=result.error)
1106
+
1107
+ return {
1108
+ "status": result.status,
1109
+ "message": result.message,
1110
+ "artifact_path": (
1111
+ str(result.artifact_path) if result.artifact_path else None
1112
+ ),
1113
+ "details": result.details,
1114
+ }
1115
+
1116
+ @router.post("/compact")
1117
+ async def compact_pma_history(request: Request) -> dict[str, Any]:
1118
+ pma_config = _get_pma_config(request)
1119
+ if not pma_config.get("enabled", True):
1120
+ raise HTTPException(status_code=404, detail="PMA is disabled")
1121
+
1122
+ body = await request.json()
1123
+ summary = (body.get("summary") or "").strip()
1124
+ agent = _normalize_optional_text(body.get("agent"))
1125
+ thread_id = _normalize_optional_text(body.get("thread_id"))
1126
+
1127
+ if not summary:
1128
+ raise HTTPException(status_code=400, detail="summary is required")
1129
+
1130
+ hub_root = request.app.state.config.root
1131
+ lifecycle_router = PmaLifecycleRouter(hub_root)
1132
+
1133
+ result = await lifecycle_router.compact(
1134
+ summary=summary, agent=agent, thread_id=thread_id
1135
+ )
1136
+
1137
+ if result.status != "ok":
1138
+ raise HTTPException(status_code=500, detail=result.error)
1139
+
1140
+ return {
1141
+ "status": result.status,
1142
+ "message": result.message,
1143
+ "artifact_path": (
1144
+ str(result.artifact_path) if result.artifact_path else None
1145
+ ),
1146
+ "details": result.details,
1147
+ }
1148
+
1149
+ @router.post("/thread/reset")
1150
+ async def reset_pma_thread(request: Request) -> dict[str, Any]:
1151
+ pma_config = _get_pma_config(request)
1152
+ if not pma_config.get("enabled", True):
1153
+ raise HTTPException(status_code=404, detail="PMA is disabled")
1154
+ body = await request.json()
1155
+ raw_agent = (body.get("agent") or "").strip().lower()
1156
+ agent = raw_agent or None
1157
+
1158
+ hub_root = request.app.state.config.root
1159
+ lifecycle_router = PmaLifecycleRouter(hub_root)
1160
+
1161
+ result = await lifecycle_router.reset(agent=agent)
1162
+
1163
+ if result.status != "ok":
1164
+ raise HTTPException(status_code=500, detail=result.error)
1165
+
1166
+ return {
1167
+ "status": result.status,
1168
+ "cleared": result.details.get("cleared_threads", []),
1169
+ "artifact_path": (
1170
+ str(result.artifact_path) if result.artifact_path else None
1171
+ ),
1172
+ }
1173
+
1174
+ @router.get("/turns/{turn_id}/events")
1175
+ async def stream_pma_turn_events(
1176
+ turn_id: str, request: Request, thread_id: str, agent: str = "codex"
1177
+ ):
1178
+ pma_config = _get_pma_config(request)
1179
+ if not pma_config.get("enabled", True):
1180
+ raise HTTPException(status_code=404, detail="PMA is disabled")
1181
+ agent_id = (agent or "").strip().lower()
1182
+ if agent_id == "codex":
1183
+ events = getattr(request.app.state, "app_server_events", None)
1184
+ if events is None:
1185
+ raise HTTPException(status_code=404, detail="Codex events unavailable")
1186
+ if not thread_id:
1187
+ raise HTTPException(status_code=400, detail="thread_id is required")
1188
+ return StreamingResponse(
1189
+ events.stream(thread_id, turn_id),
1190
+ media_type="text/event-stream",
1191
+ headers=SSE_HEADERS,
1192
+ )
1193
+ if agent_id == "opencode":
1194
+ if not thread_id:
1195
+ raise HTTPException(status_code=400, detail="thread_id is required")
1196
+ supervisor = getattr(request.app.state, "opencode_supervisor", None)
1197
+ if supervisor is None:
1198
+ raise HTTPException(status_code=404, detail="OpenCode unavailable")
1199
+ harness = OpenCodeHarness(supervisor)
1200
+ return StreamingResponse(
1201
+ harness.stream_events(
1202
+ request.app.state.config.root, thread_id, turn_id
1203
+ ),
1204
+ media_type="text/event-stream",
1205
+ headers=SSE_HEADERS,
1206
+ )
1207
+ raise HTTPException(status_code=404, detail="Unknown agent")
1208
+
1209
+ def _serialize_pma_entry(
1210
+ entry: dict[str, Any], *, request: Request
1211
+ ) -> dict[str, Any]:
1212
+ base = request.scope.get("root_path", "") or ""
1213
+ box = entry.get("box", "inbox")
1214
+ filename = entry.get("name", "")
1215
+ download = f"{base}/hub/pma/files/{box}/{filename}"
1216
+ return {
1217
+ "name": filename,
1218
+ "box": box,
1219
+ "size": entry.get("size"),
1220
+ "modified_at": entry.get("modified_at"),
1221
+ "source": "pma",
1222
+ "url": download,
1223
+ }
1224
+
1225
+ @router.get("/files")
1226
+ def list_pma_files(request: Request) -> dict[str, list[dict[str, Any]]]:
1227
+ pma_config = _get_pma_config(request)
1228
+ if not pma_config.get("enabled", True):
1229
+ raise HTTPException(status_code=404, detail="PMA is disabled")
1230
+ hub_root = request.app.state.config.root
1231
+ pma_dir = hub_root / ".codex-autorunner" / "pma"
1232
+ result: dict[str, list[dict[str, Any]]] = {"inbox": [], "outbox": []}
1233
+ for box in ["inbox", "outbox"]:
1234
+ box_dir = pma_dir / box
1235
+ if box_dir.exists():
1236
+ files = [
1237
+ {
1238
+ "name": f.name,
1239
+ "box": box,
1240
+ "size": f.stat().st_size if f.is_file() else None,
1241
+ "modified_at": (
1242
+ datetime.fromtimestamp(
1243
+ f.stat().st_mtime, tz=timezone.utc
1244
+ ).isoformat()
1245
+ if f.is_file()
1246
+ else None
1247
+ ),
1248
+ }
1249
+ for f in box_dir.iterdir()
1250
+ if f.is_file() and not f.name.startswith(".")
1251
+ ]
1252
+ result[box] = [
1253
+ _serialize_pma_entry(f, request=request)
1254
+ for f in sorted(files, key=lambda x: x["name"])
1255
+ ]
1256
+ return result
1257
+
1258
+ @router.get("/queue")
1259
+ async def pma_queue_status(request: Request) -> dict[str, Any]:
1260
+ pma_config = _get_pma_config(request)
1261
+ if not pma_config.get("enabled", True):
1262
+ raise HTTPException(status_code=404, detail="PMA is disabled")
1263
+
1264
+ queue = _get_pma_queue(request)
1265
+ summary = await queue.get_queue_summary()
1266
+ return summary
1267
+
1268
+ @router.get("/queue/{lane_id:path}")
1269
+ async def pma_lane_queue_status(request: Request, lane_id: str) -> dict[str, Any]:
1270
+ pma_config = _get_pma_config(request)
1271
+ if not pma_config.get("enabled", True):
1272
+ raise HTTPException(status_code=404, detail="PMA is disabled")
1273
+
1274
+ queue = _get_pma_queue(request)
1275
+ items = await queue.list_items(lane_id)
1276
+ return {
1277
+ "lane_id": lane_id,
1278
+ "items": [
1279
+ {
1280
+ "item_id": item.item_id,
1281
+ "state": item.state.value,
1282
+ "enqueued_at": item.enqueued_at,
1283
+ "started_at": item.started_at,
1284
+ "finished_at": item.finished_at,
1285
+ "error": item.error,
1286
+ "dedupe_reason": item.dedupe_reason,
1287
+ }
1288
+ for item in items
1289
+ ],
1290
+ }
1291
+
1292
+ @router.post("/files/{box}")
1293
+ async def upload_pma_file(box: str, request: Request):
1294
+ pma_config = _get_pma_config(request)
1295
+ if not pma_config.get("enabled", True):
1296
+ raise HTTPException(status_code=404, detail="PMA is disabled")
1297
+ if box not in ("inbox", "outbox"):
1298
+ raise HTTPException(status_code=400, detail="Invalid box")
1299
+ hub_root = request.app.state.config.root
1300
+ max_upload_bytes = request.app.state.config.pma.max_upload_bytes
1301
+
1302
+ form = await request.form()
1303
+ saved = []
1304
+ for _form_field_name, file in form.items():
1305
+ try:
1306
+ if isinstance(file, UploadFile):
1307
+ content = await file.read()
1308
+ filename = file.filename or ""
1309
+ else:
1310
+ content = file if isinstance(file, bytes) else str(file).encode()
1311
+ filename = ""
1312
+ except Exception as exc:
1313
+ logger.warning("Failed to read PMA upload: %s", exc)
1314
+ raise HTTPException(
1315
+ status_code=400, detail="Failed to read file"
1316
+ ) from exc
1317
+ if len(content) > max_upload_bytes:
1318
+ logger.warning(
1319
+ "File too large for PMA upload: %s (%d bytes)",
1320
+ filename,
1321
+ len(content),
1322
+ )
1323
+ raise HTTPException(
1324
+ status_code=400,
1325
+ detail=f"File too large (max {max_upload_bytes} bytes)",
1326
+ )
1327
+ try:
1328
+ target_path = _pma_target_path(hub_root, box, filename)
1329
+ except HTTPException:
1330
+ logger.warning("Invalid filename in PMA upload: %s", filename)
1331
+ raise
1332
+ try:
1333
+ target_path.write_bytes(content)
1334
+ saved.append(target_path.name)
1335
+ _get_safety_checker(request).record_action(
1336
+ action_type=PmaActionType.FILE_UPLOADED,
1337
+ details={
1338
+ "box": box,
1339
+ "filename": target_path.name,
1340
+ "size": len(content),
1341
+ },
1342
+ )
1343
+ except Exception as exc:
1344
+ logger.warning("Failed to write PMA file: %s", exc)
1345
+ raise HTTPException(
1346
+ status_code=500, detail="Failed to save file"
1347
+ ) from exc
1348
+ return {"status": "ok", "saved": saved}
1349
+
1350
+ def _pma_target_path(hub_root: Path, box: str, filename: str) -> Path:
1351
+ """Return a resolved path within the PMA box folder, rejecting traversal attempts."""
1352
+ box_dir = hub_root / ".codex-autorunner" / "pma" / box
1353
+ box_dir.mkdir(parents=True, exist_ok=True)
1354
+ try:
1355
+ safe_name = sanitize_filename(filename)
1356
+ except ValueError as exc:
1357
+ raise HTTPException(status_code=400, detail="Invalid filename") from exc
1358
+ root = box_dir.resolve()
1359
+ candidate = (root / safe_name).resolve()
1360
+ try:
1361
+ candidate.relative_to(root)
1362
+ except ValueError as exc:
1363
+ raise HTTPException(status_code=400, detail="Invalid filename") from exc
1364
+ if candidate.parent != root:
1365
+ raise HTTPException(status_code=400, detail="Invalid filename")
1366
+ return candidate
1367
+
1368
+ @router.get("/files/{box}/{filename}")
1369
+ def download_pma_file(box: str, filename: str, request: Request):
1370
+ pma_config = _get_pma_config(request)
1371
+ if not pma_config.get("enabled", True):
1372
+ raise HTTPException(status_code=404, detail="PMA is disabled")
1373
+ if box not in ("inbox", "outbox"):
1374
+ raise HTTPException(status_code=400, detail="Invalid box")
1375
+ hub_root = request.app.state.config.root
1376
+ try:
1377
+ file_path = _pma_target_path(hub_root, box, filename)
1378
+ except HTTPException:
1379
+ logger.warning("Invalid filename in PMA download: %s", filename)
1380
+ raise
1381
+ if not file_path.exists() or not file_path.is_file():
1382
+ logger.warning("File not found in PMA download: %s", filename)
1383
+ raise HTTPException(status_code=404, detail="File not found")
1384
+ _get_safety_checker(request).record_action(
1385
+ action_type=PmaActionType.FILE_DOWNLOADED,
1386
+ details={
1387
+ "box": box,
1388
+ "filename": file_path.name,
1389
+ "size": file_path.stat().st_size,
1390
+ },
1391
+ )
1392
+ return FileResponse(file_path, filename=file_path.name)
1393
+
1394
+ @router.delete("/files/{box}/{filename}")
1395
+ def delete_pma_file(box: str, filename: str, request: Request):
1396
+ pma_config = _get_pma_config(request)
1397
+ if not pma_config.get("enabled", True):
1398
+ raise HTTPException(status_code=404, detail="PMA is disabled")
1399
+ if box not in ("inbox", "outbox"):
1400
+ raise HTTPException(status_code=400, detail="Invalid box")
1401
+ hub_root = request.app.state.config.root
1402
+ try:
1403
+ file_path = _pma_target_path(hub_root, box, filename)
1404
+ except HTTPException:
1405
+ logger.warning("Invalid filename in PMA delete: %s", filename)
1406
+ raise
1407
+ if not file_path.exists() or not file_path.is_file():
1408
+ logger.warning("File not found in PMA delete: %s", filename)
1409
+ raise HTTPException(status_code=404, detail="File not found")
1410
+ try:
1411
+ file_size = file_path.stat().st_size
1412
+ file_path.unlink()
1413
+ _get_safety_checker(request).record_action(
1414
+ action_type=PmaActionType.FILE_DELETED,
1415
+ details={"box": box, "filename": file_path.name, "size": file_size},
1416
+ )
1417
+ except Exception as exc:
1418
+ logger.warning("Failed to delete PMA file: %s", exc)
1419
+ raise HTTPException(
1420
+ status_code=500, detail="Failed to delete file"
1421
+ ) from exc
1422
+ return {"status": "ok"}
1423
+
1424
+ @router.delete("/files/{box}")
1425
+ def delete_pma_box(box: str, request: Request):
1426
+ pma_config = _get_pma_config(request)
1427
+ if not pma_config.get("enabled", True):
1428
+ raise HTTPException(status_code=404, detail="PMA is disabled")
1429
+ if box not in ("inbox", "outbox"):
1430
+ raise HTTPException(status_code=400, detail="Invalid box")
1431
+ hub_root = request.app.state.config.root
1432
+ box_dir = hub_root / ".codex-autorunner" / "pma" / box
1433
+ deleted_files: list[str] = []
1434
+ if box_dir.exists():
1435
+ for f in box_dir.iterdir():
1436
+ if f.is_file() and not f.name.startswith("."):
1437
+ deleted_files.append(f.name)
1438
+ f.unlink()
1439
+ _get_safety_checker(request).record_action(
1440
+ action_type=PmaActionType.FILE_BULK_DELETED,
1441
+ details={
1442
+ "box": box,
1443
+ "count": len(deleted_files),
1444
+ "sample": deleted_files[:PMA_BULK_DELETE_SAMPLE_LIMIT],
1445
+ },
1446
+ )
1447
+ return {"status": "ok"}
1448
+
1449
+ @router.post("/context/snapshot")
1450
+ def snapshot_pma_context(request: Request, body: Optional[dict[str, Any]] = None):
1451
+ pma_config = _get_pma_config(request)
1452
+ if not pma_config.get("enabled", True):
1453
+ raise HTTPException(status_code=404, detail="PMA is disabled")
1454
+ hub_root = request.app.state.config.root
1455
+ try:
1456
+ ensure_pma_docs(hub_root)
1457
+ except Exception as exc:
1458
+ raise HTTPException(
1459
+ status_code=500, detail=f"Failed to ensure PMA docs: {exc}"
1460
+ ) from exc
1461
+
1462
+ reset = False
1463
+ if isinstance(body, dict):
1464
+ reset = bool(body.get("reset", False))
1465
+
1466
+ 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"
1469
+
1470
+ try:
1471
+ active_content = active_context_path.read_text(encoding="utf-8")
1472
+ except Exception as exc:
1473
+ raise HTTPException(
1474
+ status_code=500, detail=f"Failed to read active_context.md: {exc}"
1475
+ ) from exc
1476
+
1477
+ timestamp = now_iso()
1478
+ snapshot_header = f"\n\n## Snapshot: {timestamp}\n\n"
1479
+ snapshot_content = snapshot_header + active_content
1480
+ snapshot_bytes = len(snapshot_content.encode("utf-8"))
1481
+ if snapshot_bytes > PMA_CONTEXT_SNAPSHOT_MAX_BYTES:
1482
+ raise HTTPException(
1483
+ status_code=413,
1484
+ detail=(
1485
+ "Snapshot too large "
1486
+ f"(max {PMA_CONTEXT_SNAPSHOT_MAX_BYTES} bytes)"
1487
+ ),
1488
+ )
1489
+
1490
+ try:
1491
+ with context_log_path.open("a", encoding="utf-8") as f:
1492
+ f.write(snapshot_content)
1493
+ except Exception as exc:
1494
+ raise HTTPException(
1495
+ status_code=500, detail=f"Failed to append context_log.md: {exc}"
1496
+ ) from exc
1497
+
1498
+ if reset:
1499
+ try:
1500
+ atomic_write(active_context_path, pma_active_context_content())
1501
+ except Exception as exc:
1502
+ raise HTTPException(
1503
+ status_code=500, detail=f"Failed to reset active_context.md: {exc}"
1504
+ ) from exc
1505
+
1506
+ line_count = len(active_content.splitlines())
1507
+ response: dict[str, Any] = {
1508
+ "status": "ok",
1509
+ "timestamp": timestamp,
1510
+ "active_context_line_count": line_count,
1511
+ "reset": reset,
1512
+ }
1513
+ try:
1514
+ context_log_bytes = context_log_path.stat().st_size
1515
+ response["context_log_bytes"] = context_log_bytes
1516
+ if context_log_bytes > PMA_CONTEXT_LOG_SOFT_LIMIT_BYTES:
1517
+ response["warning"] = (
1518
+ "context_log.md is large "
1519
+ f"({context_log_bytes} bytes); consider pruning"
1520
+ )
1521
+ except Exception:
1522
+ pass
1523
+
1524
+ return response
1525
+
1526
+ PMA_DOC_ORDER = (
1527
+ "AGENTS.md",
1528
+ "active_context.md",
1529
+ "context_log.md",
1530
+ "ABOUT_CAR.md",
1531
+ "prompt.md",
1532
+ )
1533
+ PMA_DOC_SET = set(PMA_DOC_ORDER)
1534
+ PMA_DOC_DEFAULTS = {
1535
+ "AGENTS.md": pma_agents_content,
1536
+ "active_context.md": pma_active_context_content,
1537
+ "context_log.md": pma_context_log_content,
1538
+ "ABOUT_CAR.md": pma_about_content,
1539
+ "prompt.md": pma_prompt_content,
1540
+ }
1541
+
1542
+ @router.get("/docs/default/{name}")
1543
+ def get_pma_doc_default(name: str, request: Request) -> dict[str, str]:
1544
+ pma_config = _get_pma_config(request)
1545
+ if not pma_config.get("enabled", True):
1546
+ raise HTTPException(status_code=404, detail="PMA is disabled")
1547
+ if name not in PMA_DOC_SET:
1548
+ raise HTTPException(status_code=400, detail=f"Unknown doc name: {name}")
1549
+ content_fn = PMA_DOC_DEFAULTS.get(name)
1550
+ if content_fn is None:
1551
+ raise HTTPException(status_code=404, detail=f"Default not found: {name}")
1552
+ return {"name": name, "content": content_fn()}
1553
+
1554
+ @router.get("/docs")
1555
+ def list_pma_docs(request: Request) -> dict[str, Any]:
1556
+ pma_config = _get_pma_config(request)
1557
+ if not pma_config.get("enabled", True):
1558
+ raise HTTPException(status_code=404, detail="PMA is disabled")
1559
+ hub_root = request.app.state.config.root
1560
+ pma_dir = hub_root / ".codex-autorunner" / "pma"
1561
+ result: list[dict[str, Any]] = []
1562
+ for doc_name in PMA_DOC_ORDER:
1563
+ doc_path = pma_dir / doc_name
1564
+ entry: dict[str, Any] = {"name": doc_name}
1565
+ if doc_path.exists():
1566
+ entry["exists"] = True
1567
+ stat = doc_path.stat()
1568
+ entry["size"] = stat.st_size
1569
+ entry["mtime"] = datetime.fromtimestamp(
1570
+ stat.st_mtime, tz=timezone.utc
1571
+ ).isoformat()
1572
+ if doc_name == "active_context.md":
1573
+ try:
1574
+ entry["line_count"] = len(
1575
+ doc_path.read_text(encoding="utf-8").splitlines()
1576
+ )
1577
+ except Exception:
1578
+ entry["line_count"] = 0
1579
+ else:
1580
+ entry["exists"] = False
1581
+ result.append(entry)
1582
+ return {
1583
+ "docs": result,
1584
+ "active_context_max_lines": int(
1585
+ pma_config.get("active_context_max_lines", 200)
1586
+ ),
1587
+ }
1588
+
1589
+ @router.get("/docs/{name}")
1590
+ def get_pma_doc(name: str, request: Request) -> dict[str, str]:
1591
+ pma_config = _get_pma_config(request)
1592
+ if not pma_config.get("enabled", True):
1593
+ 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}")
1596
+ hub_root = request.app.state.config.root
1597
+ pma_dir = hub_root / ".codex-autorunner" / "pma"
1598
+ doc_path = pma_dir / name
1599
+ if not doc_path.exists():
1600
+ raise HTTPException(status_code=404, detail=f"Doc not found: {name}")
1601
+ try:
1602
+ content = doc_path.read_text(encoding="utf-8")
1603
+ except Exception as exc:
1604
+ raise HTTPException(
1605
+ status_code=500, detail=f"Failed to read doc: {exc}"
1606
+ ) from exc
1607
+ return {"name": name, "content": content}
1608
+
1609
+ @router.put("/docs/{name}")
1610
+ def update_pma_doc(
1611
+ name: str, request: Request, body: dict[str, str]
1612
+ ) -> dict[str, str]:
1613
+ pma_config = _get_pma_config(request)
1614
+ if not pma_config.get("enabled", True):
1615
+ raise HTTPException(status_code=404, detail="PMA is disabled")
1616
+ if name not in PMA_DOC_SET:
1617
+ raise HTTPException(status_code=400, detail=f"Unknown doc name: {name}")
1618
+ content = body.get("content", "")
1619
+ if not isinstance(content, str):
1620
+ raise HTTPException(status_code=400, detail="content must be a string")
1621
+ MAX_DOC_SIZE = 500_000
1622
+ if len(content) > MAX_DOC_SIZE:
1623
+ raise HTTPException(
1624
+ status_code=413, detail=f"Content too large (max {MAX_DOC_SIZE} bytes)"
1625
+ )
1626
+ hub_root = request.app.state.config.root
1627
+ pma_dir = hub_root / ".codex-autorunner" / "pma"
1628
+ pma_dir.mkdir(parents=True, exist_ok=True)
1629
+ doc_path = pma_dir / name
1630
+ try:
1631
+ atomic_write(doc_path, content)
1632
+ except Exception as exc:
1633
+ raise HTTPException(
1634
+ status_code=500, detail=f"Failed to write doc: {exc}"
1635
+ ) from exc
1636
+ details = {
1637
+ "name": name,
1638
+ "size": len(content.encode("utf-8")),
1639
+ "source": "web",
1640
+ }
1641
+ if name == "active_context.md":
1642
+ details["line_count"] = len(content.splitlines())
1643
+ _get_safety_checker(request).record_action(
1644
+ action_type=PmaActionType.DOC_UPDATED,
1645
+ details=details,
1646
+ )
1647
+ return {"name": name, "status": "ok"}
1648
+
1649
+ return router
1650
+
1651
+
1652
+ __all__ = ["build_pma_routes"]