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
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import dataclasses
4
5
  import json
5
6
  import logging
6
7
  import math
@@ -27,16 +28,23 @@ from .....agents.opencode.runtime import (
27
28
  split_model_id,
28
29
  )
29
30
  from .....agents.opencode.supervisor import OpenCodeSupervisorError
30
- from .....core.about_car import CAR_CONTEXT_HINT, CAR_CONTEXT_KEYWORDS
31
+ from .....core.app_server_threads import (
32
+ PMA_KEY,
33
+ PMA_OPENCODE_KEY,
34
+ AppServerThreadRegistry,
35
+ )
31
36
  from .....core.config import load_repo_config
37
+ from .....core.context_awareness import CAR_AWARENESS_BLOCK
32
38
  from .....core.injected_context import wrap_injected_context
33
39
  from .....core.logging_utils import log_event
40
+ from .....core.pma_context import build_hub_snapshot, format_pma_prompt, load_pma_prompt
34
41
  from .....core.state import now_iso
35
42
  from .....core.utils import canonicalize_path
36
43
  from .....integrations.github.service import GitHubService
37
44
  from ....app_server.client import (
38
45
  CodexAppServerClient,
39
46
  CodexAppServerDisconnected,
47
+ CodexAppServerResponseError,
40
48
  _normalize_sandbox_policy,
41
49
  )
42
50
  from ...adapter import (
@@ -75,6 +83,7 @@ from ...helpers import (
75
83
  find_github_links,
76
84
  is_interrupt_status,
77
85
  )
86
+ from ...state import topic_key as build_topic_key
78
87
 
79
88
  if TYPE_CHECKING:
80
89
  from ...state import TelegramTopicRecord
@@ -547,18 +556,11 @@ class ExecutionCommands(SharedHelpers):
547
556
  return f"{prompt_text}{separator}{injection}", True
548
557
 
549
558
  def _maybe_inject_car_context(self, prompt_text: str) -> tuple[str, bool]:
550
- if not prompt_text or not prompt_text.strip():
551
- return prompt_text, False
552
- lowered = prompt_text.lower()
553
- if "about_car.md" in lowered:
554
- return prompt_text, False
555
- if CAR_CONTEXT_HINT in prompt_text:
559
+ if CAR_AWARENESS_BLOCK in prompt_text:
556
560
  return prompt_text, False
557
- if not any(keyword in lowered for keyword in CAR_CONTEXT_KEYWORDS):
558
- return prompt_text, False
559
- separator = "\n" if prompt_text.endswith("\n") else "\n\n"
560
- injection = wrap_injected_context(CAR_CONTEXT_HINT)
561
- return f"{prompt_text}{separator}{injection}", True
561
+ if not prompt_text or not prompt_text.strip():
562
+ return CAR_AWARENESS_BLOCK, True
563
+ return f"{CAR_AWARENESS_BLOCK}\n\n{prompt_text}", True
562
564
 
563
565
  def _maybe_inject_outbox_context(
564
566
  self,
@@ -1046,6 +1048,8 @@ class ExecutionCommands(SharedHelpers):
1046
1048
  missing_thread_message: Optional[str],
1047
1049
  transcript_message_id: Optional[int],
1048
1050
  transcript_text: Optional[str],
1051
+ pma_thread_registry: Optional[AppServerThreadRegistry] = None,
1052
+ pma_thread_key: Optional[str] = None,
1049
1053
  ) -> _TurnRunResult | _TurnRunFailure:
1050
1054
  supervisor = getattr(self, "_opencode_supervisor", None)
1051
1055
  if supervisor is None:
@@ -1109,6 +1113,7 @@ class ExecutionCommands(SharedHelpers):
1109
1113
  transcript_text,
1110
1114
  )
1111
1115
 
1116
+ pma_mode = bool(pma_thread_registry and pma_thread_key)
1112
1117
  try:
1113
1118
  if not thread_id:
1114
1119
  if not allow_new_thread:
@@ -1164,27 +1169,34 @@ class ExecutionCommands(SharedHelpers):
1164
1169
  rollout_path=record.rollout_path,
1165
1170
  )
1166
1171
 
