codex-autorunner 1.2.1__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 (55) hide show
  1. codex_autorunner/bootstrap.py +26 -5
  2. codex_autorunner/core/config.py +176 -59
  3. codex_autorunner/core/filesystem.py +24 -0
  4. codex_autorunner/core/flows/controller.py +50 -12
  5. codex_autorunner/core/flows/runtime.py +8 -3
  6. codex_autorunner/core/hub.py +293 -16
  7. codex_autorunner/core/lifecycle_events.py +44 -5
  8. codex_autorunner/core/pma_delivery.py +81 -0
  9. codex_autorunner/core/pma_dispatches.py +224 -0
  10. codex_autorunner/core/pma_lane_worker.py +122 -0
  11. codex_autorunner/core/pma_queue.py +167 -18
  12. codex_autorunner/core/pma_reactive.py +91 -0
  13. codex_autorunner/core/pma_safety.py +58 -0
  14. codex_autorunner/core/pma_sink.py +104 -0
  15. codex_autorunner/core/pma_transcripts.py +183 -0
  16. codex_autorunner/core/safe_paths.py +117 -0
  17. codex_autorunner/housekeeping.py +77 -23
  18. codex_autorunner/integrations/agents/codex_backend.py +18 -12
  19. codex_autorunner/integrations/agents/wiring.py +2 -0
  20. codex_autorunner/integrations/app_server/client.py +31 -0
  21. codex_autorunner/integrations/app_server/supervisor.py +3 -0
  22. codex_autorunner/integrations/telegram/constants.py +1 -1
  23. codex_autorunner/integrations/telegram/handlers/commands/execution.py +16 -15
  24. codex_autorunner/integrations/telegram/handlers/commands/files.py +5 -8
  25. codex_autorunner/integrations/telegram/handlers/commands/github.py +10 -6
  26. codex_autorunner/integrations/telegram/handlers/commands/shared.py +9 -8
  27. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +85 -2
  28. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +29 -8
  29. codex_autorunner/integrations/telegram/helpers.py +30 -2
  30. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +54 -3
  31. codex_autorunner/static/docChatCore.js +2 -0
  32. codex_autorunner/static/hub.js +59 -0
  33. codex_autorunner/static/index.html +70 -54
  34. codex_autorunner/static/notificationBell.js +173 -0
  35. codex_autorunner/static/notifications.js +154 -36
  36. codex_autorunner/static/pma.js +96 -35
  37. codex_autorunner/static/styles.css +415 -4
  38. codex_autorunner/static/utils.js +5 -1
  39. codex_autorunner/surfaces/cli/cli.py +206 -129
  40. codex_autorunner/surfaces/cli/template_repos.py +157 -0
  41. codex_autorunner/surfaces/web/app.py +193 -5
  42. codex_autorunner/surfaces/web/routes/file_chat.py +109 -61
  43. codex_autorunner/surfaces/web/routes/flows.py +125 -67
  44. codex_autorunner/surfaces/web/routes/pma.py +638 -57
  45. codex_autorunner/tickets/agent_pool.py +6 -1
  46. codex_autorunner/tickets/outbox.py +27 -14
  47. codex_autorunner/tickets/replies.py +4 -10
  48. codex_autorunner/tickets/runner.py +1 -0
  49. codex_autorunner/workspace/paths.py +8 -3
  50. {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/METADATA +1 -1
  51. {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/RECORD +55 -45
  52. {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/WHEEL +0 -0
  53. {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/entry_points.txt +0 -0
  54. {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/licenses/LICENSE +0 -0
  55. {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/top_level.txt +0 -0
@@ -47,6 +47,7 @@ class CodexAppServerBackend(AgentBackend):
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
  notification_handler: Optional[NotificationHandler] = None,
51
52
  logger: Optional[logging.Logger] = None,
52
53
  ):
@@ -71,6 +72,7 @@ class CodexAppServerBackend(AgentBackend):
71
72
  self._restart_backoff_initial_seconds = restart_backoff_initial_seconds
72
73
  self._restart_backoff_max_seconds = restart_backoff_max_seconds
73
74
  self._restart_backoff_jitter_ratio = restart_backoff_jitter_ratio
75
+ self._output_policy = output_policy
74
76
  self._notification_handler = notification_handler
75
77
  self._logger = logger or _logger
76
78
 
@@ -102,6 +104,7 @@ class CodexAppServerBackend(AgentBackend):
102
104
  restart_backoff_initial_seconds=self._restart_backoff_initial_seconds,
103
105
  restart_backoff_max_seconds=self._restart_backoff_max_seconds,
104
106
  restart_backoff_jitter_ratio=self._restart_backoff_jitter_ratio,
107
+ output_policy=self._output_policy,
105
108
  logger=self._logger,
106
109
  )
107
110
  await self._client.start()
@@ -201,16 +204,18 @@ class CodexAppServerBackend(AgentBackend):
201
204
  yield AgentEvent.stream_delta(content=message, delta_type="user_message")
202
205
 
203
206
  result = await handle.wait(timeout=self._turn_timeout_seconds)
204
-
205
- for msg in result.agent_messages:
206
- yield AgentEvent.stream_delta(content=msg, delta_type="assistant_message")
207
+ final_text = str(getattr(result, "final_message", "") or "")
208
+ if not final_text.strip():
209
+ final_text = "\n\n".join(
210
+ msg.strip()
211
+ for msg in getattr(result, "agent_messages", [])
212
+ if isinstance(msg, str) and msg.strip()
213
+ )
207
214
 
208
215
  for event_data in result.raw_events:
209
216
  yield self._parse_raw_event(event_data)
210
217
 
211
- yield AgentEvent.message_complete(
212
- final_message="\n".join(result.agent_messages)
213
- )
218
+ yield AgentEvent.message_complete(final_message=final_text)
214
219
 
215
220
  async def run_turn_events(
216
221
  self, session_id: str, message: str
@@ -283,11 +288,12 @@ class CodexAppServerBackend(AgentBackend):
283
288
  if get_task in pending_set:
284
289
  get_task.cancel()
285
290
  result = wait_task.result()
286
- for msg in result.agent_messages:
287
- yield OutputDelta(
288
- timestamp=now_iso(),
289
- content=msg,
290
- delta_type="assistant_message",
291
+ final_text = str(getattr(result, "final_message", "") or "")
292
+ if not final_text.strip():
293
+ final_text = "\n\n".join(
294
+ msg.strip()
295
+ for msg in getattr(result, "agent_messages", [])
296
+ if isinstance(msg, str) and msg.strip()
291
297
  )
292
298
  # raw_events already contain the same notifications we streamed
293
299
  # through _event_queue; skipping here avoids double-emitting.
@@ -297,7 +303,7 @@ class CodexAppServerBackend(AgentBackend):
297
303
  yield extra
298
304
  yield Completed(
299
305
  timestamp=now_iso(),
300
- final_message="\n".join(result.agent_messages),
306
+ final_message=final_text,
301
307
  )
302
308
  break
303
309
 
@@ -106,6 +106,7 @@ class AgentBackendFactory:
106
106
  restart_backoff_initial_seconds=self._config.app_server.client.restart_backoff_initial_seconds,
107
107
  restart_backoff_max_seconds=self._config.app_server.client.restart_backoff_max_seconds,
108
108
  restart_backoff_jitter_ratio=self._config.app_server.client.restart_backoff_jitter_ratio,
109
+ output_policy=self._config.app_server.output.policy,
109
110
  notification_handler=notification_handler,
110
111
  logger=self._logger,
111
112
  )
@@ -267,6 +268,7 @@ def build_app_server_supervisor_factory(
267
268
  restart_backoff_initial_seconds=config.app_server.client.restart_backoff_initial_seconds,
268
269
  restart_backoff_max_seconds=config.app_server.client.restart_backoff_max_seconds,
269
270
  restart_backoff_jitter_ratio=config.app_server.client.restart_backoff_jitter_ratio,
271
+ output_policy=config.app_server.output.policy,
270
272
  )
271
273
 
272
274
  return factory
@@ -62,6 +62,8 @@ _TURN_STALL_POLL_INTERVAL_SECONDS = 2.0
62
62
  _TURN_STALL_RECOVERY_MIN_INTERVAL_SECONDS = 10.0
63
63
  _MAX_TURN_RAW_EVENTS = 200
64
64
  _INVALID_JSON_PREVIEW_BYTES = 200
65
+ _DEFAULT_OUTPUT_POLICY = "final_only"
66
+ _OUTPUT_POLICIES = {"final_only", "all_agent_messages"}
65
67
 
66
68
  # Track live clients so tests/cleanup can cancel any background restart tasks.
67
69
  _CLIENT_INSTANCES: weakref.WeakSet = weakref.WeakSet()
@@ -108,6 +110,7 @@ class CodexAppServerProtocolError(CodexAppServerError, PermanentError):
108
110
  class TurnResult:
109
111
  turn_id: str
110
112
  status: Optional[str]
113
+ final_message: str
111
114
  agent_messages: list[str]
112
115
  errors: list[str]
113
116
  raw_events: list[Dict[str, Any]]
@@ -163,6 +166,7 @@ class CodexAppServerClient:
163
166
  restart_backoff_initial_seconds: Optional[float] = None,
164
167
  restart_backoff_max_seconds: Optional[float] = None,
165
168
  restart_backoff_jitter_ratio: Optional[float] = None,
169
+ output_policy: str = _DEFAULT_OUTPUT_POLICY,
166
170
  notification_handler: Optional[NotificationHandler] = None,
167
171
  logger: Optional[logging.Logger] = None,
168
172
  ) -> None:
@@ -217,6 +221,7 @@ class CodexAppServerClient:
217
221
  and restart_backoff_jitter_ratio >= 0
218
222
  else _RESTART_BACKOFF_JITTER_RATIO
219
223
  )
224
+ self._output_policy = _normalize_output_policy(output_policy)
220
225
 
221
226
  self._process: Optional[asyncio.subprocess.Process] = None
222
227
  self._reader_task: Optional[asyncio.Task] = None
@@ -558,6 +563,9 @@ class CodexAppServerClient:
558
563
  state.future.set_result(
559
564
  TurnResult(
560
565
  turn_id=state.turn_id,
566
+ final_message=_final_message_for_result(
567
+ state, policy=self._output_policy
568
+ ),
561
569
  agent_messages=_agent_messages_for_result(state),
562
570
  errors=list(state.errors),
563
571
  raw_events=list(state.raw_events),
@@ -1269,6 +1277,9 @@ class CodexAppServerClient:
1269
1277
  TurnResult(
1270
1278
  turn_id=target.turn_id,
1271
1279
  status=target.status,
1280
+ final_message=_final_message_for_result(
1281
+ target, policy=self._output_policy
1282
+ ),
1272
1283
  agent_messages=_agent_messages_for_result(target),
1273
1284
  errors=list(target.errors),
1274
1285
  raw_events=list(target.raw_events),
@@ -1366,6 +1377,9 @@ class CodexAppServerClient:
1366
1377
  TurnResult(
1367
1378
  turn_id=state.turn_id,
1368
1379
  status=state.status,
1380
+ final_message=_final_message_for_result(
1381
+ state, policy=self._output_policy
1382
+ ),
1369
1383
  agent_messages=_agent_messages_for_result(state),
1370
1384
  errors=list(state.errors),
1371
1385
  raw_events=list(state.raw_events),
@@ -1701,6 +1715,23 @@ def _agent_messages_for_result(state: _TurnState) -> list[str]:
1701
1715
  return _agent_message_deltas_as_list(state.agent_message_deltas)
1702
1716
 
1703
1717
 
1718
+ def _normalize_output_policy(policy: Optional[str]) -> str:
1719
+ candidate = str(policy or "").strip().lower()
1720
+ if candidate in _OUTPUT_POLICIES:
1721
+ return candidate
1722
+ return _DEFAULT_OUTPUT_POLICY
1723
+
1724
+
1725
+ def _final_message_for_result(state: _TurnState, *, policy: str) -> str:
1726
+ messages = _agent_messages_for_result(state)
1727
+ cleaned = [msg.strip() for msg in messages if isinstance(msg, str) and msg.strip()]
1728
+ if not cleaned:
1729
+ return ""
1730
+ if policy == "all_agent_messages":
1731
+ return "\n\n".join(cleaned)
1732
+ return cleaned[-1]
1733
+
1734
+
1704
1735
  def _extract_status_value(value: Any) -> Optional[str]:
1705
1736
  if isinstance(value, str):
1706
1737
  return value
@@ -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
  )
@@ -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,