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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. codex_autorunner/bootstrap.py +26 -5
  2. codex_autorunner/core/about_car.py +12 -12
  3. codex_autorunner/core/config.py +178 -61
  4. codex_autorunner/core/context_awareness.py +1 -0
  5. codex_autorunner/core/filesystem.py +24 -0
  6. codex_autorunner/core/flows/controller.py +50 -12
  7. codex_autorunner/core/flows/runtime.py +8 -3
  8. codex_autorunner/core/hub.py +293 -16
  9. codex_autorunner/core/lifecycle_events.py +44 -5
  10. codex_autorunner/core/pma_context.py +188 -1
  11. codex_autorunner/core/pma_delivery.py +81 -0
  12. codex_autorunner/core/pma_dispatches.py +224 -0
  13. codex_autorunner/core/pma_lane_worker.py +122 -0
  14. codex_autorunner/core/pma_queue.py +167 -18
  15. codex_autorunner/core/pma_reactive.py +91 -0
  16. codex_autorunner/core/pma_safety.py +58 -0
  17. codex_autorunner/core/pma_sink.py +104 -0
  18. codex_autorunner/core/pma_transcripts.py +183 -0
  19. codex_autorunner/core/safe_paths.py +117 -0
  20. codex_autorunner/housekeeping.py +77 -23
  21. codex_autorunner/integrations/agents/codex_backend.py +18 -12
  22. codex_autorunner/integrations/agents/wiring.py +2 -0
  23. codex_autorunner/integrations/app_server/client.py +31 -0
  24. codex_autorunner/integrations/app_server/supervisor.py +3 -0
  25. codex_autorunner/integrations/telegram/adapter.py +1 -1
  26. codex_autorunner/integrations/telegram/config.py +1 -1
  27. codex_autorunner/integrations/telegram/constants.py +1 -1
  28. codex_autorunner/integrations/telegram/handlers/commands/execution.py +16 -15
  29. codex_autorunner/integrations/telegram/handlers/commands/files.py +5 -8
  30. codex_autorunner/integrations/telegram/handlers/commands/github.py +10 -6
  31. codex_autorunner/integrations/telegram/handlers/commands/shared.py +9 -8
  32. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +85 -2
  33. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +29 -8
  34. codex_autorunner/integrations/telegram/handlers/messages.py +8 -2
  35. codex_autorunner/integrations/telegram/helpers.py +30 -2
  36. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +54 -3
  37. codex_autorunner/static/archive.js +274 -81
  38. codex_autorunner/static/archiveApi.js +21 -0
  39. codex_autorunner/static/constants.js +1 -1
  40. codex_autorunner/static/docChatCore.js +2 -0
  41. codex_autorunner/static/hub.js +59 -0
  42. codex_autorunner/static/index.html +70 -54
  43. codex_autorunner/static/notificationBell.js +173 -0
  44. codex_autorunner/static/notifications.js +187 -36
  45. codex_autorunner/static/pma.js +96 -35
  46. codex_autorunner/static/styles.css +431 -4
  47. codex_autorunner/static/terminalManager.js +22 -3
  48. codex_autorunner/static/utils.js +5 -1
  49. codex_autorunner/surfaces/cli/cli.py +206 -129
  50. codex_autorunner/surfaces/cli/template_repos.py +157 -0
  51. codex_autorunner/surfaces/web/app.py +193 -5
  52. codex_autorunner/surfaces/web/routes/archive.py +197 -0
  53. codex_autorunner/surfaces/web/routes/file_chat.py +115 -87
  54. codex_autorunner/surfaces/web/routes/flows.py +125 -67
  55. codex_autorunner/surfaces/web/routes/pma.py +638 -57
  56. codex_autorunner/surfaces/web/schemas.py +11 -0
  57. codex_autorunner/tickets/agent_pool.py +6 -1
  58. codex_autorunner/tickets/outbox.py +27 -14
  59. codex_autorunner/tickets/replies.py +4 -10
  60. codex_autorunner/tickets/runner.py +1 -0
  61. codex_autorunner/workspace/paths.py +8 -3
  62. {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/METADATA +1 -1
  63. {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/RECORD +67 -57
  64. {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/WHEEL +0 -0
  65. {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/entry_points.txt +0 -0
  66. {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/licenses/LICENSE +0 -0
  67. {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/top_level.txt +0 -0
@@ -47,6 +47,7 @@ class WorkspaceAppServerSupervisor:
47
47
  restart_backoff_initial_seconds: Optional[float] = None,
48
48
  restart_backoff_max_seconds: Optional[float] = None,
49
49
  restart_backoff_jitter_ratio: Optional[float] = None,
50
+ output_policy: str = "final_only",
50
51
  default_approval_decision: str = "cancel",
51
52
  max_handles: Optional[int] = None,
52
53
  idle_ttl_seconds: Optional[float] = None,
@@ -78,6 +79,7 @@ class WorkspaceAppServerSupervisor:
78
79
  self._restart_backoff_initial_seconds = restart_backoff_initial_seconds
79
80
  self._restart_backoff_max_seconds = restart_backoff_max_seconds
80
81
  self._restart_backoff_jitter_ratio = restart_backoff_jitter_ratio
82
+ self._output_policy = output_policy
81
83
  self._default_approval_decision = default_approval_decision
82
84
  self._max_handles = max_handles
83
85
  self._idle_ttl_seconds = idle_ttl_seconds
@@ -170,6 +172,7 @@ class WorkspaceAppServerSupervisor:
170
172
  restart_backoff_initial_seconds=self._restart_backoff_initial_seconds,
171
173
  restart_backoff_max_seconds=self._restart_backoff_max_seconds,
172
174
  restart_backoff_jitter_ratio=self._restart_backoff_jitter_ratio,
175
+ output_policy=self._output_policy,
173
176
  notification_handler=self._notification_handler,
174
177
  logger=self._logger,
175
178
  )
@@ -1370,7 +1370,7 @@ class TelegramBotClient:
1370
1370
  return bool(result) if isinstance(result, bool) else False
1371
1371
 
1372
1372
  async def download_file(
1373
- self, file_path: str, max_size_bytes: int = 50 * 1024 * 1024
1373
+ self, file_path: str, max_size_bytes: int = 100 * 1024 * 1024
1374
1374
  ) -> bytes:
1375
1375
  safe_path = file_path.lstrip("/")
1376
1376
  url = f"{self._file_base_url}/{safe_path}"
@@ -44,7 +44,7 @@ DEFAULT_APP_SERVER_TURN_TIMEOUT_SECONDS = 28800
44
44
  DEFAULT_APPROVAL_TIMEOUT_SECONDS = 300.0
45
45
  DEFAULT_MEDIA_MAX_IMAGE_BYTES = 10 * 1024 * 1024
46
46
  DEFAULT_MEDIA_MAX_VOICE_BYTES = 10 * 1024 * 1024
47
- DEFAULT_MEDIA_MAX_FILE_BYTES = 10 * 1024 * 1024
47
+ DEFAULT_MEDIA_MAX_FILE_BYTES = 100 * 1024 * 1024
48
48
  DEFAULT_MEDIA_IMAGE_PROMPT = (
49
49
  "The user sent an image with no caption. Use it to continue the "
50
50
  "conversation; if no clear task, describe the image and ask what they want."
@@ -120,7 +120,7 @@ MAX_MENTION_BYTES = 200_000
120
120
  VALID_REASONING_EFFORTS = {"none", "minimal", "low", "medium", "high", "xhigh"}
121
121
  VALID_AGENT_VALUES = {"codex", "opencode"}
122
122
  DEFAULT_AGENT_MODELS = {
123
- "codex": "gpt-5.2-codex",
123
+ "codex": "gpt-5.3-codex",
124
124
  "opencode": "zai-coding-plan/glm-4.7",
125
125
  }
126
126
  LEGACY_DEFAULT_AGENT_MODELS = DEFAULT_AGENT_MODELS
@@ -81,6 +81,7 @@ from ...helpers import (
81
81
  _set_thread_summary,
82
82
  _with_conversation_id,
83
83
  find_github_links,
84
+ format_public_error,
84
85
  is_interrupt_status,
85
86
  )
86
87
  from ...state import topic_key as build_topic_key
@@ -340,12 +341,12 @@ def _format_opencode_exception(exc: Exception) -> Optional[str]:
340
341
  if isinstance(exc, OpenCodeSupervisorError):
341
342
  detail = str(exc).strip()
342
343
  if detail:
343
- return f"OpenCode backend unavailable ({detail})."
344
+ return f"OpenCode backend unavailable ({format_public_error(detail)})."
344
345
  return "OpenCode backend unavailable."
345
346
  if isinstance(exc, OpenCodeProtocolError):
346
347
  detail = str(exc).strip()
347
348
  if detail:
348
- return f"OpenCode protocol error: {detail}"
349
+ return f"OpenCode protocol error: {format_public_error(detail)}"
349
350
  return "OpenCode protocol error."
350
351
  if isinstance(exc, json.JSONDecodeError):
351
352
  return "OpenCode returned invalid JSON."
@@ -356,15 +357,15 @@ def _format_opencode_exception(exc: Exception) -> Optional[str]:
356
357
  except Exception:
357
358
  detail = None
358
359
  if detail:
359
- return f"OpenCode error: {detail}"
360
+ return f"OpenCode error: {format_public_error(detail)}"
360
361
  response_text = exc.response.text.strip()
361
362
  if response_text:
362
- return f"OpenCode error: {response_text}"
363
+ return f"OpenCode error: {format_public_error(response_text)}"
363
364
  return f"OpenCode request failed (HTTP {exc.response.status_code})."
364
365
  if isinstance(exc, httpx.RequestError):
365
366
  detail = str(exc).strip()
366
367
  if detail:
367
- return f"OpenCode request failed: {detail}"
368
+ return f"OpenCode request failed: {format_public_error(detail)}"
368
369
  return "OpenCode request failed."
369
370
  return None
370
371
 
@@ -399,15 +400,15 @@ def _format_httpx_exception(exc: Exception) -> Optional[str]:
399
400
  payload.get("detail") or payload.get("message") or payload.get("error")
400
401
  )
401
402
  if isinstance(detail, str) and detail:
402
- return detail
403
+ return format_public_error(detail)
403
404
  response_text = exc.response.text.strip()
404
405
  if response_text:
405
- return response_text
406
+ return format_public_error(response_text)
406
407
  return f"Request failed (HTTP {exc.response.status_code})."
407
408
  if isinstance(exc, httpx.RequestError):
408
409
  detail = str(exc).strip()
409
410
  if detail:
410
- return detail
411
+ return format_public_error(detail)
411
412
  return "Request failed."
412
413
  return None
413
414
 
@@ -424,10 +425,7 @@ def _iter_exception_chain(exc: BaseException) -> list[BaseException]:
424
425
 
425
426
 
426
427
  def _sanitize_error_detail(detail: str, *, limit: int = 200) -> str:
427
- cleaned = " ".join(detail.split())
428
- if len(cleaned) > limit:
429
- return f"{cleaned[: limit - 3]}..."
430
- return cleaned
428
+ return format_public_error(detail, limit=limit)
431
429
 
432
430
 
433
431
  def _format_telegram_download_error(exc: Exception) -> Optional[str]:
@@ -435,10 +433,10 @@ def _format_telegram_download_error(exc: Exception) -> Optional[str]:
435
433
  if isinstance(current, Exception):
436
434
  detail = _format_httpx_exception(current)
437
435
  if detail:
438
- return _sanitize_error_detail(detail)
436
+ return format_public_error(detail)
439
437
  message = str(current).strip()
440
438
  if message and message not in _GENERIC_TELEGRAM_ERRORS:
441
- return _sanitize_error_detail(message)
439
+ return format_public_error(message)
442
440
  return None
443
441
 
444
442
 
@@ -2386,7 +2384,10 @@ class ExecutionCommands(SharedHelpers):
2386
2384
  runtime.interrupt_requested = False
2387
2385
 
2388
2386
  response = _compose_agent_response(
2389
- result.agent_messages, errors=result.errors, status=result.status
2387
+ getattr(result, "final_message", None),
2388
+ messages=result.agent_messages,
2389
+ errors=result.errors,
2390
+ status=result.status,
2390
2391
  )
2391
2392
  if thread_id and result.agent_messages:
2392
2393
  assistant_preview = _preview_from_text(
@@ -15,7 +15,7 @@ from .....core.logging_utils import log_event
15
15
  from .....core.state import now_iso
16
16
  from ...adapter import TelegramMessage
17
17
  from ...config import TelegramMediaCandidate
18
- from ...helpers import _path_within
18
+ from ...helpers import _path_within, format_public_error
19
19
  from ...state import PendingVoiceRecord, TelegramTopicRecord
20
20
  from .. import messages as message_handlers
21
21
  from .shared import SharedHelpers
@@ -57,10 +57,7 @@ def _iter_exception_chain(exc: BaseException) -> list[BaseException]:
57
57
 
58
58
 
59
59
  def _sanitize_error_detail(detail: str, *, limit: int = 200) -> str:
60
- cleaned = " ".join(detail.split())
61
- if len(cleaned) > limit:
62
- return f"{cleaned[: limit - 3]}..."
63
- return cleaned
60
+ return format_public_error(detail, limit=limit)
64
61
 
65
62
 
66
63
  @dataclass
@@ -166,10 +163,10 @@ class FilesCommands(SharedHelpers):
166
163
  if isinstance(current, Exception):
167
164
  detail = self._format_httpx_exception(current)
168
165
  if detail:
169
- return _sanitize_error_detail(detail)
166
+ return format_public_error(detail)
170
167
  message = str(current).strip()
171
168
  if message and message not in _GENERIC_TELEGRAM_ERRORS:
172
- return _sanitize_error_detail(message)
169
+ return format_public_error(message)
173
170
  return None
174
171
 
175
172
  def _format_download_failure_response(
@@ -177,7 +174,7 @@ class FilesCommands(SharedHelpers):
177
174
  ) -> str:
178
175
  base = f"Failed to download {kind}."
179
176
  if detail:
180
- return f"{base} Reason: {detail}"
177
+ return f"{base} Reason: {format_public_error(detail)}"
181
178
  return base
182
179
 
183
180
  def _format_media_batch_failure(
@@ -57,6 +57,7 @@ from ...helpers import (
57
57
  _preview_from_text,
58
58
  _set_thread_summary,
59
59
  _with_conversation_id,
60
+ format_public_error,
60
61
  is_interrupt_status,
61
62
  )
62
63
  from ...types import ReviewCommitSelectionState, TurnContext
@@ -493,7 +494,10 @@ class GitHubCommands(SharedHelpers):
493
494
  ) -> None:
494
495
  """Handle successful Codex review completion."""
495
496
  response = _compose_agent_response(
496
- result.agent_messages, errors=result.errors, status=result.status
497
+ getattr(result, "final_message", None),
498
+ messages=result.agent_messages,
499
+ errors=result.errors,
500
+ status=result.status,
497
501
  )
498
502
  if thread_id and result.agent_messages:
499
503
  assistant_preview = _preview_from_text(
@@ -1640,12 +1644,12 @@ def _format_opencode_exception(exc: Exception) -> Optional[str]:
1640
1644
  if isinstance(exc, OpenCodeSupervisorError):
1641
1645
  detail = str(exc).strip()
1642
1646
  if detail:
1643
- return f"OpenCode backend unavailable ({detail})."
1647
+ return f"OpenCode backend unavailable ({format_public_error(detail)})."
1644
1648
  return "OpenCode backend unavailable."
1645
1649
  if isinstance(exc, OpenCodeProtocolError):
1646
1650
  detail = str(exc).strip()
1647
1651
  if detail:
1648
- return f"OpenCode protocol error: {detail}"
1652
+ return f"OpenCode protocol error: {format_public_error(detail)}"
1649
1653
  return "OpenCode protocol error."
1650
1654
  if isinstance(exc, json.JSONDecodeError):
1651
1655
  return "OpenCode returned invalid JSON."
@@ -1656,15 +1660,15 @@ def _format_opencode_exception(exc: Exception) -> Optional[str]:
1656
1660
  except Exception:
1657
1661
  detail = None
1658
1662
  if detail:
1659
- return f"OpenCode error: {detail}"
1663
+ return f"OpenCode error: {format_public_error(detail)}"
1660
1664
  response_text = exc.response.text.strip()
1661
1665
  if response_text:
1662
- return f"OpenCode error: {response_text}"
1666
+ return f"OpenCode error: {format_public_error(response_text)}"
1663
1667
  return f"OpenCode request failed (HTTP {exc.response.status_code})."
1664
1668
  if isinstance(exc, httpx.RequestError):
1665
1669
  detail = str(exc).strip()
1666
1670
  if detail:
1667
- return f"OpenCode request failed: {detail}"
1671
+ return f"OpenCode request failed: {format_public_error(detail)}"
1668
1672
  return "OpenCode request failed."
1669
1673
  return None
1670
1674
 
@@ -11,6 +11,7 @@ import httpx
11
11
  from .....agents.opencode.client import OpenCodeProtocolError
12
12
  from .....agents.opencode.supervisor import OpenCodeSupervisorError
13
13
  from ...adapter import InlineButton, build_inline_keyboard, encode_cancel_callback
14
+ from ...helpers import format_public_error
14
15
 
15
16
  if TYPE_CHECKING:
16
17
  pass
@@ -60,15 +61,15 @@ class SharedHelpers:
60
61
  or payload.get("error")
61
62
  )
62
63
  if isinstance(detail, str) and detail:
63
- return detail
64
+ return format_public_error(detail)
64
65
  response_text = exc.response.text.strip()
65
66
  if response_text:
66
- return response_text
67
+ return format_public_error(response_text)
67
68
  return f"Request failed (HTTP {exc.response.status_code})."
68
69
  if isinstance(exc, httpx.RequestError):
69
70
  detail = str(exc).strip()
70
71
  if detail:
71
- return detail
72
+ return format_public_error(detail)
72
73
  return "Request failed."
73
74
  return None
74
75
 
@@ -84,12 +85,12 @@ class SharedHelpers:
84
85
  if isinstance(exc, OpenCodeSupervisorError):
85
86
  detail = str(exc).strip()
86
87
  if detail:
87
- return f"OpenCode backend unavailable ({detail})."
88
+ return f"OpenCode backend unavailable ({format_public_error(detail)})."
88
89
  return "OpenCode backend unavailable."
89
90
  if isinstance(exc, OpenCodeProtocolError):
90
91
  detail = str(exc).strip()
91
92
  if detail:
92
- return f"OpenCode protocol error: {detail}"
93
+ return f"OpenCode protocol error: {format_public_error(detail)}"
93
94
  return "OpenCode protocol error."
94
95
  if isinstance(exc, json.JSONDecodeError):
95
96
  return "OpenCode returned invalid JSON."
@@ -100,15 +101,15 @@ class SharedHelpers:
100
101
  except Exception:
101
102
  detail = None
102
103
  if detail:
103
- return f"OpenCode error: {detail}"
104
+ return f"OpenCode error: {format_public_error(detail)}"
104
105
  response_text = exc.response.text.strip()
105
106
  if response_text:
106
- return f"OpenCode error: {response_text}"
107
+ return f"OpenCode error: {format_public_error(response_text)}"
107
108
  return f"OpenCode request failed (HTTP {exc.response.status_code})."
108
109
  if isinstance(exc, httpx.RequestError):
109
110
  detail = str(exc).strip()
110
111
  if detail:
111
- return f"OpenCode request failed: {detail}"
112
+ return f"OpenCode request failed: {format_public_error(detail)}"
112
113
  return "OpenCode request failed."
113
114
  return None
114
115
 
@@ -16,6 +16,7 @@ from .....core.utils import canonicalize_path, resolve_opencode_binary
16
16
  from .....manifest import load_manifest
17
17
  from ....app_server.client import (
18
18
  CodexAppServerClient,
19
+ CodexAppServerResponseError,
19
20
  )
20
21
  from ...adapter import (
21
22
  TelegramCallbackQuery,
@@ -112,6 +113,12 @@ class ResumeThreadData:
112
113
 
113
114
 
114
115
  class WorkspaceCommands(SharedHelpers):
116
+ def _is_missing_thread_error(self, exc: Exception) -> bool:
117
+ if not isinstance(exc, CodexAppServerResponseError):
118
+ return False
119
+ message = str(exc).lower()
120
+ return "thread not found" in message
121
+
115
122
  def _resolve_workspace_path(
116
123
  self,
117
124
  record: Optional["TelegramTopicRecord"],
@@ -390,6 +397,18 @@ class WorkspaceCommands(SharedHelpers):
390
397
  try:
391
398
  result = await client.thread_resume(thread_id)
392
399
  except Exception as exc:
400
+ if self._is_missing_thread_error(exc):
401
+ log_event(
402
+ self._logger,
403
+ logging.INFO,
404
+ "telegram.thread.verify_missing",
405
+ chat_id=message.chat_id,
406
+ thread_id=message.thread_id,
407
+ codex_thread_id=thread_id,
408
+ )
409
+ return await self._router.set_active_thread(
410
+ message.chat_id, message.thread_id, None
411
+ )
393
412
  log_event(
394
413
  self._logger,
395
414
  logging.WARNING,
@@ -1113,7 +1132,25 @@ class WorkspaceCommands(SharedHelpers):
1113
1132
  reply_to=message.message_id,
1114
1133
  )
1115
1134
  return
1116
- thread = await client.thread_start(record.workspace_path, agent=agent)
1135
+ try:
1136
+ thread = await client.thread_start(record.workspace_path, agent=agent)
1137
+ except Exception as exc:
1138
+ log_event(
1139
+ self._logger,
1140
+ logging.WARNING,
1141
+ "telegram.reset.failed",
1142
+ chat_id=message.chat_id,
1143
+ thread_id=message.thread_id,
1144
+ workspace_path=record.workspace_path,
1145
+ exc=exc,
1146
+ )
1147
+ await self._send_message(
1148
+ message.chat_id,
1149
+ "Failed to reset thread; check logs for details.",
1150
+ thread_id=message.thread_id,
1151
+ reply_to=message.message_id,
1152
+ )
1153
+ return
1117
1154
  if not await self._require_thread_workspace(
1118
1155
  message, record.workspace_path, thread, action="thread_start"
1119
1156
  ):
@@ -1264,7 +1301,25 @@ class WorkspaceCommands(SharedHelpers):
1264
1301
  reply_to=message.message_id,
1265
1302
  )
1266
1303
  return
1267
- thread = await client.thread_start(record.workspace_path, agent=agent)
1304
+ try:
1305
+ thread = await client.thread_start(record.workspace_path, agent=agent)
1306
+ except Exception as exc:
1307
+ log_event(
1308
+ self._logger,
1309
+ logging.WARNING,
1310
+ "telegram.new.failed",
1311
+ chat_id=message.chat_id,
1312
+ thread_id=message.thread_id,
1313
+ workspace_path=record.workspace_path,
1314
+ exc=exc,
1315
+ )
1316
+ await self._send_message(
1317
+ message.chat_id,
1318
+ "Failed to start a new thread; check logs for details.",
1319
+ thread_id=message.thread_id,
1320
+ reply_to=message.message_id,
1321
+ )
1322
+ return
1268
1323
  if not await self._require_thread_workspace(
1269
1324
  message, record.workspace_path, thread, action="thread_start"
1270
1325
  ):
@@ -2044,6 +2099,34 @@ class WorkspaceCommands(SharedHelpers):
2044
2099
  try:
2045
2100
  result = await client.thread_resume(thread_id)
2046
2101
  except Exception as exc:
2102
+ if self._is_missing_thread_error(exc):
2103
+ log_event(
2104
+ self._logger,
2105
+ logging.INFO,
2106
+ "telegram.resume.missing_thread",
2107
+ topic_key=key,
2108
+ thread_id=thread_id,
2109
+ )
2110
+
2111
+ def clear_stale(record: "TelegramTopicRecord") -> None:
2112
+ if record.active_thread_id == thread_id:
2113
+ record.active_thread_id = None
2114
+ if thread_id in record.thread_ids:
2115
+ record.thread_ids.remove(thread_id)
2116
+ record.thread_summaries.pop(thread_id, None)
2117
+
2118
+ await self._store.update_topic(key, clear_stale)
2119
+ await self._answer_callback(callback, "Thread missing")
2120
+ await self._finalize_selection(
2121
+ key,
2122
+ callback,
2123
+ _with_conversation_id(
2124
+ "Thread no longer exists. Cleared stale state; use /new to start a fresh thread.",
2125
+ chat_id=chat_id,
2126
+ thread_id=thread_id_val,
2127
+ ),
2128
+ )
2129
+ return
2047
2130
  log_event(
2048
2131
  self._logger,
2049
2132
  logging.WARNING,
@@ -18,6 +18,7 @@ import httpx
18
18
  from ....agents.opencode.client import OpenCodeProtocolError
19
19
  from ....agents.opencode.supervisor import OpenCodeSupervisorError
20
20
  from ....core.logging_utils import log_event
21
+ from ....core.pma_sink import PmaActiveSinkStore
21
22
  from ....core.state import now_iso
22
23
  from ....core.update import _normalize_update_target, _spawn_update_process
23
24
  from ....core.update_paths import resolve_update_paths
@@ -81,6 +82,7 @@ from ..helpers import (
81
82
  _with_conversation_id,
82
83
  derive_codex_features_command,
83
84
  format_codex_features,
85
+ format_public_error,
84
86
  parse_codex_features_list,
85
87
  )
86
88
  from ..state import (
@@ -158,12 +160,12 @@ def _format_opencode_exception(exc: Exception) -> Optional[str]:
158
160
  if isinstance(exc, OpenCodeSupervisorError):
159
161
  detail = str(exc).strip()
160
162
  if detail:
161
- return f"OpenCode backend unavailable ({detail})."
163
+ return f"OpenCode backend unavailable ({format_public_error(detail)})."
162
164
  return "OpenCode backend unavailable."
163
165
  if isinstance(exc, OpenCodeProtocolError):
164
166
  detail = str(exc).strip()
165
167
  if detail:
166
- return f"OpenCode protocol error: {detail}"
168
+ return f"OpenCode protocol error: {format_public_error(detail)}"
167
169
  return "OpenCode protocol error."
168
170
  if isinstance(exc, json.JSONDecodeError):
169
171
  return "OpenCode returned invalid JSON."
@@ -174,15 +176,15 @@ def _format_opencode_exception(exc: Exception) -> Optional[str]:
174
176
  except Exception:
175
177
  detail = None
176
178
  if detail:
177
- return f"OpenCode error: {detail}"
179
+ return f"OpenCode error: {format_public_error(detail)}"
178
180
  response_text = exc.response.text.strip()
179
181
  if response_text:
180
- return f"OpenCode error: {response_text}"
182
+ return f"OpenCode error: {format_public_error(response_text)}"
181
183
  return f"OpenCode request failed (HTTP {exc.response.status_code})."
182
184
  if isinstance(exc, httpx.RequestError):
183
185
  detail = str(exc).strip()
184
186
  if detail:
185
- return f"OpenCode request failed: {detail}"
187
+ return f"OpenCode request failed: {format_public_error(detail)}"
186
188
  return "OpenCode request failed."
187
189
  return None
188
190
 
@@ -239,15 +241,15 @@ def _format_httpx_exception(exc: Exception) -> Optional[str]:
239
241
  payload.get("detail") or payload.get("message") or payload.get("error")
240
242
  )
241
243
  if isinstance(detail, str) and detail:
242
- return detail
244
+ return format_public_error(detail)
243
245
  response_text = exc.response.text.strip()
244
246
  if response_text:
245
- return response_text
247
+ return format_public_error(response_text)
246
248
  return f"Request failed (HTTP {exc.response.status_code})."
247
249
  if isinstance(exc, httpx.RequestError):
248
250
  detail = str(exc).strip()
249
251
  if detail:
250
- return detail
252
+ return format_public_error(detail)
251
253
  return "Request failed."
252
254
  return None
253
255
 
@@ -1202,6 +1204,25 @@ class TelegramCommandHandlers(
1202
1204
  message.thread_id,
1203
1205
  apply_pma,
1204
1206
  )
1207
+ try:
1208
+ sink_store = PmaActiveSinkStore(Path(self._hub_root))
1209
+ if enabled:
1210
+ sink_store.set_telegram(
1211
+ chat_id=message.chat_id,
1212
+ thread_id=message.thread_id,
1213
+ topic_key=topic_key(message.chat_id, message.thread_id),
1214
+ )
1215
+ else:
1216
+ sink_store.clear()
1217
+ except Exception:
1218
+ log_event(
1219
+ self._logger,
1220
+ logging.WARNING,
1221
+ "telegram.pma.active_sink.update_failed",
1222
+ chat_id=message.chat_id,
1223
+ thread_id=message.thread_id,
1224
+ enabled=enabled,
1225
+ )
1205
1226
  status = "enabled" if enabled else "disabled"
1206
1227
  if enabled:
1207
1228
  hint = "Use /pma off to exit. Previous repo binding saved."
@@ -859,7 +859,10 @@ async def handle_media_message(
859
859
  best = photos[0]
860
860
  try:
861
861
  file_info = await handlers._bot.get_file(best.file_id)
862
- 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
+ )
863
866
  filename = f"photo_{best.file_id}.jpg"
864
867
  files.append((filename, data))
865
868
  except Exception as exc:
@@ -868,7 +871,10 @@ async def handle_media_message(
868
871
  elif message.document:
869
872
  try:
870
873
  file_info = await handlers._bot.get_file(message.document.file_id)
871
- 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
+ )
872
878
  filename = (
873
879
  message.document.file_name or f"document_{message.document.file_id}"
874
880
  )
@@ -9,6 +9,7 @@ from datetime import datetime, timedelta, timezone
9
9
  from pathlib import Path
10
10
  from typing import Any, Callable, Iterable, Optional, Sequence
11
11
 
12
+ from ...core.redaction import redact_text
12
13
  from ...core.state_roots import resolve_global_state_root
13
14
  from ...core.utils import (
14
15
  RepoNotFoundError,
@@ -2009,12 +2010,17 @@ def _extract_first_bold_span(text: str) -> Optional[str]:
2009
2010
 
2010
2011
 
2011
2012
  def _compose_agent_response(
2012
- messages: list[str],
2013
+ final_message: Optional[str] = None,
2013
2014
  *,
2015
+ messages: Optional[list[str]] = None,
2014
2016
  errors: Optional[list[str]] = None,
2015
2017
  status: Optional[str] = None,
2016
2018
  ) -> str:
2017
- cleaned = [msg.strip() for msg in messages if isinstance(msg, str) and msg.strip()]
2019
+ if isinstance(final_message, str) and final_message.strip():
2020
+ return final_message.strip()
2021
+ cleaned = [
2022
+ msg.strip() for msg in (messages or []) if isinstance(msg, str) and msg.strip()
2023
+ ]
2018
2024
  if not cleaned:
2019
2025
  cleaned_errors = [
2020
2026
  err.strip()
@@ -2183,3 +2189,25 @@ def _format_selection_prompt(base: str, page: int, total_pages: int) -> str:
2183
2189
  return base
2184
2190
  trimmed = base.rstrip(".")
2185
2191
  return f"{trimmed} (page {page + 1}/{total_pages})."
2192
+
2193
+
2194
+ def format_public_error(detail: str, *, limit: int = 200) -> str:
2195
+ """Format error detail for public Telegram messages with redaction and truncation.
2196
+
2197
+ This helper ensures all user-visible error text sent via Telegram is:
2198
+ - Short and readable
2199
+ - Redacted for known secret patterns
2200
+ - Does not include raw file contents or stack traces
2201
+
2202
+ Args:
2203
+ detail: Error detail string to format.
2204
+ limit: Maximum length of output (default 200).
2205
+
2206
+ Returns:
2207
+ Formatted error string with secrets redacted and length limited.
2208
+ """
2209
+ normalized = " ".join(detail.split())
2210
+ redacted = redact_text(normalized)
2211
+ if len(redacted) > limit:
2212
+ return f"{redacted[: limit - 3]}..."
2213
+ return redacted