1167
- record = await self._router.update_topic(
1168
- message.chat_id, message.thread_id, apply
1169
- )
1172
+ if pma_mode:
1173
+ pma_thread_registry.set_thread_id(pma_thread_key, thread_id)
1174
+ else:
1175
+ record = await self._router.update_topic(
1176
+ message.chat_id, message.thread_id, apply
1177
+ )
1170
1178
  else:
1171
- record = await self._router.set_active_thread(
1172
- message.chat_id, message.thread_id, thread_id
1173
- )
1179
+ if not pma_mode:
1180
+ record = await self._router.set_active_thread(
1181
+ message.chat_id, message.thread_id, thread_id
1182
+ )
1174
1183
 
1175
- user_preview = _preview_from_text(prompt_text, RESUME_PREVIEW_USER_LIMIT)
1176
- await self._router.update_topic(
1177
- message.chat_id,
1178
- message.thread_id,
1179
- lambda record: _set_thread_summary(
1180
- record,
1181
- thread_id,
1182
- user_preview=user_preview,
1183
- last_used_at=now_iso(),
1184
- workspace_path=record.workspace_path,
1185
- rollout_path=record.rollout_path,
1186
- ),
1187
- )
1184
+ if not pma_mode:
1185
+ user_preview = _preview_from_text(
1186
+ prompt_text, RESUME_PREVIEW_USER_LIMIT
1187
+ )
1188
+ await self._router.update_topic(
1189
+ message.chat_id,
1190
+ message.thread_id,
1191
+ lambda record: _set_thread_summary(
1192
+ record,
1193
+ thread_id,
1194
+ user_preview=user_preview,
1195
+ last_used_at=now_iso(),
1196
+ workspace_path=record.workspace_path,
1197
+ rollout_path=record.rollout_path,
1198
+ ),
1199
+ )
1188
1200
 
1189
1201
  pending_seed = None
1190
1202
  pending_seed_thread_id = record.pending_compact_seed_thread_id
@@ -1924,11 +1936,43 @@ class ExecutionCommands(SharedHelpers):
1924
1936
  missing_thread_message: Optional[str],
1925
1937
  transcript_message_id: Optional[int],
1926
1938
  transcript_text: Optional[str],
1939
+ pma_thread_registry: Optional[AppServerThreadRegistry] = None,
1940
+ pma_thread_key: Optional[str] = None,
1927
1941
  ) -> _TurnRunResult | _TurnRunFailure:
1928
1942
  turn_handle = None
1929
1943
  turn_key: Optional[TurnKey] = None
1930
1944
  turn_started_at: Optional[float] = None
1931
1945
 
1946
+ def _is_missing_thread_error(exc: Exception) -> bool:
1947
+ if not isinstance(exc, CodexAppServerResponseError):
1948
+ return False
1949
+ message = str(exc).lower()
1950
+ return "thread not found" in message
1951
+
1952
+ async def _start_new_thread(agent: str) -> Optional[str]:
1953
+ nonlocal record
1954
+ workspace_path = record.workspace_path
1955
+ if not workspace_path:
1956
+ return None
1957
+ thread = await client.thread_start(workspace_path, agent=agent)
1958
+ if not await self._require_thread_workspace(
1959
+ message, workspace_path, thread, action="thread_start"
1960
+ ):
1961
+ return None
1962
+ new_thread_id = _extract_thread_id(thread)
1963
+ if not new_thread_id:
1964
+ return None
1965
+ if pma_mode and pma_thread_registry and pma_thread_key:
1966
+ pma_thread_registry.set_thread_id(pma_thread_key, new_thread_id)
1967
+ elif not pma_mode:
1968
+ record = await self._apply_thread_result(
1969
+ message.chat_id,
1970
+ message.thread_id,
1971
+ thread,
1972
+ active_thread_id=new_thread_id,
1973
+ )
1974
+ return new_thread_id
1975
+
1932
1976
  try:
1933
1977
  client = await self._client_for_workspace(record.workspace_path)
1934
1978
  except AppServerUnavailableError as exc:
@@ -1966,6 +2010,7 @@ class ExecutionCommands(SharedHelpers):
1966
2010
  failure_message, None, transcript_message_id, transcript_text
1967
2011
  )
1968
2012
 
2013
+ pma_mode = bool(pma_thread_registry and pma_thread_key)
1969
2014
  try:
