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
@@ -0,0 +1,183 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import re
6
+ from dataclasses import dataclass
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any, Optional
10
+
11
+ from .time_utils import now_iso
12
+ from .utils import atomic_write
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ PMA_TRANSCRIPTS_DIRNAME = "transcripts"
17
+ PMA_TRANSCRIPT_VERSION = 1
18
+ PMA_TRANSCRIPT_PREVIEW_CHARS = 400
19
+
20
+
21
+ def default_pma_transcripts_dir(hub_root: Path) -> Path:
22
+ return hub_root / ".codex-autorunner" / "pma" / PMA_TRANSCRIPTS_DIRNAME
23
+
24
+
25
+ def _safe_segment(value: str) -> str:
26
+ cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", (value or "").strip())
27
+ cleaned = cleaned.strip("-._")
28
+ if not cleaned:
29
+ return "unknown"
30
+ return cleaned[:120]
31
+
32
+
33
+ def _stamp_now() -> str:
34
+ return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
35
+
36
+
37
+ def _read_preview(path: Path) -> str:
38
+ if not path.exists():
39
+ return ""
40
+ try:
41
+ with open(path, "r", encoding="utf-8") as handle:
42
+ text = handle.read(PMA_TRANSCRIPT_PREVIEW_CHARS + 1)
43
+ except OSError as exc:
44
+ logger.warning("Failed to read PMA transcript content at %s: %s", path, exc)
45
+ return ""
46
+ text = text.strip()
47
+ if len(text) <= PMA_TRANSCRIPT_PREVIEW_CHARS:
48
+ return text
49
+ return text[:PMA_TRANSCRIPT_PREVIEW_CHARS].rstrip() + "..."
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class PmaTranscriptPointer:
54
+ turn_id: str
55
+ metadata_path: str
56
+ content_path: str
57
+ created_at: str
58
+
59
+
60
+ class PmaTranscriptStore:
61
+ def __init__(self, hub_root: Path) -> None:
62
+ self._root = hub_root
63
+ self._dir = default_pma_transcripts_dir(hub_root)
64
+
65
+ @property
66
+ def dir(self) -> Path:
67
+ return self._dir
68
+
69
+ def write_transcript(
70
+ self,
71
+ *,
72
+ turn_id: str,
73
+ metadata: dict[str, Any],
74
+ assistant_text: str,
75
+ ) -> PmaTranscriptPointer:
76
+ safe_turn_id = _safe_segment(turn_id)
77
+ stamp = _stamp_now()
78
+ base = f"{stamp}_{safe_turn_id}"
79
+ json_path = self._dir / f"{base}.json"
80
+ md_path = self._dir / f"{base}.md"
81
+
82
+ payload = dict(metadata)
83
+ payload.setdefault("version", PMA_TRANSCRIPT_VERSION)
84
+ payload.setdefault("turn_id", turn_id)
85
+ payload.setdefault("created_at", now_iso())
86
+ payload["metadata_path"] = str(json_path)
87
+ payload["content_path"] = str(md_path)
88
+ payload["assistant_text_chars"] = len(assistant_text or "")
89
+
90
+ self._dir.mkdir(parents=True, exist_ok=True)
91
+ atomic_write(md_path, (assistant_text or "") + "\n")
92
+ atomic_write(json_path, json.dumps(payload, indent=2) + "\n")
93
+
94
+ return PmaTranscriptPointer(
95
+ turn_id=turn_id,
96
+ metadata_path=str(json_path),
97
+ content_path=str(md_path),
98
+ created_at=payload["created_at"],
99
+ )
100
+
101
+ def list_recent(self, *, limit: int = 50) -> list[dict[str, Any]]:
102
+ if limit <= 0:
103
+ return []
104
+ if not self._dir.exists():
105
+ return []
106
+ entries: list[dict[str, Any]] = []
107
+ for path in sorted(self._dir.glob("*.json"), reverse=True):
108
+ try:
109
+ raw = path.read_text(encoding="utf-8")
110
+ data = json.loads(raw)
111
+ except (OSError, json.JSONDecodeError) as exc:
112
+ logger.warning(
113
+ "Failed to read PMA transcript metadata at %s: %s", path, exc
114
+ )
115
+ continue
116
+ if not isinstance(data, dict):
117
+ continue
118
+ content_path = Path(str(data.get("content_path") or ""))
119
+ if not content_path.is_absolute():
120
+ content_path = (path.parent / content_path).resolve()
121
+ data = dict(data)
122
+ data["preview"] = _read_preview(content_path)
123
+ entries.append(data)
124
+ if len(entries) >= limit:
125
+ break
126
+ return entries
127
+
128
+ def read_transcript(self, turn_id: str) -> Optional[dict[str, Any]]:
129
+ match = self._find_metadata(turn_id)
130
+ if not match:
131
+ return None
132
+ meta, meta_path = match
133
+ content_path = Path(str(meta.get("content_path") or ""))
134
+ if not content_path.is_absolute():
135
+ content_path = (meta_path.parent / content_path).resolve()
136
+ try:
137
+ content = content_path.read_text(encoding="utf-8")
138
+ except OSError as exc:
139
+ logger.warning(
140
+ "Failed to read PMA transcript content at %s: %s", content_path, exc
141
+ )
142
+ content = ""
143
+ return {"metadata": meta, "content": content}
144
+
145
+ def _find_metadata(self, turn_id: str) -> Optional[tuple[dict[str, Any], Path]]:
146
+ if not self._dir.exists():
147
+ return None
148
+ safe_turn_id = _safe_segment(turn_id)
149
+ candidates = sorted(self._dir.glob(f"*_{safe_turn_id}.json"), reverse=True)
150
+ for path in candidates:
151
+ meta = self._read_metadata(path)
152
+ if meta and str(meta.get("turn_id")) == turn_id:
153
+ return meta, path
154
+ if candidates:
155
+ meta = self._read_metadata(candidates[0])
156
+ if meta:
157
+ return meta, candidates[0]
158
+ for path in sorted(self._dir.glob("*.json"), reverse=True):
159
+ meta = self._read_metadata(path)
160
+ if meta and str(meta.get("turn_id")) == turn_id:
161
+ return meta, path
162
+ return None
163
+
164
+ def _read_metadata(self, path: Path) -> Optional[dict[str, Any]]:
165
+ try:
166
+ raw = path.read_text(encoding="utf-8")
167
+ data = json.loads(raw)
168
+ except (OSError, json.JSONDecodeError) as exc:
169
+ logger.warning(
170
+ "Failed to read PMA transcript metadata at %s: %s", path, exc
171
+ )
172
+ return None
173
+ return data if isinstance(data, dict) else None
174
+
175
+
176
+ __all__ = [
177
+ "PMA_TRANSCRIPTS_DIRNAME",
178
+ "PMA_TRANSCRIPT_PREVIEW_CHARS",
179
+ "PMA_TRANSCRIPT_VERSION",
180
+ "PmaTranscriptPointer",
181
+ "PmaTranscriptStore",
182
+ "default_pma_transcripts_dir",
183
+ ]
@@ -0,0 +1,117 @@
1
+ """Safe path validation utilities for web endpoints.
2
+
3
+ This module provides utilities for validating user-controlled paths to prevent
4
+ directory traversal attacks and other path-based security issues.
5
+ """
6
+
7
+ from pathlib import PurePosixPath
8
+ from typing import Optional
9
+
10
+
11
+ class SafePathError(Exception):
12
+ """Raised when a path fails safety validation."""
13
+
14
+ def __init__(self, message: str, path: Optional[str] = None) -> None:
15
+ super().__init__(message)
16
+ self.path = path
17
+
18
+
19
+ def validate_relative_posix_path(raw: str) -> PurePosixPath:
20
+ """Validate a user-provided path string and return a PurePosixPath.
21
+
22
+ This function validates that:
23
+ 1. The path is not absolute
24
+ 2. The path does not contain '..' segments (parent directory traversal)
25
+ 3. The path does not contain backslashes (Windows separators)
26
+ 4. The path is not empty, '.', or only slashes
27
+
28
+ Args:
29
+ raw: The user-provided path string (typically from a URL path parameter)
30
+
31
+ Returns:
32
+ A validated PurePosixPath object
33
+
34
+ Raises:
35
+ SafePathError: If the path fails validation
36
+
37
+ Examples:
38
+ >>> validate_relative_posix_path("file.txt")
39
+ PurePosixPath('file.txt')
40
+
41
+ >>> validate_relative_posix_path("a/b/c.txt")
42
+ PurePosixPath('a/b/c.txt')
43
+
44
+ >>> validate_relative_posix_path("../etc/passwd")
45
+ SafePathError: Invalid path: '..' not allowed
46
+
47
+ >>> validate_relative_posix_path("/etc/passwd")
48
+ SafePathError: Absolute paths not allowed
49
+ """
50
+ if not raw or raw.strip() == "" or raw == ".":
51
+ raise SafePathError("Invalid path: empty or '.'", path=raw)
52
+
53
+ # Reject backslashes early (Windows separators)
54
+ if "\\" in raw:
55
+ raise SafePathError("Invalid path: backslashes not allowed", path=raw)
56
+
57
+ # Reject '..' in the raw path before PurePosixPath normalizes it
58
+ # We need to check the raw string because PurePosixPath("a/../b")
59
+ # normalizes to "b", which would bypass the later parts check
60
+ if ".." in raw:
61
+ raise SafePathError("Invalid path: '..' not allowed", path=raw)
62
+
63
+ # Parse with PurePosixPath to ensure POSIX semantics
64
+ try:
65
+ file_rel = PurePosixPath(raw)
66
+ except Exception as exc:
67
+ raise SafePathError(f"Invalid path: {exc}", path=raw) from exc
68
+
69
+ # Reject absolute paths
70
+ if file_rel.is_absolute():
71
+ raise SafePathError("Absolute paths not allowed", path=raw)
72
+
73
+ # Double-check '..' traversal segments after parsing (for edge cases)
74
+ if ".." in file_rel.parts:
75
+ raise SafePathError("Invalid path: '..' not allowed", path=raw)
76
+
77
+ return file_rel
78
+
79
+
80
+ def validate_single_filename(raw: str) -> str:
81
+ """Validate that a path string represents only a single filename (no subpaths).
82
+
83
+ This is a stricter version of validate_relative_posix_path that only allows
84
+ a single filename component, not subdirectories.
85
+
86
+ Args:
87
+ raw: The user-provided path string
88
+
89
+ Returns:
90
+ The validated filename
91
+
92
+ Raises:
93
+ SafePathError: If the path contains slashes or is otherwise invalid
94
+
95
+ Examples:
96
+ >>> validate_single_filename("file.txt")
97
+ 'file.txt'
98
+
99
+ >>> validate_single_filename("a/b.txt")
100
+ SafePathError: Subpaths not allowed: only single filenames permitted
101
+
102
+ >>> validate_single_filename("../etc/passwd")
103
+ SafePathError: Subpaths not allowed: only single filenames permitted
104
+ """
105
+ file_rel = validate_relative_posix_path(raw)
106
+
107
+ # Ensure only a single component (no subpaths)
108
+ if len(file_rel.parts) != 1:
109
+ raise SafePathError(
110
+ "Subpaths not allowed: only single filenames permitted", path=raw
111
+ )
112
+
113
+ # Return the string representation of the filename
114
+ return str(file_rel)
115
+
116
+
117
+ __all__ = ["SafePathError", "validate_relative_posix_path", "validate_single_filename"]
@@ -8,6 +8,8 @@ from collections import deque
8
8
  from pathlib import Path
