codex-autorunner 1.0.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 (227) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/agents/codex/harness.py +1 -1
  3. codex_autorunner/agents/opencode/client.py +113 -4
  4. codex_autorunner/agents/opencode/constants.py +3 -0
  5. codex_autorunner/agents/opencode/harness.py +6 -1
  6. codex_autorunner/agents/opencode/runtime.py +59 -18
  7. codex_autorunner/agents/opencode/supervisor.py +4 -0
  8. codex_autorunner/agents/registry.py +36 -7
  9. codex_autorunner/bootstrap.py +226 -4
  10. codex_autorunner/cli.py +5 -1174
  11. codex_autorunner/codex_cli.py +20 -84
  12. codex_autorunner/core/__init__.py +20 -0
  13. codex_autorunner/core/about_car.py +119 -1
  14. codex_autorunner/core/app_server_ids.py +59 -0
  15. codex_autorunner/core/app_server_threads.py +17 -2
  16. codex_autorunner/core/app_server_utils.py +165 -0
  17. codex_autorunner/core/archive.py +349 -0
  18. codex_autorunner/core/codex_runner.py +6 -2
  19. codex_autorunner/core/config.py +433 -4
  20. codex_autorunner/core/context_awareness.py +38 -0
  21. codex_autorunner/core/docs.py +0 -122
  22. codex_autorunner/core/drafts.py +58 -4
  23. codex_autorunner/core/exceptions.py +4 -0
  24. codex_autorunner/core/filebox.py +265 -0
  25. codex_autorunner/core/flows/controller.py +96 -2
  26. codex_autorunner/core/flows/models.py +13 -0
  27. codex_autorunner/core/flows/reasons.py +52 -0
  28. codex_autorunner/core/flows/reconciler.py +134 -0
  29. codex_autorunner/core/flows/runtime.py +57 -4
  30. codex_autorunner/core/flows/store.py +142 -7
  31. codex_autorunner/core/flows/transition.py +27 -15
  32. codex_autorunner/core/flows/ux_helpers.py +272 -0
  33. codex_autorunner/core/flows/worker_process.py +32 -6
  34. codex_autorunner/core/git_utils.py +62 -0
  35. codex_autorunner/core/hub.py +291 -20
  36. codex_autorunner/core/lifecycle_events.py +253 -0
  37. codex_autorunner/core/notifications.py +14 -2
  38. codex_autorunner/core/path_utils.py +2 -1
  39. codex_autorunner/core/pma_audit.py +224 -0
  40. codex_autorunner/core/pma_context.py +496 -0
  41. codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
  42. codex_autorunner/core/pma_lifecycle.py +527 -0
  43. codex_autorunner/core/pma_queue.py +367 -0
  44. codex_autorunner/core/pma_safety.py +221 -0
  45. codex_autorunner/core/pma_state.py +115 -0
  46. codex_autorunner/core/ports/__init__.py +28 -0
  47. codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +13 -8
  48. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  49. codex_autorunner/{integrations/agents → core/ports}/run_event.py +23 -6
  50. codex_autorunner/core/prompt.py +0 -80
  51. codex_autorunner/core/prompts.py +56 -172
  52. codex_autorunner/core/redaction.py +0 -4
  53. codex_autorunner/core/review_context.py +11 -9
  54. codex_autorunner/core/runner_controller.py +35 -33
  55. codex_autorunner/core/runner_state.py +147 -0
  56. codex_autorunner/core/runtime.py +829 -0
  57. codex_autorunner/core/sqlite_utils.py +13 -4
  58. codex_autorunner/core/state.py +7 -10
  59. codex_autorunner/core/state_roots.py +62 -0
  60. codex_autorunner/core/supervisor_protocol.py +15 -0
  61. codex_autorunner/core/templates/__init__.py +39 -0
  62. codex_autorunner/core/templates/git_mirror.py +234 -0
  63. codex_autorunner/core/templates/provenance.py +56 -0
  64. codex_autorunner/core/templates/scan_cache.py +120 -0
  65. codex_autorunner/core/text_delta_coalescer.py +54 -0
  66. codex_autorunner/core/ticket_linter_cli.py +218 -0
  67. codex_autorunner/core/ticket_manager_cli.py +494 -0
  68. codex_autorunner/core/time_utils.py +11 -0
  69. codex_autorunner/core/types.py +18 -0
  70. codex_autorunner/core/update.py +4 -5
  71. codex_autorunner/core/update_paths.py +28 -0
  72. codex_autorunner/core/usage.py +164 -12
  73. codex_autorunner/core/utils.py +125 -15
  74. codex_autorunner/flows/review/__init__.py +17 -0
  75. codex_autorunner/{core/review.py → flows/review/service.py} +37 -34
  76. codex_autorunner/flows/ticket_flow/definition.py +52 -3
  77. codex_autorunner/integrations/agents/__init__.py +11 -19
  78. codex_autorunner/integrations/agents/backend_orchestrator.py +302 -0
  79. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  80. codex_autorunner/integrations/agents/codex_backend.py +177 -25
  81. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  82. codex_autorunner/integrations/agents/opencode_backend.py +305 -32
  83. codex_autorunner/integrations/agents/runner.py +86 -0
  84. codex_autorunner/integrations/agents/wiring.py +279 -0
  85. codex_autorunner/integrations/app_server/client.py +7 -60
  86. codex_autorunner/integrations/app_server/env.py +2 -107
  87. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  88. codex_autorunner/integrations/telegram/adapter.py +65 -0
  89. codex_autorunner/integrations/telegram/config.py +46 -0
  90. codex_autorunner/integrations/telegram/constants.py +1 -1
  91. codex_autorunner/integrations/telegram/doctor.py +228 -6
  92. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
  93. codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
  94. codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
  95. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1496 -71
  96. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
  97. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +206 -48
  98. codex_autorunner/integrations/telegram/handlers/commands_spec.py +20 -3
  99. codex_autorunner/integrations/telegram/handlers/messages.py +27 -1
  100. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  101. codex_autorunner/integrations/telegram/helpers.py +22 -1
  102. codex_autorunner/integrations/telegram/runtime.py +9 -4
  103. codex_autorunner/integrations/telegram/service.py +45 -10
  104. codex_autorunner/integrations/telegram/state.py +38 -0
  105. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +338 -43
  106. codex_autorunner/integrations/telegram/transport.py +13 -4
  107. codex_autorunner/integrations/templates/__init__.py +27 -0
  108. codex_autorunner/integrations/templates/scan_agent.py +312 -0
  109. codex_autorunner/routes/__init__.py +37 -76
  110. codex_autorunner/routes/agents.py +2 -137
  111. codex_autorunner/routes/analytics.py +2 -238
  112. codex_autorunner/routes/app_server.py +2 -131
  113. codex_autorunner/routes/base.py +2 -596
  114. codex_autorunner/routes/file_chat.py +4 -833
  115. codex_autorunner/routes/flows.py +4 -977
  116. codex_autorunner/routes/messages.py +4 -456
  117. codex_autorunner/routes/repos.py +2 -196
  118. codex_autorunner/routes/review.py +2 -147
  119. codex_autorunner/routes/sessions.py +2 -175
  120. codex_autorunner/routes/settings.py +2 -168
  121. codex_autorunner/routes/shared.py +2 -275
  122. codex_autorunner/routes/system.py +4 -193
  123. codex_autorunner/routes/usage.py +2 -86
  124. codex_autorunner/routes/voice.py +2 -119
  125. codex_autorunner/routes/workspace.py +2 -270
  126. codex_autorunner/server.py +4 -4
  127. codex_autorunner/static/agentControls.js +61 -16
  128. codex_autorunner/static/app.js +126 -14
  129. codex_autorunner/static/archive.js +826 -0
  130. codex_autorunner/static/archiveApi.js +37 -0
  131. codex_autorunner/static/autoRefresh.js +7 -7
  132. codex_autorunner/static/chatUploads.js +137 -0
  133. codex_autorunner/static/dashboard.js +224 -171
  134. codex_autorunner/static/docChatCore.js +185 -13
  135. codex_autorunner/static/fileChat.js +68 -40
  136. codex_autorunner/static/fileboxUi.js +159 -0
  137. codex_autorunner/static/hub.js +114 -131
  138. codex_autorunner/static/index.html +375 -49
  139. codex_autorunner/static/messages.js +568 -87
  140. codex_autorunner/static/notifications.js +255 -0
  141. codex_autorunner/static/pma.js +1167 -0
  142. codex_autorunner/static/preserve.js +17 -0
  143. codex_autorunner/static/settings.js +128 -6
  144. codex_autorunner/static/smartRefresh.js +52 -0
  145. codex_autorunner/static/streamUtils.js +57 -0
  146. codex_autorunner/static/styles.css +9798 -6143
  147. codex_autorunner/static/tabs.js +152 -11
  148. codex_autorunner/static/templateReposSettings.js +225 -0
  149. codex_autorunner/static/terminal.js +18 -0
  150. codex_autorunner/static/ticketChatActions.js +165 -3
  151. codex_autorunner/static/ticketChatStream.js +17 -119
  152. codex_autorunner/static/ticketEditor.js +137 -15
  153. codex_autorunner/static/ticketTemplates.js +798 -0
  154. codex_autorunner/static/tickets.js +821 -98
  155. codex_autorunner/static/turnEvents.js +27 -0
  156. codex_autorunner/static/turnResume.js +33 -0
  157. codex_autorunner/static/utils.js +39 -0
  158. codex_autorunner/static/workspace.js +389 -82
  159. codex_autorunner/static/workspaceFileBrowser.js +15 -13
  160. codex_autorunner/surfaces/__init__.py +5 -0
  161. codex_autorunner/surfaces/cli/__init__.py +6 -0
  162. codex_autorunner/surfaces/cli/cli.py +2534 -0
  163. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  164. codex_autorunner/surfaces/cli/pma_cli.py +817 -0
  165. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  166. codex_autorunner/surfaces/web/__init__.py +1 -0
  167. codex_autorunner/surfaces/web/app.py +2223 -0
  168. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  169. codex_autorunner/surfaces/web/middleware.py +587 -0
  170. codex_autorunner/surfaces/web/pty_session.py +370 -0
  171. codex_autorunner/surfaces/web/review.py +6 -0
  172. codex_autorunner/surfaces/web/routes/__init__.py +82 -0
  173. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  174. codex_autorunner/surfaces/web/routes/analytics.py +284 -0
  175. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  176. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  177. codex_autorunner/surfaces/web/routes/base.py +615 -0
  178. codex_autorunner/surfaces/web/routes/file_chat.py +1117 -0
  179. codex_autorunner/surfaces/web/routes/filebox.py +227 -0
  180. codex_autorunner/surfaces/web/routes/flows.py +1354 -0
  181. codex_autorunner/surfaces/web/routes/messages.py +490 -0
  182. codex_autorunner/surfaces/web/routes/pma.py +1652 -0
  183. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  184. codex_autorunner/surfaces/web/routes/review.py +148 -0
  185. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  186. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  187. codex_autorunner/surfaces/web/routes/shared.py +277 -0
  188. codex_autorunner/surfaces/web/routes/system.py +196 -0
  189. codex_autorunner/surfaces/web/routes/templates.py +634 -0
  190. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  191. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  192. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  193. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  194. codex_autorunner/surfaces/web/schemas.py +469 -0
  195. codex_autorunner/surfaces/web/static_assets.py +490 -0
  196. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  197. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  198. codex_autorunner/tickets/__init__.py +8 -1
  199. codex_autorunner/tickets/agent_pool.py +53 -4
  200. codex_autorunner/tickets/files.py +37 -16
  201. codex_autorunner/tickets/lint.py +50 -0
  202. codex_autorunner/tickets/models.py +6 -1
  203. codex_autorunner/tickets/outbox.py +50 -2
  204. codex_autorunner/tickets/runner.py +396 -57
  205. codex_autorunner/web/__init__.py +5 -1
  206. codex_autorunner/web/app.py +2 -1949
  207. codex_autorunner/web/hub_jobs.py +2 -191
  208. codex_autorunner/web/middleware.py +2 -586
  209. codex_autorunner/web/pty_session.py +2 -369
  210. codex_autorunner/web/runner_manager.py +2 -24
  211. codex_autorunner/web/schemas.py +2 -376
  212. codex_autorunner/web/static_assets.py +4 -441
  213. codex_autorunner/web/static_refresh.py +2 -85
  214. codex_autorunner/web/terminal_sessions.py +2 -77
  215. codex_autorunner/workspace/paths.py +49 -33
  216. codex_autorunner-1.2.0.dist-info/METADATA +150 -0
  217. codex_autorunner-1.2.0.dist-info/RECORD +339 -0
  218. codex_autorunner/core/adapter_utils.py +0 -21
  219. codex_autorunner/core/engine.py +0 -2653
  220. codex_autorunner/core/static_assets.py +0 -55
  221. codex_autorunner-1.0.0.dist-info/METADATA +0 -246
  222. codex_autorunner-1.0.0.dist-info/RECORD +0 -251
  223. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  224. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
  225. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
  226. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
  227. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