1970
2015
  if not thread_id:
1971
2016
  if not allow_new_thread:
@@ -1986,26 +2031,8 @@ class ExecutionCommands(SharedHelpers):
1986
2031
  transcript_message_id,
1987
2032
  transcript_text,
1988
2033
  )
1989
- workspace_path = record.workspace_path
1990
- if not workspace_path:
1991
- return _TurnRunFailure(
1992
- "Workspace missing.",
1993
- None,
1994
- transcript_message_id,
1995
- transcript_text,
1996
- )
1997
2034
  agent = self._effective_agent(record)
1998
- thread = await client.thread_start(workspace_path, agent=agent)
1999
- if not await self._require_thread_workspace(
2000
- message, workspace_path, thread, action="thread_start"
2001
- ):
2002
- return _TurnRunFailure(
2003
- "Thread workspace mismatch.",
2004
- None,
2005
- transcript_message_id,
2006
- transcript_text,
2007
- )
2008
- thread_id = _extract_thread_id(thread)
2035
+ thread_id = await _start_new_thread(agent)
2009
2036
  if not thread_id:
2010
2037
  failure_message = "Failed to start a new thread."
2011
2038
  if send_failure_response:
@@ -2021,18 +2048,13 @@ class ExecutionCommands(SharedHelpers):
2021
2048
  transcript_message_id,
2022
2049
  transcript_text,
2023
2050
  )
2024
- record = await self._apply_thread_result(
2025
- message.chat_id,
2026
- message.thread_id,
2027
- thread,
2028
- active_thread_id=thread_id,
2029
- )
2030
2051
  else:
2031
- record = await self._router.set_active_thread(
2032
- message.chat_id, message.thread_id, thread_id
2033
- )
2052
+ if not pma_mode:
2053
+ record = await self._router.set_active_thread(
2054
+ message.chat_id, message.thread_id, thread_id
2055
+ )
2034
2056
 
2035
- if thread_id:
2057
+ if thread_id and not pma_mode:
2036
2058
  user_preview = _preview_from_text(
2037
2059
  prompt_text, RESUME_PREVIEW_USER_LIMIT
2038
2060
  )
@@ -2052,7 +2074,9 @@ class ExecutionCommands(SharedHelpers):
2052
2074
  pending_seed = None
2053
2075
  pending_seed_thread_id = record.pending_compact_seed_thread_id
2054
2076
  if record.pending_compact_seed:
2055
- if pending_seed_thread_id is None:
2077
+ if pma_mode:
2078
+ pending_seed = None
2079
+ elif pending_seed_thread_id is None:
2056
2080
  pending_seed = record.pending_compact_seed
2057
2081
  elif thread_id and pending_seed_thread_id == thread_id:
2058
2082
  pending_seed = record.pending_compact_seed
@@ -2147,14 +2171,69 @@ class ExecutionCommands(SharedHelpers):
2147
2171
  PLACEHOLDER_TEXT,
2148
2172
  )
2149
2173
 
