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
@@ -21,6 +21,7 @@ from fastapi.responses import StreamingResponse
21
21
 
22
22
  from ....agents.registry import validate_agent_id
23
23
  from ....core import drafts as draft_utils
24
+ from ....core.context_awareness import CAR_AWARENESS_BLOCK, format_file_role_addendum
24
25
  from ....core.state import now_iso
25
26
  from ....core.utils import atomic_write, find_repo_root
26
27
  from ....integrations.app_server.event_buffer import format_sse
@@ -50,6 +51,24 @@ class _Target:
50
51
  state_key: str
51
52
 
52
53
 
54
+ @dataclass
55
+ class FileChatRoutesState:
56
+ active_chats: Dict[str, asyncio.Event]
57
+ chat_lock: asyncio.Lock
58
+ turn_lock: asyncio.Lock
59
+ current_by_target: Dict[str, Dict[str, Any]]
60
+ current_by_client: Dict[str, Dict[str, Any]]
61
+ last_by_client: Dict[str, Dict[str, Any]]
62
+
63
+ def __init__(self) -> None:
64
+ self.active_chats = {}
65
+ self.chat_lock = asyncio.Lock()
66
+ self.turn_lock = asyncio.Lock()
67
+ self.current_by_target = {}
68
+ self.current_by_client = {}
69
+ self.last_by_client = {}
70
+
71
+
53
72
  def _state_path(repo_root: Path) -> Path:
54
73
  return draft_utils.state_path(repo_root)
55
74
 
@@ -146,41 +165,20 @@ def _parse_target(repo_root: Path, raw: str) -> _Target:
146
165
  def _build_file_chat_prompt(*, target: _Target, message: str, before: str) -> str:
147
166
  if target.kind == "ticket":
148
167
  file_role_context = (
149
- "This file is a CAR ticket. Ticket flow processes "
150
- "`.codex-autorunner/tickets/TICKET-###*.md` in numeric order.\n"
168
+ f"{format_file_role_addendum('ticket', target.rel_path)}\n"
151
169
  "Edits here change what the ticket flow agent will do; keep YAML "
152
170
  "frontmatter valid."
153
171
  )
154
172
  elif target.kind == "workspace":
155
173
  file_role_context = (
156
- "This file is a CAR workspace doc under `.codex-autorunner/workspace/`."
157
- " These docs act as shared memory across ticket turns."
174
+ f"{format_file_role_addendum('workspace', target.rel_path)}\n"
175
+ "These docs act as shared memory across ticket turns."
158
176
  )
159
177
  else:
160
- file_role_context = (
161
- "This file is a normal repo file (not a CAR ticket/workspace doc)."
162
- )
178
+ file_role_context = format_file_role_addendum("other", target.rel_path)
163
179
 