9
9
  from typing import Any, Iterable, Optional, Protocol, cast
10
10
 
11
+ _MAX_ERROR_SAMPLES = 5
12
+
11
13
 
12
14
  @dataclasses.dataclass(frozen=True)
13
15
  class HousekeepingRule:
@@ -42,6 +44,7 @@ class HousekeepingRuleResult:
42
44
  deleted_bytes: int = 0
43
45
  truncated_bytes: int = 0
44
46
  errors: int = 0
47
+ error_samples: list[str] = dataclasses.field(default_factory=list)
45
48
  duration_ms: int = 0
46
49
 
47
50
 
@@ -136,21 +139,26 @@ def run_housekeeping_once(
136
139
  continue
137
140
  results.append(result)
138
141
  if logger is not None:
142
+ log_fields: dict[str, Any] = {
143
+ "name": result.name,
144
+ "kind": result.kind,
145
+ "scanned_count": result.scanned_count,
146
+ "eligible_count": result.eligible_count,
147
+ "deleted_count": result.deleted_count,
148
+ "deleted_bytes": result.deleted_bytes,
149
+ "truncated_bytes": result.truncated_bytes,
150
+ "errors": result.errors,
151
+ "duration_ms": result.duration_ms,
152
+ "dry_run": config.dry_run,
153
+ "root": str(root),
154
+ }
155
+ if result.errors > 0 and result.error_samples:
156
+ log_fields["error_samples"] = result.error_samples
139
157
  _log_event(
140
158
  logger,
141
159
  logging.INFO,
142
160
  "housekeeping.rule",
143
- name=result.name,
144
- kind=result.kind,
145
- scanned_count=result.scanned_count,
146
- eligible_count=result.eligible_count,
147
- deleted_count=result.deleted_count,
148
- deleted_bytes=result.deleted_bytes,
149
- truncated_bytes=result.truncated_bytes,
150
- errors=result.errors,
151
- duration_ms=result.duration_ms,
152
- dry_run=config.dry_run,
153
- root=str(root),
161
+ **log_fields,
154
162
  )
155
163
  if logger is not None:
156
164
  _log_event(
@@ -174,7 +182,7 @@ def _apply_directory_rule(
174
182
  return result
175
183
  now = time.time()
176
184
  min_age = max(config.min_file_age_seconds, 0)
177
- files = _collect_files(base, rule)
185
+ files = _collect_files(base, rule, result)
178
186
  result.scanned_count = len(files)
179
187
  if not files:
180
188
  result.duration_ms = int((time.monotonic() - start) * 1000)
@@ -192,8 +200,9 @@ def _apply_directory_rule(
192
200
  if not config.dry_run:
193
201
  try:
194
202
  entry.path.unlink()
195
- except OSError:
203
+ except OSError as e:
196
204
  errors += 1
205
+ _add_error_sample(result, "unlink", entry.path, e)
197
206
  return
198
207
  deleted.add(entry.path)
199
208
  deleted_bytes += entry.size
@@ -254,8 +263,9 @@ def _apply_file_rule(
254
263
  return result
255
264
  try:
256
265
  stat = path.stat()
257
- except OSError:
266
+ except OSError as e:
258
267
  result.errors = 1
268
+ _add_error_sample(result, "stat", path, e)
259
269
  return result
260
270
  if not path.is_file():
261
271
  return result
@@ -271,6 +281,7 @@ def _apply_file_rule(
271
281
  path,
272
282
  rule.max_lines,
273
283
  dry_run=config.dry_run,
284
+ result=result,
274
285
  )
275
286
  result.truncated_bytes += truncated
276
287
  if rule.max_bytes is not None:
@@ -278,13 +289,16 @@ def _apply_file_rule(
278
289
  path,
279
290
  rule.max_bytes,
280
291
  dry_run=config.dry_run,
292
+ result=result,
281
293
  )
282
294
  result.truncated_bytes += truncated
283
295
  result.duration_ms = int((time.monotonic() - start) * 1000)
284
296
  return result
285
297
 
286
298
 
287
- def _collect_files(base: Path, rule: HousekeepingRule) -> list[_FileInfo]:
299
+ def _collect_files(
300
+ base: Path, rule: HousekeepingRule, result: Optional[HousekeepingRuleResult] = None
301
+ ) -> list[_FileInfo]:
288
302
  results: list[_FileInfo] = []
289
303
  glob_pattern = rule.glob or "*"
290
304
  iterator = base.rglob(glob_pattern) if rule.recursive else base.glob(glob_pattern)
@@ -293,7 +307,9 @@ def _collect_files(base: Path, rule: HousekeepingRule) -> list[_FileInfo]:
293
307
  if not path.is_file():
294
308
  continue
295
309
  stat = path.stat()
296
- except OSError:
310
+ except OSError as e:
311
+ if result is not None:
312
+ _add_error_sample(result, "stat", path, e)
297
313
  continue
298
314
  results.append(_FileInfo(path=path, size=stat.st_size, mtime=stat.st_mtime))
299
315
  return results
@@ -310,12 +326,21 @@ def _is_absolute_path(path: str) -> bool:
310
326
  return Path(path).expanduser().is_absolute()
311
327
 
312
328
 
313
- def _truncate_bytes(path: Path, max_bytes: int, *, dry_run: bool) -> int:
329
+ def _truncate_bytes(
330
+ path: Path,
331
+ max_bytes: int,
332
+ *,
333
+ dry_run: bool,
334
+ result: Optional[HousekeepingRuleResult] = None,
335
+ ) -> int:
314
336
  if max_bytes <= 0:
315
337
  return 0
316
338
  try:
317
339
  size = path.stat().st_size
318
- except OSError:
340
+ except OSError as e:
341
+ if result is not None:
342
+ result.errors += 1
343
+ _add_error_sample(result, "truncate_bytes", path, e)
319
344
  return 0
320
345
  if size <= max_bytes:
321
346
  return 0
@@ -328,23 +353,38 @@ def _truncate_bytes(path: Path, max_bytes: int, *, dry_run: bool) -> int:
328
353
  payload = handle.read()
329
354
  _atomic_write_bytes(path, payload)
330
355
  return truncated
331
- except OSError:
356
+ except OSError as e:
357
+ if result is not None:
358
+ result.errors += 1
359
+ _add_error_sample(result, "truncate_bytes", path, e)
332
360
  return 0
333
361
 
334
362
 
335
- def _truncate_lines(path: Path, max_lines: int, *, dry_run: bool) -> int:
363
+ def _truncate_lines(
364
+ path: Path,
365
+ max_lines: int,
366
+ *,
367
+ dry_run: bool,
368
+ result: Optional[HousekeepingRuleResult] = None,
369
+ ) -> int:
336
370
  if max_lines <= 0:
337
371
  return 0
338
372
  try:
339
373
  size = path.stat().st_size
340
- except OSError:
374
+ except OSError as e:
375
+ if result is not None:
376
+ result.errors += 1
377
+ _add_error_sample(result, "truncate_lines", path, e)
341
378
  return 0
342
379
  lines: deque[bytes] = deque(maxlen=max_lines)
343
380
  try:
344
381
  with path.open("rb") as handle:
345
382
  for line in handle:
346
383
  lines.append(line)
347
- except OSError:
384
+ except OSError as e:
385
+ if result is not None:
386
+ result.errors += 1
387
+ _add_error_sample(result, "truncate_lines", path, e)
348
388
  return 0
349
389
  payload = b"".join(lines)
350
390
  if len(payload) >= size:
@@ -353,7 +393,10 @@ def _truncate_lines(path: Path, max_lines: int, *, dry_run: bool) -> int:
353
393
  return size - len(payload)
354
394
  try:
355
395
  _atomic_write_bytes(path, payload)
356
- except OSError:
396
+ except OSError as e:
397
+ if result is not None:
398
+ result.errors += 1
399
+ _add_error_sample(result, "truncate_lines", path, e)
357
400
  return 0
358
401
  return size - len(payload)
359
402
 
@@ -379,6 +422,17 @@ def _prune_empty_dirs(base: Path) -> None:
379
422
  continue
380
423
 
381
424
 
425
+ def _add_error_sample(
426
+ result: HousekeepingRuleResult, operation: str, path: Path, exc: OSError
427
+ ) -> None:
428
+ if len(result.error_samples) >= _MAX_ERROR_SAMPLES:
429
+ return
430
+ exc_info = f"{type(exc).__name__}"
431
+ if exc.strerror:
432
+ exc_info += f": {exc.strerror}"
433
+ result.error_samples.append(f"{operation} {path}: {exc_info}")
434
+
435
+
382
436
  def _int_or_none(value: object) -> Optional[int]:
383
437
  if value is None:
384
438
  return None
@@ -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