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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) 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 +124 -11
  7. codex_autorunner/core/app_server_threads.py +6 -0
  8. codex_autorunner/core/config.py +238 -3
  9. codex_autorunner/core/context_awareness.py +39 -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 +683 -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/adapter.py +1 -1
  58. codex_autorunner/integrations/telegram/config.py +1 -1
  59. codex_autorunner/integrations/telegram/doctor.py +228 -6
  60. codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
  61. codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
  62. codex_autorunner/integrations/telegram/handlers/commands/flows.py +346 -58
  63. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
  64. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +202 -45
  65. codex_autorunner/integrations/telegram/handlers/commands_spec.py +18 -7
  66. codex_autorunner/integrations/telegram/handlers/messages.py +34 -3
  67. codex_autorunner/integrations/telegram/helpers.py +1 -3
  68. codex_autorunner/integrations/telegram/runtime.py +9 -4
  69. codex_autorunner/integrations/telegram/service.py +30 -0
  70. codex_autorunner/integrations/telegram/state.py +38 -0
  71. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +10 -4
  72. codex_autorunner/integrations/telegram/transport.py +10 -3
  73. codex_autorunner/integrations/templates/__init__.py +27 -0
  74. codex_autorunner/integrations/templates/scan_agent.py +312 -0
  75. codex_autorunner/server.py +2 -2
  76. codex_autorunner/static/agentControls.js +21 -5
  77. codex_autorunner/static/app.js +115 -11
  78. codex_autorunner/static/archive.js +274 -81
  79. codex_autorunner/static/archiveApi.js +21 -0
  80. codex_autorunner/static/chatUploads.js +137 -0
  81. codex_autorunner/static/constants.js +1 -1
  82. codex_autorunner/static/docChatCore.js +185 -13
  83. codex_autorunner/static/fileChat.js +68 -40
  84. codex_autorunner/static/fileboxUi.js +159 -0
  85. codex_autorunner/static/hub.js +46 -81
  86. codex_autorunner/static/index.html +303 -24
  87. codex_autorunner/static/messages.js +82 -4
  88. codex_autorunner/static/notifications.js +288 -0
  89. codex_autorunner/static/pma.js +1167 -0
  90. codex_autorunner/static/settings.js +3 -0
  91. codex_autorunner/static/streamUtils.js +57 -0
  92. codex_autorunner/static/styles.css +9141 -6742
  93. codex_autorunner/static/templateReposSettings.js +225 -0
  94. codex_autorunner/static/terminalManager.js +22 -3
  95. codex_autorunner/static/ticketChatActions.js +165 -3
  96. codex_autorunner/static/ticketChatStream.js +17 -119
  97. codex_autorunner/static/ticketEditor.js +41 -13
  98. codex_autorunner/static/ticketTemplates.js +798 -0
  99. codex_autorunner/static/tickets.js +69 -19
  100. codex_autorunner/static/turnEvents.js +27 -0
  101. codex_autorunner/static/turnResume.js +33 -0
  102. codex_autorunner/static/utils.js +28 -0
  103. codex_autorunner/static/workspace.js +258 -44
  104. codex_autorunner/static/workspaceFileBrowser.js +6 -4
  105. codex_autorunner/surfaces/cli/cli.py +1465 -155
  106. codex_autorunner/surfaces/cli/pma_cli.py +817 -0
  107. codex_autorunner/surfaces/web/app.py +253 -49
  108. codex_autorunner/surfaces/web/routes/__init__.py +4 -0
  109. codex_autorunner/surfaces/web/routes/analytics.py +29 -22
  110. codex_autorunner/surfaces/web/routes/archive.py +197 -0
  111. codex_autorunner/surfaces/web/routes/file_chat.py +297 -36
  112. codex_autorunner/surfaces/web/routes/filebox.py +227 -0
  113. codex_autorunner/surfaces/web/routes/flows.py +219 -29
  114. codex_autorunner/surfaces/web/routes/messages.py +70 -39
  115. codex_autorunner/surfaces/web/routes/pma.py +1652 -0
  116. codex_autorunner/surfaces/web/routes/repos.py +1 -1
  117. codex_autorunner/surfaces/web/routes/shared.py +0 -3
  118. codex_autorunner/surfaces/web/routes/templates.py +634 -0
  119. codex_autorunner/surfaces/web/runner_manager.py +2 -2
  120. codex_autorunner/surfaces/web/schemas.py +81 -18
  121. codex_autorunner/tickets/agent_pool.py +27 -0
  122. codex_autorunner/tickets/files.py +33 -16
  123. codex_autorunner/tickets/lint.py +50 -0
  124. codex_autorunner/tickets/models.py +3 -0
  125. codex_autorunner/tickets/outbox.py +41 -5
  126. codex_autorunner/tickets/runner.py +350 -69
  127. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/METADATA +15 -19
  128. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/RECORD +132 -101
  129. codex_autorunner/core/adapter_utils.py +0 -21
  130. codex_autorunner/core/engine.py +0 -3302
  131. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/WHEEL +0 -0
  132. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/entry_points.txt +0 -0
  133. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/licenses/LICENSE +0 -0
  134. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/top_level.txt +0 -0