164
180
  return (
165
- "<injected context>\n"
166
- "You are operating inside a Codex Autorunner (CAR) managed repo.\n\n"
167
- "CAR’s durable control-plane lives under `.codex-autorunner/`:\n"
168
- "- `.codex-autorunner/ABOUT_CAR.md` — short repo-local briefing "
169
- "(ticket/workspace conventions + helper scripts).\n"
170
- "- `.codex-autorunner/tickets/` — ordered ticket queue "
171
- "(`TICKET-###*.md`) used by the ticket flow runner.\n"
172
- "- `.codex-autorunner/workspace/` — shared context docs:\n"
173
- " - `active_context.md` — current “north star” context; kept fresh "
174
- "for ongoing work.\n"
175
- " - `spec.md` — longer spec / acceptance criteria when needed.\n"
176
- " - `decisions.md` — prior decisions / tradeoffs when relevant.\n\n"
177
- "Intent signals: if the user mentions tickets, “dispatch”, “resume”, "
178
- "workspace docs, or `.codex-autorunner/`, they are likely referring "
179
- "to CAR artifacts/workflow rather than generic repo files.\n\n"
180
- "Use the above as orientation. If you need the operational details "
181
- "(exact helper commands, what CAR auto-generates), read "
182
- "`.codex-autorunner/ABOUT_CAR.md`.\n"
183
- "</injected context>\n\n"
181
+ f"{CAR_AWARENESS_BLOCK}\n\n"
184
182
  "<file_role_context>\n"
185
183
  f"{file_role_context}\n"
186
184
  "</file_role_context>\n\n"
@@ -225,25 +223,32 @@ def _build_patch(rel_path: str, before: str, after: str) -> str:
225
223
 
226
224
  def build_file_chat_routes() -> APIRouter:
227
225
  router = APIRouter(prefix="/api", tags=["file-chat"])
228
- _active_chats: Dict[str, asyncio.Event] = {}
229
- _chat_lock = asyncio.Lock()
230
- _turn_lock = asyncio.Lock()
231
- _current_by_target: Dict[str, Dict[str, Any]] = {}
232
- _current_by_client: Dict[str, Dict[str, Any]] = {}
233
- _last_by_client: Dict[str, Dict[str, Any]] = {}
234
-
235
- async def _get_or_create_interrupt_event(key: str) -> asyncio.Event:
236
- async with _chat_lock:
237
- if key not in _active_chats:
238
- _active_chats[key] = asyncio.Event()
239
- return _active_chats[key]
240
-
241
- async def _clear_interrupt_event(key: str) -> None:
242
- async with _chat_lock:
243
- _active_chats.pop(key, None)
244
-
245
- async def _begin_turn_state(target: _Target, client_turn_id: Optional[str]) -> None:
246
- async with _turn_lock:
226
+ state = FileChatRoutesState()
227
+
228
+ def _get_state(request: Request) -> FileChatRoutesState:
229
+ if not hasattr(request.app.state, "file_chat_routes_state"):
230
+ request.app.state.file_chat_routes_state = state
231
+ return request.app.state.file_chat_routes_state
232
+
233
+ async def _get_or_create_interrupt_event(
234
+ request: Request, key: str
235
+ ) -> asyncio.Event:
236
+ s = _get_state(request)
237
+ async with s.chat_lock:
238
+ if key not in s.active_chats:
239
+ s.active_chats[key] = asyncio.Event()
240
+ return s.active_chats[key]
241
+
242
+ async def _clear_interrupt_event(request: Request, key: str) -> None:
243
+ s = _get_state(request)
244
+ async with s.chat_lock:
245
+ s.active_chats.pop(key, None)
246
+
247
+ async def _begin_turn_state(
248
+ request: Request, target: _Target, client_turn_id: Optional[str]
249
+ ) -> None:
250
+ s = _get_state(request)
251
+ async with s.turn_lock:
247
252
  state: Dict[str, Any] = {
248
253
  "client_turn_id": client_turn_id or "",
249
254
  "target": target.target,
@@ -252,13 +257,16 @@ def build_file_chat_routes() -> APIRouter:
252
257
  "thread_id": None,
253
258
  "turn_id": None,
254
259
  }
255
- _current_by_target[target.state_key] = state
260
+ s.current_by_target[target.state_key] = state
256
261
  if client_turn_id:
257
- _current_by_client[client_turn_id] = state
258
-
259
- async def _update_turn_state(target: _Target, **updates: Any) -> None:
260
- async with _turn_lock:
261
- state = _current_by_target.get(target.state_key)
262
+ s.current_by_client[client_turn_id] = state
263
+
264
+ async def _update_turn_state(
265
+ request: Request, target: _Target, **updates: Any
266
+ ) -> None:
267
+ s = _get_state(request)
268
+ async with s.turn_lock:
269
+ state = s.current_by_target.get(target.state_key)
262
270
  if not state:
263
271
  return
264
272
  for key, value in updates.items():
@@ -267,34 +275,45 @@ def build_file_chat_routes() -> APIRouter:
267
275
  state[key] = value
268
276
  cid = state.get("client_turn_id") or ""
269
277
  if cid:
270
- _current_by_client[cid] = state
271
-
272
- async def _finalize_turn_state(target: _Target, result: Dict[str, Any]) -> None:
273
- async with _turn_lock:
274
- state = _current_by_target.pop(target.state_key, None)
278
+ s.current_by_client[cid] = state
279
+
280
+ async def _finalize_turn_state(
281
+ request: Request, target: _Target, result: Dict[str, Any]
282
+ ) -> None:
283
+ s = _get_state(request)
284
+ async with s.turn_lock:
285
+ state = s.current_by_target.pop(target.state_key, None)
275
286
  cid = ""
276
287
  if state:
277
288
  cid = state.get("client_turn_id", "") or ""
278
289
  if cid:
279
- _current_by_client.pop(cid, None)
280
- _last_by_client[cid] = dict(result or {})
290
+ s.current_by_client.pop(cid, None)
291
+ s.last_by_client[cid] = dict(result or {})
281
292
 
282
- async def _active_for_client(client_turn_id: Optional[str]) -> Dict[str, Any]:
293
+ async def _active_for_client(
294
+ request: Request, client_turn_id: Optional[str]
295
+ ) -> Dict[str, Any]:
283
296
  if not client_turn_id:
284
297
  return {}
285
- async with _turn_lock:
286
- return dict(_current_by_client.get(client_turn_id, {}))
298
+ s = _get_state(request)
299
+ async with s.turn_lock:
300
+ return dict(s.current_by_client.get(client_turn_id, {}))
287
301
 
288
- async def _last_for_client(client_turn_id: Optional[str]) -> Dict[str, Any]:
302
+ async def _last_for_client(
303
+ request: Request, client_turn_id: Optional[str]
304
+ ) -> Dict[str, Any]:
289
305
  if not client_turn_id:
290
306
  return {}
291
- async with _turn_lock:
292
- return dict(_last_by_client.get(client_turn_id, {}))
307
+ s = _get_state(request)
308
+ async with s.turn_lock:
309
+ return dict(s.last_by_client.get(client_turn_id, {}))
293
310
 
294
311
  @router.get("/file-chat/active")
295
- async def file_chat_active(client_turn_id: Optional[str] = None) -> Dict[str, Any]:
296
- current = await _active_for_client(client_turn_id)
297
- last = await _last_for_client(client_turn_id)
312
+ async def file_chat_active(
313
+ request: Request, client_turn_id: Optional[str] = None
314
+ ) -> Dict[str, Any]:
315
+ current = await _active_for_client(request, client_turn_id)
316
+ last = await _last_for_client(request, client_turn_id)
298
317
  return {"active": bool(current), "current": current, "last_result": last}
299
318
 
300
319
  @router.post("/file-chat")
@@ -320,13 +339,14 @@ def build_file_chat_routes() -> APIRouter:
320
339
  target.path.parent.mkdir(parents=True, exist_ok=True)
321
340
 
322
341
  # Concurrency guard per target
323
- async with _chat_lock:
324
- existing = _active_chats.get(target.state_key)
342
+ s = _get_state(request)
343
+ async with s.chat_lock:
344
+ existing = s.active_chats.get(target.state_key)
325
345
  if existing is not None and not existing.is_set():
326
346
  raise HTTPException(status_code=409, detail="File chat already running")
327
- _active_chats[target.state_key] = asyncio.Event()
347
+ s.active_chats[target.state_key] = asyncio.Event()
328
348
 
329
- await _begin_turn_state(target, client_turn_id)
349
+ await _begin_turn_state(request, target, client_turn_id)
330
350
 
331
351
  if stream:
332
352
  return StreamingResponse(
@@ -349,6 +369,7 @@ def build_file_chat_routes() -> APIRouter:
349
369
 
350
370
  async def _on_meta(agent_id: str, thread_id: str, turn_id: str) -> None:
351
371
  await _update_turn_state(
372
+ request,
352
373
  target,
353
374
  agent=agent_id,
354
375
  thread_id=thread_id,
@@ -368,6 +389,7 @@ def build_file_chat_routes() -> APIRouter:
368
389
  )
369
390
  except Exception as exc:
370
391
  await _finalize_turn_state(
392
+ request,
371
393
  target,
372
394
  {
373
395
  "status": "error",
@@ -378,10 +400,10 @@ def build_file_chat_routes() -> APIRouter:
378
400
  raise
379
401
  result = dict(result or {})
380
402
  result["client_turn_id"] = client_turn_id or ""
381
- await _finalize_turn_state(target, result)
403
+ await _finalize_turn_state(request, target, result)
382
404
  return result
383
405
  finally:
384
- await _clear_interrupt_event(target.state_key)
406
+ await _clear_interrupt_event(request, target.state_key)
385
407
 
386
408
  async def _stream_file_chat(
387
409
  request: Request,
@@ -399,6 +421,7 @@ def build_file_chat_routes() -> APIRouter:
399
421
 
400
422
  async def _on_meta(agent_id: str, thread_id: str, turn_id: str) -> None:
401
423
  await _update_turn_state(
424
+ request,
402
425
  target,
403
426
  agent=agent_id,
404
427
  thread_id=thread_id,
@@ -430,7 +453,7 @@ def build_file_chat_routes() -> APIRouter:
430
453
  }
431
454
  result = dict(result or {})
432
455
  result["client_turn_id"] = client_turn_id or ""
433
- await _finalize_turn_state(target, result)
456
+ await _finalize_turn_state(request, target, result)
434
457
 
435
458
  asyncio.create_task(_finalize())
436
459
 
@@ -463,7 +486,7 @@ def build_file_chat_routes() -> APIRouter:
463
486
  logger.exception("file chat stream failed")
464
487
  yield format_sse("error", {"detail": "File chat failed"})
465
488
  finally:
466
- await _clear_interrupt_event(target.state_key)
489
+ await _clear_interrupt_event(request, target.state_key)
467
490
 
468
491
  async def _execute_file_chat(
469
492
  request: Request,
@@ -499,7 +522,9 @@ def build_file_chat_routes() -> APIRouter:
499
522
 
500
523
  prompt = _build_file_chat_prompt(target=target, message=message, before=before)
501
524
 
502
- interrupt_event = await _get_or_create_interrupt_event(target.state_key)
525
+ interrupt_event = await _get_or_create_interrupt_event(
526
+ request, target.state_key
527
+ )
503
528
  if interrupt_event.is_set():
504
529
  return {"status": "interrupted", "detail": "File chat interrupted"}
505
530
 
@@ -509,7 +534,7 @@ def build_file_chat_routes() -> APIRouter:
509
534
  agent_id = "codex"
510
535
 
511
536
  thread_key = f"file_chat.{target.state_key}"
512
- await _update_turn_state(target, status="running", agent=agent_id)
537
+ await _update_turn_state(request, target, status="running", agent=agent_id)
513
538
 
514
539
  if agent_id == "opencode":
515
540
  if opencode is None:
@@ -972,8 +997,9 @@ def build_file_chat_routes() -> APIRouter:
972
997
  body = await request.json()
973
998
  repo_root = _resolve_repo_root(request)
974
999
  resolved = _parse_target(repo_root, str(body.get("target") or ""))
975
- async with _chat_lock:
976
- ev = _active_chats.get(resolved.state_key)
1000
+ s = _get_state(request)
1001
+ async with s.chat_lock:
1002
+ ev = s.active_chats.get(resolved.state_key)
977
1003
  if ev is None:
978
1004
  return {"status": "ok", "detail": "No active chat to interrupt"}
979
1005
  ev.set()
@@ -997,14 +1023,15 @@ def build_file_chat_routes() -> APIRouter:
997
1023
  repo_root = _resolve_repo_root(request)
998
1024
  target = _parse_target(repo_root, f"ticket:{int(index)}")
999
1025
 
1000
- async with _chat_lock:
1001
- existing = _active_chats.get(target.state_key)
1026
+ s = _get_state(request)
1027
+ async with s.chat_lock:
1028
+ existing = s.active_chats.get(target.state_key)
1002
1029
  if existing is not None and not existing.is_set():
1003
1030
  raise HTTPException(
1004
1031
  status_code=409, detail="Ticket chat already running"
1005
1032
  )
1006
- _active_chats[target.state_key] = asyncio.Event()
1007
- await _begin_turn_state(target, client_turn_id)
1033
+ s.active_chats[target.state_key] = asyncio.Event()
1034
+ await _begin_turn_state(request, target, client_turn_id)
1008
1035
 
1009
1036
  if stream:
1010
1037
  return StreamingResponse(
@@ -1034,10 +1061,10 @@ def build_file_chat_routes() -> APIRouter:
1034
1061
  )
1035
1062
  result = dict(result or {})
1036
1063
  result["client_turn_id"] = client_turn_id or ""
1037
- await _finalize_turn_state(target, result)
1064
+ await _finalize_turn_state(request, target, result)
1038
1065
  return result
1039
1066
  finally:
1040
- await _clear_interrupt_event(target.state_key)
1067
+ await _clear_interrupt_event(request, target.state_key)
1041
1068
 
1042
1069
  @router.get("/tickets/{index}/chat/pending")
1043
1070
  async def pending_ticket_patch(index: int, request: Request):
@@ -1107,8 +1134,9 @@ def build_file_chat_routes() -> APIRouter:
1107
1134
  async def interrupt_ticket_chat(index: int, request: Request):
1108
1135
  repo_root = _resolve_repo_root(request)
1109
1136
  target = _parse_target(repo_root, f"ticket:{int(index)}")
1110
- async with _chat_lock:
1111
- ev = _active_chats.get(target.state_key)
1137
+ s = _get_state(request)
1138
+ async with s.chat_lock:
1139
+ ev = s.active_chats.get(target.state_key)
1112
1140
  if ev is None:
1113
1141
  return {"status": "ok", "detail": "No active chat to interrupt"}
1114
1142
  ev.set()