@@ -20,6 +20,7 @@ from ....agents.opencode.supervisor import OpenCodeSupervisorError
20
20
  from ....core.logging_utils import log_event
21
21
  from ....core.state import now_iso
22
22
  from ....core.update import _normalize_update_target, _spawn_update_process
23
+ from ....core.update_paths import resolve_update_paths
23
24
  from ....core.utils import canonicalize_path
24
25
  from ...app_server.client import _normalize_sandbox_policy
25
26
  from ..adapter import (
@@ -869,7 +870,7 @@ class TelegramCommandHandlers(
869
870
  key = await self._resolve_topic_key(message.chat_id, message.thread_id)
870
871
  self._model_options.pop(key, None)
871
872
  self._model_pending.pop(key, None)
872
- record = await self._router.get_topic(key)
873
+ record = await self._router.ensure_topic(message.chat_id, message.thread_id)
873
874
  agent = self._effective_agent(record)
874
875
  supports_effort = self._agent_supports_effort(agent)
875
876
  list_params = {
@@ -877,10 +878,17 @@ class TelegramCommandHandlers(
877
878
  "limit": DEFAULT_MODEL_LIST_LIMIT,
878
879
  "agent": agent,
879
880
  }
880
- try:
881
- client = await self._client_for_workspace(
882
- 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,
883
888
  )
889
+ return
890
+ try:
891
+ client = await self._client_for_workspace(workspace_path)
884
892
  except AppServerUnavailableError as exc:
885
893
  log_event(
886
894
  self._logger,
@@ -900,16 +908,20 @@ class TelegramCommandHandlers(
900
908
  if client is None:
901
909
  await self._send_message(
902
910
  message.chat_id,
903
- "Topic not bound. Use /bind <repo_id> or /bind <path>.",
911
+ error or "Topic not bound. Use /bind <repo_id> or /bind <path>.",
904
912
  thread_id=message.thread_id,
905
913
  reply_to=message.message_id,
906
914
  )
907
915
  return
908
916
  argv = self._parse_command_args(args)
909
917
  if not argv:
918
+ record_for_models = self._record_with_workspace_path(record, workspace_path)
910
919
  try:
911
920
  result = await self._fetch_model_list(
912
- record, agent=agent, client=client, list_params=list_params
921
+ record_for_models,
922
+ agent=agent,
923
+ client=client,
924
+ list_params=list_params,
913
925
  )
914
926
  except OpenCodeSupervisorError as exc:
915
927
  log_event(
@@ -992,9 +1004,13 @@ class TelegramCommandHandlers(
992
1004
  )
993
1005
  return
994
1006
  if argv[0].lower() in ("list", "ls"):
1007
+ record_for_models = self._record_with_workspace_path(record, workspace_path)
995
1008
  try:
996
1009
  result = await self._fetch_model_list(
997
- record, agent=agent, client=client, list_params=list_params
1010
+ record_for_models,
1011
+ agent=agent,
1012
+ client=client,
1013
+ list_params=list_params,
998
1014
  )
999
1015
  except OpenCodeSupervisorError as exc:
1000
1016
  log_event(
@@ -1107,6 +1123,101 @@ class TelegramCommandHandlers(
1107
1123
  reply_to=message.message_id,
1108
1124
  )
1109
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
+
1110
1221
  async def _opencode_review_arguments(target: dict[str, Any]) -> str:
1111
1222
  target_type = target.get("type")
1112
1223
  if target_type == "uncommittedChanges":
@@ -1833,10 +1944,14 @@ class TelegramCommandHandlers(
1833
1944
  async def _apply_compact_summary(
1834
1945
  self, message: TelegramMessage, record: "TelegramTopicRecord", summary_text: str
1835
1946
  ) -> tuple[bool, str | None]:
1836
- if not record.workspace_path:
1837
- 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
+ )
1838
1953
  try:
1839
- client = await self._client_for_workspace(record.workspace_path)
1954
+ client = await self._client_for_workspace(workspace_path)
1840
1955
  except AppServerUnavailableError as exc:
1841
1956
  log_event(
1842
1957
  self._logger,
@@ -1848,7 +1963,10 @@ class TelegramCommandHandlers(
1848
1963
  )
1849
1964
  return False, "App server unavailable; try again or check logs."
1850
1965
  if client is None:
1851
- 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
+ )
1852
1970
  log_event(
1853
1971
  self._logger,
1854
1972
  logging.INFO,
@@ -1856,11 +1974,11 @@ class TelegramCommandHandlers(
1856
1974
  chat_id=message.chat_id,
1857
1975
  thread_id=message.thread_id,
1858
1976
  summary_len=len(summary_text),
1859
- workspace_path=record.workspace_path,
1977
+ workspace_path=workspace_path,
1860
1978
  )
1861
1979
  try:
1862
1980
  agent = self._effective_agent(record)
1863
- thread = await client.thread_start(record.workspace_path, agent=agent)
1981
+ thread = await client.thread_start(workspace_path, agent=agent)
1864
1982
  except Exception as exc:
1865
1983
  log_event(
1866
1984
  self._logger,
@@ -1872,7 +1990,7 @@ class TelegramCommandHandlers(
1872
1990
  )
1873
1991
  return False, "Failed to start a new thread."
1874
1992
  if not await self._require_thread_workspace(
1875
- message, record.workspace_path, thread, action="thread_start"
1993
+ message, workspace_path, thread, action="thread_start"
1876
1994
  ):
1877
1995
  return False, "Failed to start a new thread."
1878
1996
  new_thread_id = _extract_thread_id(thread)
@@ -1910,7 +2028,7 @@ class TelegramCommandHandlers(
1910
2028
  ) -> None:
1911
2029
  argv = self._parse_command_args(args)
1912
2030
  if argv and argv[0].lower() in ("soft", "summary", "summarize"):
1913
- record = await self._require_bound_record(message)
2031
+ record = await self._require_bound_record(message, allow_pma=True)
1914
2032
  if not record:
1915
2033
  return
1916
2034
  await self._handle_normal_message(
@@ -1918,33 +2036,51 @@ class TelegramCommandHandlers(
1918
2036
  )
1919
2037
  return
1920
2038
  auto_apply = bool(argv and argv[0].lower() == "apply")
1921
- record = await self._require_bound_record(message)
2039
+ record = await self._require_bound_record(message, allow_pma=True)
1922
2040
  if not record:
1923
2041
  return
1924
2042
  key = await self._resolve_topic_key(message.chat_id, message.thread_id)
1925
- if not record.active_thread_id:
1926
- await self._send_message(
1927
- message.chat_id,
1928
- "No active thread to compact. Use /new to start one.",
1929
- thread_id=message.thread_id,
1930
- reply_to=message.message_id,
1931
- )
1932
- return
1933
- conflict_key = await self._find_thread_conflict(
1934
- record.active_thread_id, key=key
1935
- )
1936
- if conflict_key:
1937
- await self._router.set_active_thread(
1938
- message.chat_id, message.thread_id, None
1939
- )
1940
- await self._handle_thread_conflict(
1941
- 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
1942
2071
  )
1943
- return
1944
- verified = await self._verify_active_thread(message, record)
1945
- if not verified:
1946
- return
1947
- 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
1948
2084
  outcome = await self._run_turn_and_collect_result(
1949
2085
  message,
1950
2086
  runtime,
@@ -2066,9 +2202,13 @@ Compact canceled.""",
2066
2202
  )
2067
2203
  self._compact_pending.pop(key, None)
2068
2204
  record = await self._router.get_topic(key)
2069
- if record is None or not record.workspace_path:
2205
+ if record is None:
2070
2206
  await self._answer_callback(callback, "Selection expired")
2071
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
2072
2212
  if callback.chat_id is None:
2073
2213
  return
2074
2214
  await self._answer_callback(callback, "Applying summary...")
@@ -2277,7 +2417,7 @@ Summary applied.""",
2277
2417
  repo_ref = (self._update_repo_ref or DEFAULT_UPDATE_REPO_REF).strip()
2278
2418
  if not repo_ref:
2279
2419
  repo_ref = DEFAULT_UPDATE_REPO_REF
2280
- update_dir = Path.home() / ".codex-autorunner" / "update_cache"
2420
+ update_dir = resolve_update_paths().cache_dir
2281
2421
  notify_reply_to = reply_to
2282
2422
  if notify_reply_to is None and callback is not None:
2283
2423
  notify_reply_to = callback.message_id
@@ -2383,7 +2523,7 @@ Summary applied.""",
2383
2523
  )
2384
2524
 
2385
2525
  def _update_status_path(self) -> Path:
2386
- return Path.home() / ".codex-autorunner" / "update_status.json"
2526
+ return resolve_update_paths().status_path
2387
2527
 
2388
2528
  def _read_update_status(self) -> Optional[dict[str, Any]]:
2389
2529
  path = self._update_status_path()
@@ -2467,7 +2607,7 @@ Summary applied.""",
2467
2607
  )
2468
2608
 
2469
2609
  def _compact_status_path(self) -> Path:
2470
- return Path.home() / ".codex-autorunner" / "compact_status.json"
2610
+ return resolve_update_paths().compact_status_path
2471
2611
 
2472
2612
  def _read_compact_status(self) -> Optional[dict[str, Any]]:
2473
2613
  path = self._compact_status_path()
@@ -2633,14 +2773,23 @@ Summary applied.""",
2633
2773
  async def _handle_logout(
2634
2774
  self, message: TelegramMessage, _args: str, _runtime: Any
2635
2775
  ) -> None:
2636
- record = await self._require_bound_record(message)
2776
+ record = await self._require_bound_record(message, allow_pma=True)
2637
2777
  if not record:
2638
2778
  return
2639
- 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)
2640
2789
  if client is None:
2641
2790
  await self._send_message(
2642
2791
  message.chat_id,
2643
- "Topic not bound. Use /bind <repo_id> or /bind <path>.",
2792
+ error or "Topic not bound. Use /bind <repo_id> or /bind <path>.",
2644
2793
  thread_id=message.thread_id,
2645
2794
  reply_to=message.message_id,
2646
2795
  )
@@ -2686,14 +2835,23 @@ Summary applied.""",
2686
2835
  reply_to=message.message_id,
2687
2836
  )
2688
2837
  return
2689
- record = await self._require_bound_record(message)
2838
+ record = await self._require_bound_record(message, allow_pma=True)
2690
2839
  if not record:
2691
2840
  return
2692
- 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)
2693
2851
  if client is None:
2694
2852
  await self._send_message(
2695
2853
  message.chat_id,
2696
- "Topic not bound. Use /bind <repo_id> or /bind <path>.",
2854
+ error or "Topic not bound. Use /bind <repo_id> or /bind <path>.",
2697
2855
  thread_id=message.thread_id,
2698
2856
  reply_to=message.message_id,
2699
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",
@@ -38,13 +49,13 @@ def build_command_specs(handlers: Any) -> dict[str, CommandSpec]:
38
49
  ),
39
50
  "flow": CommandSpec(
40
51
  "flow",
41
- "start/resume ticket flow runs",
52
+ "ticket flow controls (status, runs, bootstrap, resume, stop, archive, reply)",
42
53
  lambda message, args, _runtime: handlers._handle_flow(message, args),
43
54
  allow_during_turn=True,
44
55
  ),
45
56
  "reply": CommandSpec(
46
57
  "reply",
47
- "reply to a paused ticket flow dispatch",
58
+ "reply to a paused ticket flow dispatch (prefer /flow reply)",
48
59
  lambda message, args, _runtime: handlers._handle_reply(message, args),
49
60
  allow_during_turn=True,
50
61
  ),
@@ -63,6 +74,12 @@ def build_command_specs(handlers: Any) -> dict[str, CommandSpec]:
63
74
  "set approval and sandbox policy",
64
75
  handlers._handle_approvals,
65
76
  ),
77
+ "pma": CommandSpec(
78
+ "pma",
79
+ "toggle PMA mode for this topic",
80
+ handlers._handle_pma,
81
+ allow_during_turn=True,
82
+ ),
66
83
  "status": CommandSpec(
67
84
  "status",
68
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:
@@ -263,6 +278,7 @@ async def handle_message_inner(
263
278
  if text and text.startswith("!") and not has_media:
264
279
  handlers._resume_options.pop(key, None)
265
280
  handlers._bind_options.pop(key, None)
281
+ handlers._flow_run_options.pop(key, None)
266
282
  handlers._agent_options.pop(key, None)
267
283
  handlers._model_options.pop(key, None)
268
284
  handlers._model_pending.pop(key, None)
@@ -351,7 +367,8 @@ async def handle_message_inner(
351
367
  record = await handlers._router.get_topic(key)
352
368
  paused = None
353
369
  workspace_root: Optional[Path] = None
354
- 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:
355
372
  workspace_root = canonicalize_path(Path(record.workspace_path))
356
373
  preferred_run_id = handlers._ticket_flow_pause_targets.get(
357
374
  str(workspace_root), None
@@ -800,6 +817,15 @@ async def handle_media_message(
800
817
  return
801
818
  key = await handlers._resolve_topic_key(message.chat_id, message.thread_id)
802
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
803
829
  if record is None or not record.workspace_path:
804
830
  await handlers._send_message(
805
831
  message.chat_id,
@@ -7,6 +7,8 @@ from ..adapter import (
7
7
  AgentCallback,
8
8
  CancelCallback,
9
9
  EffortCallback,
10
+ FlowCallback,
11
+ FlowRunCallback,
10
12
  ModelCallback,
11
13
  PageCallback,
12
14
  ReviewCommitCallback,
@@ -18,6 +20,7 @@ from ..adapter import (
18
20
  build_agent_keyboard,
19
21
  build_bind_keyboard,
20
22
  build_effort_keyboard,
23
+ build_flow_runs_keyboard,
21
24
  build_model_keyboard,
22
25
  build_resume_keyboard,
23
26
  build_review_commit_keyboard,
@@ -29,6 +32,7 @@ from ..constants import (
29
32
  BIND_PICKER_PROMPT,
30
33
  DEFAULT_PAGE_SIZE,
31
34
  EFFORT_PICKER_PROMPT,
35
+ FLOW_RUNS_PICKER_PROMPT,
32
36
  MODEL_PICKER_PROMPT,
33
37
  RESUME_BUTTON_PREVIEW_LIMIT,
34
38
  RESUME_PICKER_PROMPT,
@@ -393,6 +397,21 @@ class TelegramSelectionHandlers:
393
397
  delivery=state.delivery,
394
398
  )
395
399
 
400
+ async def _handle_flow_run_callback(
401
+ self,
402
+ key: str,
403
+ callback: TelegramCallbackQuery,
404
+ parsed: FlowRunCallback,
405
+ ) -> None:
406
+ state = self._flow_run_options.get(key)
407
+ if not state or not _selection_contains(state.items, parsed.run_id):
408
+ await self._answer_callback(callback, "Selection expired")
409
+ return
410
+ self._flow_run_options.pop(key, None)
411
+ await self._handle_flow_callback(
412
+ callback, FlowCallback(action="status", run_id=parsed.run_id)
413
+ )
414
+
396
415
  def _selection_prompt(self, base: str, state: SelectionState) -> str:
397
416
  total_pages = _page_count(len(state.items), DEFAULT_PAGE_SIZE)
398
417
  return _format_selection_prompt(base, state.page, total_pages)
@@ -486,6 +505,37 @@ class TelegramSelectionHandlers:
486
505
  include_cancel=True,
487
506
  )
488
507
 
508
+ def _build_flow_runs_keyboard(self, state: SelectionState) -> dict[str, Any]:
509
+ page_items = _page_slice(state.items, state.page, DEFAULT_PAGE_SIZE)
510
+ options = []
511
+ for idx, (item_id, label) in enumerate(page_items, 1):
512
+ button_label = label
513
+ if state.button_labels:
514
+ button_label = state.button_labels.get(item_id, label)
515
+ options.append(
516
+ (
517
+ item_id,
518
+ f"{idx}) {_compact_preview(button_label, RESUME_BUTTON_PREVIEW_LIMIT)}",
519
+ )
520
+ )
521
+ return build_flow_runs_keyboard(
522
+ options,
523
+ page_button=self._page_button("flow-runs", state),
524
+ include_cancel=True,
525
+ )
526
+
527
+ def _flow_runs_prompt(self, state: SelectionState) -> str:
528
+ total_pages = _page_count(len(state.items), DEFAULT_PAGE_SIZE)
529
+ page_items = _page_slice(state.items, state.page, DEFAULT_PAGE_SIZE)
530
+ lines = [FLOW_RUNS_PICKER_PROMPT]
531
+ for run_id, label in page_items:
532
+ if label:
533
+ lines.append(f"- {run_id} — {label}")
534
+ else:
535
+ lines.append(f"- {run_id}")
536
+ base = "\n".join(lines)
537
+ return _format_selection_prompt(base, state.page, total_pages)
538
+
489
539
  def _build_effort_keyboard(self, option: ModelOption) -> dict[str, Any]:
490
540
  options = []
491
541
  for effort in option.efforts:
@@ -566,6 +616,9 @@ class TelegramSelectionHandlers:
566
616
  elif parsed.kind == "review-custom":
567
617
  self._pending_review_custom.pop(key, None)
568
618
  text = "Custom review cancelled."
619
+ elif parsed.kind == "flow-runs":
620
+ self._flow_run_options.pop(key, None)
621
+ text = "Flow run selection cancelled."
569
622
  else:
570
623
  await self._answer_callback(callback, "Selection expired")
571
624
  return
@@ -605,6 +658,10 @@ class TelegramSelectionHandlers:
605
658
  Callable[[SelectionState], dict[str, Any]],
606
659
  self._build_review_commit_keyboard,
607
660
  )
661
+ elif parsed.kind == "flow-runs":
662
+ state = self._flow_run_options.get(key)
663
+ prompt_base = ""
664
+ build_keyboard = self._build_flow_runs_keyboard
608
665
  else:
609
666
  await self._answer_callback(callback, "Selection expired")
610
667
  return
@@ -617,7 +674,10 @@ class TelegramSelectionHandlers:
617
674
  return
618
675
  page = parsed.page % total_pages
619
676
  state.page = page
620
- prompt = _format_selection_prompt(prompt_base, page, total_pages)
677
+ if parsed.kind == "flow-runs":
678
+ prompt = self._flow_runs_prompt(state)
679
+ else:
680
+ prompt = _format_selection_prompt(prompt_base, page, total_pages)
621
681
  keyboard = build_keyboard(state)
622
682
  await self._update_selection_message(key, callback, prompt, keyboard)
623
683
  await self._answer_callback(callback, f"Page {page + 1}/{total_pages}")