2150
- turn_handle = await client.turn_start(
2151
- thread_id,
2152
- prompt_text,
2153
- input_items=input_items,
2154
- approval_policy=approval_policy,
2155
- sandbox_policy=sandbox_policy,
2156
- **turn_kwargs,
2157
- )
2174
+ try:
2175
+ turn_handle = await client.turn_start(
2176
+ thread_id,
2177
+ prompt_text,
2178
+ input_items=input_items,
2179
+ approval_policy=approval_policy,
2180
+ sandbox_policy=sandbox_policy,
2181
+ **turn_kwargs,
2182
+ )
2183
+ except Exception as exc:
2184
+ if (
2185
+ pma_mode
2186
+ and _is_missing_thread_error(exc)
2187
+ and pma_thread_registry
2188
+ and pma_thread_key
2189
+ ):
2190
+ log_event(
2191
+ self._logger,
2192
+ logging.WARNING,
2193
+ "telegram.pma.thread.reset",
2194
+ topic_key=key,
2195
+ chat_id=message.chat_id,
2196
+ thread_id=message.thread_id,
2197
+ codex_thread_id=thread_id,
2198
+ reason="thread_not_found",
2199
+ )
2200
+ pma_thread_registry.reset_thread(pma_thread_key)
2201
+ if not allow_new_thread:
2202
+ failure_message = (
2203
+ "PMA thread no longer exists. Send a new message to "
2204
+ "start a PMA thread, then retry /compact."
2205
+ )
2206
+ if send_failure_response:
2207
+ await self._send_message(
2208
+ message.chat_id,
2209
+ failure_message,
2210
+ thread_id=message.thread_id,
2211
+ reply_to=message.message_id,
2212
+ )
2213
+ if placeholder_id is not None:
2214
+ await self._delete_message(
2215
+ message.chat_id, placeholder_id
2216
+ )
2217
+ return _TurnRunFailure(
2218
+ failure_message,
2219
+ placeholder_id,
2220
+ transcript_message_id,
2221
+ transcript_text,
2222
+ )
2223
+ agent = self._effective_agent(record)
2224
+ thread_id = await _start_new_thread(agent)
2225
+ if thread_id is None:
2226
+ raise
2227
+ turn_handle = await client.turn_start(
2228
+ thread_id,
2229
+ prompt_text,
2230
+ input_items=input_items,
2231
+ approval_policy=approval_policy,
2232
+ sandbox_policy=sandbox_policy,
2233
+ **turn_kwargs,
2234
+ )
2235
+ else:
2236
+ raise
2158
2237
  if pending_seed:
2159
2238
  await self._router.update_topic(
2160
2239
  message.chat_id,
@@ -2388,6 +2467,41 @@ class ExecutionCommands(SharedHelpers):
2388
2467
  )
2389
2468
  return prompt_text
2390
2469
 
2470
+ def _pma_registry_key(
2471
+ self, record: "TelegramTopicRecord", message: Optional[TelegramMessage] = None
2472
+ ) -> str:
2473
+ """
2474
+ Return PMA thread registry key.
2475
+
2476
+ Thread scoping decision:
2477
+ - When require_topics is false (default): use global keys (pma/pma.opencode).
2478
+ All Telegram topics share one PMA conversation per agent.
2479
+ - When require_topics is true: use per-topic keys (pma.{topic_key}/pma.opencode.{topic_key}).
2480
+ Each Telegram topic gets its own isolated PMA conversation.
2481
+
2482
+ This allows hubs with multiple topics to maintain separate PMA contexts
2483
+ when require_topics is enabled, while keeping a single shared context
2484
+ in the common case (require_topics disabled).
2485
+ """
2486
+ agent = self._effective_agent(record)
2487
+ base_key = PMA_OPENCODE_KEY if agent == "opencode" else PMA_KEY
2488
+
2489
+ # PMA thread scoping: per-topic when require_topics is true
2490
+ require_topics = getattr(self._config, "require_topics", False)
2491
+ if require_topics and message is not None:
2492
+ topic_key = build_topic_key(message.chat_id, message.thread_id)
2493
+ return f"{base_key}.{topic_key}"
2494
+ return base_key
2495
+
2496
+ async def _prepare_pma_prompt(self, message_text: str) -> Optional[str]:
2497
+ hub_root = getattr(self, "_hub_root", None)
2498
+ if hub_root is None:
2499
+ return None
2500
+ supervisor = getattr(self, "_hub_supervisor", None)
2501
+ snapshot = await build_hub_snapshot(supervisor, hub_root=Path(hub_root))
2502
+ base_prompt = load_pma_prompt(hub_root)
2503
+ return format_pma_prompt(base_prompt, snapshot, message_text, hub_root=hub_root)
2504
+
2391
2505
  async def _prepare_turn_context(
2392
2506
  self,
2393
2507
  message: TelegramMessage,
@@ -2493,6 +2607,26 @@ class ExecutionCommands(SharedHelpers):
2493
2607
  ) -> _TurnRunResult | _TurnRunFailure:
2494
2608
  key = await self._resolve_topic_key(message.chat_id, message.thread_id)
2495
2609
  record = record or await self._router.get_topic(key)