@@ -870,7 +870,7 @@ class TelegramCommandHandlers(
870
870
  key = await self._resolve_topic_key(message.chat_id, message.thread_id)
871
871
  self._model_options.pop(key, None)
872
872
  self._model_pending.pop(key, None)
873
- record = await self._router.get_topic(key)
873
+ record = await self._router.ensure_topic(message.chat_id, message.thread_id)
874
874
  agent = self._effective_agent(record)
875
875
  supports_effort = self._agent_supports_effort(agent)
876
876
  list_params = {
@@ -878,10 +878,17 @@ class TelegramCommandHandlers(
878
878
  "limit": DEFAULT_MODEL_LIST_LIMIT,
879
879
  "agent": agent,
880
880
  }
881
- try:
882
- client = await self._client_for_workspace(
883
- record.workspace_path if record else None
881
+ workspace_path, error = self._resolve_workspace_path(record, allow_pma=True)
882
+ if workspace_path is None:
883
+ await self._send_message(
884
+ message.chat_id,
885
+ error or "Topic not bound. Use /bind <repo_id> or /bind <path>.",
886
+ thread_id=message.thread_id,
887
+ reply_to=message.message_id,
884
888
  )
889
+ return
890
+ try:
891
+ client = await self._client_for_workspace(workspace_path)
885
892
  except AppServerUnavailableError as exc:
886
893
  log_event(
887
894
  self._logger,
@@ -901,16 +908,20 @@ class TelegramCommandHandlers(
901
908
  if client is None:
902
909
  await self._send_message(
903
910
  message.chat_id,
904
- "Topic not bound. Use /bind <repo_id> or /bind <path>.",
911
+ error or "Topic not bound. Use /bind <repo_id> or /bind <path>.",
905
912
  thread_id=message.thread_id,
906
913
  reply_to=message.message_id,
907
914
  )
908
915
  return
909
916
  argv = self._parse_command_args(args)
910
917
  if not argv:
918
+ record_for_models = self._record_with_workspace_path(record, workspace_path)
911
919
  try:
912
920
  result = await self._fetch_model_list(
913
- record, agent=agent, client=client, list_params=list_params
921
+ record_for_models,
922
+ agent=agent,
923
+ client=client,
924
+ list_params=list_params,
914
925
  )
915
926
  except OpenCodeSupervisorError as exc:
916
927
  log_event(
@@ -993,9 +1004,13 @@ class TelegramCommandHandlers(
993
1004
  )
994
1005
  return
995
1006
  if argv[0].lower() in ("list", "ls"):
1007
+ record_for_models = self._record_with_workspace_path(record, workspace_path)
996
1008
  try:
997
1009
  result = await self._fetch_model_list(
998
- record, agent=agent, client=client, list_params=list_params
1010
+ record_for_models,
1011
+ agent=agent,
1012
+ client=client,
1013
+ list_params=list_params,
999
1014
  )
1000
1015
  except OpenCodeSupervisorError as exc:
1001
1016
  log_event(
@@ -1108,6 +1123,101 @@ class TelegramCommandHandlers(
1108
1123
  reply_to=message.message_id,
1109
1124
  )
1110
1125
 
1126
+ async def _handle_pma(
1127
+ self, message: TelegramMessage, args: str, _runtime: Any
1128
+ ) -> None:
1129
+ if not self._hub_root:
1130
+ await self._send_message(
1131
+ message.chat_id,
1132
+ "PMA unavailable; hub root not configured.",
1133
+ thread_id=message.thread_id,
1134
+ reply_to=message.message_id,
1135
+ )
1136
+ return
1137
+
1138
+ supervisor = getattr(self, "_hub_supervisor", None)
1139
+ if supervisor and hasattr(supervisor, "hub_config"):
1140
+ pma_config = supervisor.hub_config.pma
1141
+ if not pma_config.enabled:
1142
+ await self._send_message(
1143
+ message.chat_id,
1144
+ "PMA is disabled in hub config. Set pma.enabled: true to enable.",
1145
+ thread_id=message.thread_id,
1146
+ reply_to=message.message_id,
1147
+ )
1148
+ return
1149
+ argv = self._parse_command_args(args)
1150
+ action = argv[0].lower() if argv else ""
1151
+ key = await self._resolve_topic_key(message.chat_id, message.thread_id)
1152
+ record = await self._router.get_topic(key)
1153
+ current = bool(record and record.pma_enabled)
1154
+ if action in ("status", "show"):
1155
+ enabled = current
1156
+ elif action in ("on", "enable", "true"):
1157
+ enabled = True
1158
+ elif action in ("off", "disable", "false"):
1159
+ enabled = False
1160
+ elif action:
1161
+ await self._send_message(
1162
+ message.chat_id,
1163
+ "Usage: /pma [on|off|status]",
1164
+ thread_id=message.thread_id,
1165
+ reply_to=message.message_id,
1166
+ )
1167
+ return
1168
+ else:
1169
+ enabled = not current
1170
+
1171
+ if record is None:
1172
+ await self._router.ensure_topic(message.chat_id, message.thread_id)
1173
+
1174
+ def apply_pma(record: TelegramTopicRecord) -> None:
1175
+ record.pma_enabled = enabled
1176
+ if enabled:
1177
+ # Save previous binding before entering PMA mode.
1178
+ record.pma_prev_repo_id = record.repo_id
1179
+ record.pma_prev_workspace_path = record.workspace_path
1180
+ record.pma_prev_workspace_id = record.workspace_id
1181
+ record.pma_prev_active_thread_id = record.active_thread_id
1182
+ # Mutual exclusion: PMA mode implies Hub context, so unbind specific repo.
1183
+ record.workspace_path = None
1184
+ record.repo_id = None
1185
+ record.workspace_id = None
1186
+ record.active_thread_id = None
1187
+ else:
1188
+ # Restore previous binding when exiting PMA mode.
1189
+ if record.pma_prev_repo_id or record.pma_prev_workspace_path:
1190
+ record.repo_id = record.pma_prev_repo_id
1191
+ record.workspace_path = record.pma_prev_workspace_path
1192
+ record.workspace_id = record.pma_prev_workspace_id
1193
+ record.active_thread_id = record.pma_prev_active_thread_id
1194
+ # Clear saved previous binding after restore.
1195
+ record.pma_prev_repo_id = None
1196
+ record.pma_prev_workspace_path = None
1197
+ record.pma_prev_workspace_id = None
1198
+ record.pma_prev_active_thread_id = None
1199
+
1200
+ await self._router.update_topic(
1201
+ message.chat_id,
1202
+ message.thread_id,
1203
+ apply_pma,
1204
+ )
1205
+ status = "enabled" if enabled else "disabled"
1206
+ if enabled:
1207
+ hint = "Use /pma off to exit. Previous repo binding saved."
1208
+ else:
1209
+ previous = (record and record.pma_prev_workspace_path) or None
1210
+ if previous:
1211
+ hint = f"Back to repo mode. Restored {previous}."
1212
+ else:
1213
+ hint = "Back to repo mode."
1214
+ await self._send_message(
1215
+ message.chat_id,
1216
+ f"PMA mode {status}. {hint}",
1217
+ thread_id=message.thread_id,
1218
+ reply_to=message.message_id,
1219
+ )
1220
+
1111
1221
  async def _opencode_review_arguments(target: dict[str, Any]) -> str:
1112
1222
  target_type = target.get("type")
1113
1223
  if target_type == "uncommittedChanges":
@@ -1834,10 +1944,14 @@ class TelegramCommandHandlers(
1834
1944
  async def _apply_compact_summary(
1835
1945
  self, message: TelegramMessage, record: "TelegramTopicRecord", summary_text: str
1836
1946
  ) -> tuple[bool, str | None]:
1837
- if not record.workspace_path:
1838
- return (False, "Topic not bound. Use /bind <repo_id> or /bind <path>.")
1947
+ workspace_path, error = self._resolve_workspace_path(record, allow_pma=True)
1948
+ if not workspace_path:
1949
+ return (
1950
+ False,
1951
+ error or "Topic not bound. Use /bind <repo_id> or /bind <path>.",
1952
+ )
1839
1953
  try:
1840
- client = await self._client_for_workspace(record.workspace_path)
1954
+ client = await self._client_for_workspace(workspace_path)
1841
1955
  except AppServerUnavailableError as exc:
1842
1956
  log_event(
1843
1957
  self._logger,
@@ -1849,7 +1963,10 @@ class TelegramCommandHandlers(
1849
1963
  )
1850
1964
  return False, "App server unavailable; try again or check logs."
1851
1965
  if client is None:
1852
- return (False, "Topic not bound. Use /bind <repo_id> or /bind <path>.")
1966
+ return (
1967
+ False,
1968
+ error or "Topic not bound. Use /bind <repo_id> or /bind <path>.",
1969
+ )
1853
1970
  log_event(
1854
1971
  self._logger,
1855
1972
  logging.INFO,
@@ -1857,11 +1974,11 @@ class TelegramCommandHandlers(
1857
1974
  chat_id=message.chat_id,
1858
1975
  thread_id=message.thread_id,
1859
1976
  summary_len=len(summary_text),
1860
- workspace_path=record.workspace_path,
1977
+ workspace_path=workspace_path,
1861
1978
  )
1862
1979
  try:
1863
1980
  agent = self._effective_agent(record)
1864
- thread = await client.thread_start(record.workspace_path, agent=agent)
1981
+ thread = await client.thread_start(workspace_path, agent=agent)
1865
1982
  except Exception as exc:
1866
1983
  log_event(
1867
1984
  self._logger,
@@ -1873,7 +1990,7 @@ class TelegramCommandHandlers(
1873
1990
  )
1874
1991
  return False, "Failed to start a new thread."
1875
1992
  if not await self._require_thread_workspace(
1876
- message, record.workspace_path, thread, action="thread_start"
1993
+ message, workspace_path, thread, action="thread_start"
1877
1994
  ):
1878
1995
  return False, "Failed to start a new thread."
1879
1996
  new_thread_id = _extract_thread_id(thread)
@@ -1911,7 +2028,7 @@ class TelegramCommandHandlers(
1911
2028
  ) -> None:
1912
2029
  argv = self._parse_command_args(args)
1913
2030
  if argv and argv[0].lower() in ("soft", "summary", "summarize"):
1914
- record = await self._require_bound_record(message)
2031
+ record = await self._require_bound_record(message, allow_pma=True)
1915
2032
  if not record:
1916
2033
  return
1917
2034
  await self._handle_normal_message(
@@ -1919,33 +2036,51 @@ class TelegramCommandHandlers(
1919
2036
  )
1920
2037
  return
1921
2038
  auto_apply = bool(argv and argv[0].lower() == "apply")
1922
- record = await self._require_bound_record(message)
2039
+ record = await self._require_bound_record(message, allow_pma=True)
1923
2040
  if not record:
1924
2041
  return
1925
2042
  key = await self._resolve_topic_key(message.chat_id, message.thread_id)
1926
- if not record.active_thread_id:
1927
- await self._send_message(
1928
- message.chat_id,
1929
- "No active thread to compact. Use /new to start one.",
1930
- thread_id=message.thread_id,
1931
- reply_to=message.message_id,
1932
- )
1933
- return
1934
- conflict_key = await self._find_thread_conflict(
1935
- record.active_thread_id, key=key
1936
- )
1937
- if conflict_key:
1938
- await self._router.set_active_thread(
1939
- message.chat_id, message.thread_id, None
1940
- )
1941
- await self._handle_thread_conflict(
1942
- message, record.active_thread_id, conflict_key
2043
+ pma_enabled = bool(record.pma_enabled)
2044
+ if pma_enabled:
2045
+ registry = getattr(self, "_hub_thread_registry", None)
2046
+ pma_key = self._pma_registry_key(record, message)
2047
+ pma_thread_id = (
2048
+ registry.get_thread_id(pma_key)
2049
+ if registry is not None and pma_key
2050
+ else None
2051
+ )
2052
+ if not pma_thread_id:
2053
+ await self._send_message(
2054
+ message.chat_id,
2055
+ "No active PMA thread to compact. Send a message or use /new to start one.",
2056
+ thread_id=message.thread_id,
2057
+ reply_to=message.message_id,
2058
+ )
2059
+ return
2060
+ else:
2061
+ if not record.active_thread_id:
2062
+ await self._send_message(
2063
+ message.chat_id,
2064
+ "No active thread to compact. Use /new to start one.",
2065
+ thread_id=message.thread_id,
2066
+ reply_to=message.message_id,
2067
+ )
2068
+ return
2069
+ conflict_key = await self._find_thread_conflict(
2070
+ record.active_thread_id, key=key
1943
2071
  )
1944
- return
1945
- verified = await self._verify_active_thread(message, record)
1946
- if not verified:
1947
- return
1948
- record = verified
2072
+ if conflict_key:
2073
+ await self._router.set_active_thread(
2074
+ message.chat_id, message.thread_id, None
2075
+ )
2076
+ await self._handle_thread_conflict(
2077
+ message, record.active_thread_id, conflict_key
2078
+ )
2079
+ return
2080
+ verified = await self._verify_active_thread(message, record)
2081
+ if not verified:
2082
+ return
2083
+ record = verified
1949
2084
  outcome = await self._run_turn_and_collect_result(
1950
2085
  message,
1951
2086
  runtime,
@@ -2067,9 +2202,13 @@ Compact canceled.""",
2067
2202
  )
2068
2203
  self._compact_pending.pop(key, None)
2069
2204
  record = await self._router.get_topic(key)
2070
- if record is None or not record.workspace_path:
2205
+ if record is None:
2071
2206
  await self._answer_callback(callback, "Selection expired")
2072
2207
  return
2208
+ workspace_path, error = self._resolve_workspace_path(record, allow_pma=True)
2209
+ if workspace_path is None:
2210
+ await self._answer_callback(callback, error or "Selection expired")
2211
+ return
2073
2212
  if callback.chat_id is None:
2074
2213
  return
2075
2214
  await self._answer_callback(callback, "Applying summary...")
@@ -2634,14 +2773,23 @@ Summary applied.""",
2634
2773
  async def _handle_logout(
2635
2774
  self, message: TelegramMessage, _args: str, _runtime: Any
2636
2775
  ) -> None:
2637
- record = await self._require_bound_record(message)
2776
+ record = await self._require_bound_record(message, allow_pma=True)
2638
2777
  if not record:
2639
2778
  return
2640
- client = await self._client_for_workspace(record.workspace_path)
2779
+ workspace_path, error = self._resolve_workspace_path(record, allow_pma=True)
2780
+ if workspace_path is None:
2781
+ await self._send_message(
2782
+ message.chat_id,
2783
+ error or "Topic not bound. Use /bind <repo_id> or /bind <path>.",
2784
+ thread_id=message.thread_id,
2785
+ reply_to=message.message_id,
2786
+ )
2787
+ return
2788
+ client = await self._client_for_workspace(workspace_path)
2641
2789
  if client is None:
2642
2790
  await self._send_message(
2643
2791
  message.chat_id,
2644
- "Topic not bound. Use /bind <repo_id> or /bind <path>.",
2792
+ error or "Topic not bound. Use /bind <repo_id> or /bind <path>.",
2645
2793
  thread_id=message.thread_id,
2646
2794
  reply_to=message.message_id,
2647
2795
  )
@@ -2687,14 +2835,23 @@ Summary applied.""",
2687
2835
  reply_to=message.message_id,
2688
2836
  )
2689
2837
  return
2690
- record = await self._require_bound_record(message)
2838
+ record = await self._require_bound_record(message, allow_pma=True)
2691
2839
  if not record:
2692
2840
  return
2693
- client = await self._client_for_workspace(record.workspace_path)
2841
+ workspace_path, error = self._resolve_workspace_path(record, allow_pma=True)
2842
+ if workspace_path is None:
2843
+ await self._send_message(
2844
+ message.chat_id,
2845
+ error or "Topic not bound. Use /bind <repo_id> or /bind <path>.",
2846
+ thread_id=message.thread_id,
2847
+ reply_to=message.message_id,
2848
+ )
2849
+ return
2850
+ client = await self._client_for_workspace(workspace_path)
2694
2851
  if client is None:
2695
2852
  await self._send_message(
2696
2853
  message.chat_id,
2697
- "Topic not bound. Use /bind <repo_id> or /bind <path>.",
2854
+ error or "Topic not bound. Use /bind <repo_id> or /bind <path>.",
2698
2855
  thread_id=message.thread_id,
2699
2856
  reply_to=message.message_id,
2700
2857
  )
@@ -16,6 +16,12 @@ class CommandSpec:
16
16
 
17
17
  def build_command_specs(handlers: Any) -> dict[str, CommandSpec]:
18
18
  return {
19
+ "repos": CommandSpec(
20
+ "repos",
21
+ "list available repositories in the hub",
22
+ handlers._handle_repos,
23
+ allow_during_turn=True,
24
+ ),
19
25
  "bind": CommandSpec(
20
26
  "bind",
21
27
  "bind this topic to a workspace",
@@ -23,9 +29,14 @@ def build_command_specs(handlers: Any) -> dict[str, CommandSpec]:
23
29
  ),
24
30
  "new": CommandSpec(
25
31
  "new",
26
- "start a new session",
32
+ "start a new PMA session",
27
33
  lambda message, _args, _runtime: handlers._handle_new(message),
28
34
  ),
35
+ "reset": CommandSpec(
36
+ "reset",
37
+ "reset PMA thread state (clear volatile state)",
38
+ lambda message, _args, _runtime: handlers._handle_reset(message),
39
+ ),
29
40
  "resume": CommandSpec(
30
41
  "resume",
31
42
  "list or resume a previous session",
@@ -42,12 +53,6 @@ def build_command_specs(handlers: Any) -> dict[str, CommandSpec]:
42
53
  lambda message, args, _runtime: handlers._handle_flow(message, args),
43
54
  allow_during_turn=True,
44
55
  ),
45
- "flow_status": CommandSpec(
46
- "flow_status",
47
- "show ticket flow status (alias for /flow status)",
48
- lambda message, args, _runtime: handlers._handle_flow_status(message, args),
49
- allow_during_turn=True,
50
- ),
51
56
  "reply": CommandSpec(
52
57
  "reply",
53
58
  "reply to a paused ticket flow dispatch (prefer /flow reply)",
@@ -69,6 +74,12 @@ def build_command_specs(handlers: Any) -> dict[str, CommandSpec]:
69
74
  "set approval and sandbox policy",
70
75
  handlers._handle_approvals,
71
76
  ),
77
+ "pma": CommandSpec(
78
+ "pma",
79
+ "toggle PMA mode for this topic",
80
+ handlers._handle_pma,
81
+ allow_during_turn=True,
82
+ ),
72
83
  "status": CommandSpec(
73
84
  "status",
74
85
  "show current binding and thread status",
@@ -75,6 +75,21 @@ def _message_text_candidate(message: TelegramMessage) -> tuple[str, str, Any]:
75
75
  return raw_text, text_candidate, entities
76
76
 
77
77
 
78
+ def _record_with_media_workspace(
79
+ handlers: Any, record: Any
80
+ ) -> tuple[Any, Optional[str]]:
81
+ """Ensure media handlers have a workspace path, including PMA topics."""
82
+ if record is None:
83
+ return None, None
84
+ pma_enabled = bool(getattr(record, "pma_enabled", False))
85
+ if not pma_enabled:
86
+ return record, None
87
+ hub_root = getattr(handlers, "_hub_root", None)
88
+ if hub_root is None:
89
+ return None, "PMA unavailable; hub root not configured."
90
+ return dataclasses.replace(record, workspace_path=str(hub_root)), None
91
+
92
+
78
93
  async def _clear_pending_options(
79
94
  handlers: Any, key: str, message: TelegramMessage
80
95
  ) -> None:
@@ -352,7 +367,8 @@ async def handle_message_inner(
352
367
  record = await handlers._router.get_topic(key)
353
368
  paused = None
354
369
  workspace_root: Optional[Path] = None
355
- if record and record.workspace_path:
370
+ pma_enabled = bool(record and getattr(record, "pma_enabled", False))
371
+ if not pma_enabled and record and record.workspace_path:
356
372
  workspace_root = canonicalize_path(Path(record.workspace_path))
357
373
  preferred_run_id = handlers._ticket_flow_pause_targets.get(
358
374
  str(workspace_root), None
@@ -801,6 +817,15 @@ async def handle_media_message(
801
817
  return
802
818
  key = await handlers._resolve_topic_key(message.chat_id, message.thread_id)
803
819
  record = await handlers._router.get_topic(key)
820
+ record, pma_error = _record_with_media_workspace(handlers, record)
821
+ if pma_error:
822
+ await handlers._send_message(
823
+ message.chat_id,
824
+ pma_error,
825
+ thread_id=message.thread_id,
826
+ reply_to=message.message_id,
827
+ )
828
+ return
804
829
  if record is None or not record.workspace_path:
805
830
  await handlers._send_message(
806
831
  message.chat_id,
@@ -834,7 +859,10 @@ async def handle_media_message(
834
859
  best = photos[0]
835
860
  try:
836
861
  file_info = await handlers._bot.get_file(best.file_id)
837
- data = await handlers._bot.download_file(file_info.file_path)
862
+ data = await handlers._bot.download_file(
863
+ file_info.file_path,
864
+ max_size_bytes=handlers._config.media.max_image_bytes,
865
+ )
838
866
  filename = f"photo_{best.file_id}.jpg"
839
867
  files.append((filename, data))
840
868
  except Exception as exc:
@@ -843,7 +871,10 @@ async def handle_media_message(
843
871
  elif message.document:
844
872
  try:
845
873
  file_info = await handlers._bot.get_file(message.document.file_id)
846
- data = await handlers._bot.download_file(file_info.file_path)
874
+ data = await handlers._bot.download_file(
875
+ file_info.file_path,
876
+ max_size_bytes=handlers._config.media.max_file_bytes,
877
+ )
847
878
  filename = (
848
879
  message.document.file_name or f"document_{message.document.file_id}"
849
880
  )
@@ -887,12 +887,12 @@ def _format_help_text(command_specs: dict[str, CommandSpec]) -> str:
887
887
  "resume",
888
888
  "review",
889
889
  "flow",
890
- "flow_status",
891
890
  "reply",
892
891
  "pr",
893
892
  "agent",
894
893
  "model",
895
894
  "approvals",
895
+ "pma",
896
896
  "status",
897
897
  "diff",
898
898
  "mention",
@@ -935,8 +935,6 @@ def _format_help_text(command_specs: dict[str, CommandSpec]) -> str:
935
935
  lines.append("/flow restart")
936
936
  lines.append("/flow archive [run_id] [--force]")
937
937
  lines.append("/flow reply <message>")
938
- if "flow_status" in command_specs:
939
- lines.append("/flow_status [run_id]")
940
938
  if "reply" in command_specs:
941
939
  lines.append("/reply <message> (legacy)")
942
940
 
@@ -265,8 +265,11 @@ class TelegramRuntimeHelpers:
265
265
  text = f"{prefix}{text}"
266
266
  return self._prepare_message(text)
267
267
 
268
- def _render_message(self, text: str) -> tuple[str, Optional[str]]:
269
- parse_mode = self._config.parse_mode
268
+ def _render_message(
269
+ self, text: str, *, parse_mode: Optional[str] = None
270
+ ) -> tuple[str, Optional[str]]:
271
+ # Allow callers to override parse_mode (needed for ad-hoc Markdown/HTML sends)
272
+ parse_mode = self._config.parse_mode if parse_mode is None else parse_mode
270
273
  if not parse_mode:
271
274
  return text, None
272
275
  if parse_mode == "HTML":
@@ -275,8 +278,10 @@ class TelegramRuntimeHelpers:
275
278
  return _format_telegram_markdown(text, parse_mode), parse_mode
276
279
  return text, parse_mode
277
280
 
278
- def _prepare_message(self, text: str) -> tuple[str, Optional[str]]:
279
- rendered, parse_mode = self._render_message(text)
281
+ def _prepare_message(
282
+ self, text: str, *, parse_mode: Optional[str] = None
283
+ ) -> tuple[str, Optional[str]]:
284
+ rendered, parse_mode = self._render_message(text, parse_mode=parse_mode)
280
285
  # Avoid parse_mode when chunking to keep markup intact.
281
286
  if parse_mode and len(rendered) <= TELEGRAM_MAX_MESSAGE_LENGTH:
282
287
  return rendered, parse_mode
@@ -16,7 +16,12 @@ if TYPE_CHECKING:
16
16
  from .state import TelegramTopicRecord
17
17
 
18
18
  from ...agents.opencode.supervisor import OpenCodeSupervisor
19
+ from ...core.app_server_threads import (
20
+ AppServerThreadRegistry,
21
+ default_app_server_threads_path,
22
+ )
19
23
  from ...core.flows.models import FlowRunRecord
24
+ from ...core.hub import HubSupervisor
20
25
  from ...core.locks import process_alive
21
26
  from ...core.logging_utils import log_event
22
27
  from ...core.request_context import reset_conversation_id, set_conversation_id
@@ -173,6 +178,31 @@ class TelegramBotService(
173
178
  self._logger = logger or logging.getLogger(__name__)
174
179
  self._hub_root = hub_root
175
180
  self._manifest_path = manifest_path
181
+ self._hub_supervisor = None
182
+ self._hub_thread_registry = None
183
+ if self._hub_root:
184
+ try:
185
+ self._hub_supervisor = HubSupervisor.from_path(self._hub_root)
186
+ except Exception as exc:
187
+ log_event(
188
+ self._logger,
189
+ logging.WARNING,
190
+ "telegram.pma.hub_supervisor.unavailable",
191
+ hub_root=str(self._hub_root),
192
+ exc=exc,
193
+ )
194
+ try:
195
+ self._hub_thread_registry = AppServerThreadRegistry(
196
+ default_app_server_threads_path(self._hub_root)
197
+ )
198
+ except Exception as exc:
199
+ log_event(
200
+ self._logger,
201
+ logging.WARNING,
202
+ "telegram.pma.thread_registry.unavailable",
203
+ hub_root=str(self._hub_root),
204
+ exc=exc,
205
+ )
176
206
  self._update_repo_url = update_repo_url
177
207
  self._update_repo_ref = update_repo_ref
178
208
  self._update_skip_checks = update_skip_checks
@@ -190,6 +190,11 @@ class TelegramTopicRecord:
190
190
  repo_id: Optional[str] = None
191
191
  workspace_path: Optional[str] = None
192
192
  workspace_id: Optional[str] = None
193
+ pma_enabled: bool = False
194
+ pma_prev_repo_id: Optional[str] = None
195
+ pma_prev_workspace_path: Optional[str] = None
196
+ pma_prev_workspace_id: Optional[str] = None
197
+ pma_prev_active_thread_id: Optional[str] = None
193
198
  active_thread_id: Optional[str] = None
194
199
  thread_ids: list[str] = dataclasses.field(default_factory=list)
195
200
  thread_summaries: dict[str, ThreadSummary] = dataclasses.field(default_factory=dict)
@@ -220,6 +225,29 @@ class TelegramTopicRecord:
220
225
  workspace_id = payload.get("workspace_id") or payload.get("workspaceId")
221
226
  if not isinstance(workspace_id, str):
222
227
  workspace_id = None
228
+ pma_enabled = payload.get("pma_enabled") or payload.get("pmaEnabled")
229
+ if not isinstance(pma_enabled, bool):
230
+ pma_enabled = False
231
+ pma_prev_repo_id = payload.get("pma_prev_repo_id") or payload.get(
232
+ "pmaPrevRepoId"
233
+ )
234
+ if not isinstance(pma_prev_repo_id, str):
235
+ pma_prev_repo_id = None
236
+ pma_prev_workspace_path = payload.get("pma_prev_workspace_path") or payload.get(
237
+ "pmaPrevWorkspacePath"
238
+ )
239
+ if not isinstance(pma_prev_workspace_path, str):
240
+ pma_prev_workspace_path = None
241
+ pma_prev_workspace_id = payload.get("pma_prev_workspace_id") or payload.get(
242
+ "pmaPrevWorkspaceId"
243
+ )
244
+ if not isinstance(pma_prev_workspace_id, str):
245
+ pma_prev_workspace_id = None
246
+ pma_prev_active_thread_id = payload.get(
247
+ "pma_prev_active_thread_id"
248
+ ) or payload.get("pmaPrevActiveThreadId")
249
+ if not isinstance(pma_prev_active_thread_id, str):
250
+ pma_prev_active_thread_id = None
223
251
  active_thread_id = payload.get("active_thread_id") or payload.get(
224
252
  "activeThreadId"
225
253
  )
@@ -301,6 +329,11 @@ class TelegramTopicRecord:
301
329
  repo_id=repo_id,
302
330
  workspace_path=workspace_path,
303
331
  workspace_id=workspace_id,
332
+ pma_enabled=pma_enabled,
333
+ pma_prev_repo_id=pma_prev_repo_id,
334
+ pma_prev_workspace_path=pma_prev_workspace_path,
335
+ pma_prev_workspace_id=pma_prev_workspace_id,
336
+ pma_prev_active_thread_id=pma_prev_active_thread_id,
304
337
  active_thread_id=active_thread_id,
305
338
  thread_ids=thread_ids,
306
339
  thread_summaries=thread_summaries,
@@ -324,6 +357,11 @@ class TelegramTopicRecord:
324
357
  "repo_id": self.repo_id,
325
358
  "workspace_path": self.workspace_path,
326
359
  "workspace_id": self.workspace_id,
360
+ "pma_enabled": self.pma_enabled,
361
+ "pma_prev_repo_id": self.pma_prev_repo_id,
362
+ "pma_prev_workspace_path": self.pma_prev_workspace_path,
363
+ "pma_prev_workspace_id": self.pma_prev_workspace_id,
364
+ "pma_prev_active_thread_id": self.pma_prev_active_thread_id,
327
365
  "active_thread_id": self.active_thread_id,
328
366
  "thread_ids": list(self.thread_ids),
329
367
  "thread_summaries": {