2610
+ pma_enabled = bool(record and getattr(record, "pma_enabled", False))
2611
+ if pma_enabled:
2612
+ hub_root = getattr(self, "_hub_root", None)
2613
+ if hub_root is None:
2614
+ failure_message = "PMA unavailable; hub root not configured."
2615
+ if send_failure_response:
2616
+ await self._send_message(
2617
+ message.chat_id,
2618
+ failure_message,
2619
+ thread_id=message.thread_id,
2620
+ reply_to=message.message_id,
2621
+ )
2622
+ return _TurnRunFailure(
2623
+ failure_message, None, transcript_message_id, transcript_text
2624
+ )
2625
+ if record is None:
2626
+ from ...state import TelegramTopicRecord
2627
+
2628
+ record = TelegramTopicRecord(pma_enabled=True)
2629
+ record = dataclasses.replace(record, workspace_path=str(hub_root))
2496
2630
  if record is None or not record.workspace_path:
2497
2631
  failure_message = "Topic not bound. Use /bind <repo_id> or /bind <path>."
2498
2632
  if send_failure_response:
@@ -2506,7 +2640,7 @@ class ExecutionCommands(SharedHelpers):
2506
2640
  failure_message, None, transcript_message_id, transcript_text
2507
2641
  )
2508
2642
 
2509
- if record.active_thread_id:
2643
+ if record.active_thread_id and not pma_enabled:
2510
2644
  conflict_key = await self._find_thread_conflict(
2511
2645
  record.active_thread_id,
2512
2646
  key=key,
@@ -2536,16 +2670,40 @@ class ExecutionCommands(SharedHelpers):
2536
2670
  )
2537
2671
  record = verified
2538
2672
 
2539
- thread_id = record.active_thread_id
2673
+ pma_thread_registry = (
2674
+ getattr(self, "_hub_thread_registry", None) if pma_enabled else None
2675
+ )
2676
+ pma_thread_key = (
2677
+ self._pma_registry_key(record, message) if pma_enabled else None
2678
+ )
2679
+ thread_id = None if pma_enabled else record.active_thread_id
2680
+ if pma_enabled and pma_thread_registry and pma_thread_key:
2681
+ thread_id = pma_thread_registry.get_thread_id(pma_thread_key)
2540
2682
  prompt_text = (
2541
2683
  text_override if text_override is not None else (message.text or "")
2542
2684
  )
2543
2685
  prompt_text = self._prepare_turn_prompt(
2544
2686
  prompt_text, transcript_text=transcript_text
2545
2687
  )
2546
- prompt_text, key = await self._prepare_turn_context(
2547
- message, prompt_text, record
2548
- )
2688
+ if pma_enabled:
2689
+ pma_prompt = await self._prepare_pma_prompt(prompt_text)
2690
+ if pma_prompt is None:
2691
+ failure_message = "PMA unavailable; hub snapshot failed."
2692
+ if send_failure_response:
2693
+ await self._send_message(
2694
+ message.chat_id,
2695
+ failure_message,
2696
+ thread_id=message.thread_id,
2697
+ reply_to=message.message_id,
2698
+ )
2699
+ return _TurnRunFailure(
2700
+ failure_message, None, transcript_message_id, transcript_text
2701
+ )
2702
+ prompt_text = pma_prompt
2703
+ else:
2704
+ prompt_text, key = await self._prepare_turn_context(
2705
+ message, prompt_text, record
2706
+ )
2549
2707
 
2550
2708
  turn_semaphore = self._ensure_turn_semaphore()
2551
2709
  queued = turn_semaphore.locked()
@@ -2574,6 +2732,8 @@ class ExecutionCommands(SharedHelpers):
2574
2732
  missing_thread_message=missing_thread_message,
2575
2733
  transcript_message_id=transcript_message_id,
2576
2734
  transcript_text=transcript_text,
2735
+ pma_thread_registry=pma_thread_registry,
2736
+ pma_thread_key=pma_thread_key,
2577
2737
  )
2578
2738
 
2579
2739
  return await self._execute_codex_turn(
@@ -2592,4 +2752,6 @@ class ExecutionCommands(SharedHelpers):
2592
2752
  missing_thread_message=missing_thread_message,
2593
2753
  transcript_message_id=transcript_message_id,
2594
2754
  transcript_text=transcript_text,
2755
+ pma_thread_registry=pma_thread_registry,
2756
+ pma_thread_key=pma_thread_key,
2595
2757
  )