codexapi 0.4.0__tar.gz → 0.5.1__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: codexapi
3
- Version: 0.4.0
3
+ Version: 0.5.1
4
4
  Summary: Minimal Python API for running the Codex CLI.
5
5
  License: MIT
6
6
  Keywords: codex,agent,cli,openai
@@ -9,6 +9,8 @@ Classifier: Operating System :: OS Independent
9
9
  Requires-Python: >=3.8
10
10
  Description-Content-Type: text/markdown
11
11
  License-File: LICENSE
12
+ Requires-Dist: PyYAML>=6.0
13
+ Requires-Dist: tqdm>=4.64
12
14
 
13
15
  # CodexAPI
14
16
 
@@ -70,6 +72,7 @@ echo "Say hello." | codexapi run
70
72
 
71
73
  ```bash
72
74
  codexapi task "Fix the failing tests." --max-iterations 5
75
+ codexapi task -f task.yaml
73
76
  ```
74
77
 
75
78
  Show running sessions and their latest activity:
@@ -90,13 +93,22 @@ Use `--no-yolo` to run Codex with `--full-auto` instead.
90
93
  Ralph loop mode repeats the same prompt until a completion promise or a max
91
94
  iteration cap is hit (0 means unlimited). Cancel by deleting
92
95
  `.codexapi/ralph-loop.local.md` or running `codexapi ralph --cancel`.
96
+ By default each iteration starts with a fresh Agent context; use
97
+ `--ralph-reuse` to keep a single shared context across iterations.
93
98
 
94
99
  ```bash
95
100
  codexapi ralph "Fix the bug." --completion-promise DONE --max-iterations 5
96
- codexapi ralph --ralph-fresh "Try again from scratch." --max-iterations 3
101
+ codexapi ralph --ralph-reuse "Try again from the same context." --max-iterations 3
97
102
  codexapi ralph --cancel --cwd /path/to/project
98
103
  ```
99
104
 
105
+ Run a task file across a list file:
106
+
107
+ ```bash
108
+ codexapi foreach list.txt task.yaml
109
+ codexapi foreach list.txt task.yaml -n 4
110
+ ```
111
+
100
112
  ## API
101
113
 
102
114
  ### `agent(prompt, cwd=None, yolo=True, flags=None) -> str`
@@ -141,7 +153,7 @@ Runs a Codex task with checker-driven retries. Subclass it and implement
141
153
  - `__call__() -> TaskResult`: run the task.
142
154
  - `set_up()`: optional setup hook.
143
155
  - `tear_down()`: optional cleanup hook.
144
- - `check() -> str | None`: return an error description or `None`/`""`.
156
+ - `check(output=None) -> str | None`: return an error description or `None`/`""`. `output` is the last agent response.
145
157
  - `on_success(result)`: optional success hook.
146
158
  - `on_failure(result)`: optional failure hook.
147
159
 
@@ -163,6 +175,26 @@ Exception raised by `task()` when retries are exhausted.
163
175
  - `attempts` (int | None): attempts made when the task failed.
164
176
  - `errors` (str | None): last checker error, if any.
165
177
 
178
+ ### `foreach(list_file, task_file, n=None, cwd=None, yolo=True, flags=None) -> ForeachResult`
179
+
180
+ Runs a task file over a list of items, updating the list file in place.
181
+
182
+ - `list_file` (str | PathLike): path to the list file to process.
183
+ - `task_file` (str | PathLike): YAML task file (must include `prompt`).
184
+ - `n` (int | None): limit parallelism to N (default: run all items in parallel).
185
+ - `cwd` (str | PathLike | None): working directory for the Codex session.
186
+ - `yolo` (bool): pass `--yolo` to Codex when true (defaults to true).
187
+ - `flags` (str | None): extra CLI flags to pass to Codex.
188
+
189
+ ### `ForeachResult(succeeded, failed, skipped, results)`
190
+
191
+ Simple result object returned by `foreach()`.
192
+
193
+ - `succeeded` (int): number of successful items.
194
+ - `failed` (int): number of failed items.
195
+ - `skipped` (int): number of items skipped (already marked in the list file).
196
+ - `results` (list[tuple]): `(item, success, summary)` entries for items that ran.
197
+
166
198
  ## Behavior notes
167
199
 
168
200
  - Uses `codex exec --json` and parses JSONL events for `agent_message` items.
@@ -58,6 +58,7 @@ echo "Say hello." | codexapi run
58
58
 
59
59
  ```bash
60
60
  codexapi task "Fix the failing tests." --max-iterations 5
61
+ codexapi task -f task.yaml
61
62
  ```
62
63
 
63
64
  Show running sessions and their latest activity:
@@ -78,13 +79,22 @@ Use `--no-yolo` to run Codex with `--full-auto` instead.
78
79
  Ralph loop mode repeats the same prompt until a completion promise or a max
79
80
  iteration cap is hit (0 means unlimited). Cancel by deleting
80
81
  `.codexapi/ralph-loop.local.md` or running `codexapi ralph --cancel`.
82
+ By default each iteration starts with a fresh Agent context; use
83
+ `--ralph-reuse` to keep a single shared context across iterations.
81
84
 
82
85
  ```bash
83
86
  codexapi ralph "Fix the bug." --completion-promise DONE --max-iterations 5
84
- codexapi ralph --ralph-fresh "Try again from scratch." --max-iterations 3
87
+ codexapi ralph --ralph-reuse "Try again from the same context." --max-iterations 3
85
88
  codexapi ralph --cancel --cwd /path/to/project
86
89
  ```
87
90
 
91
+ Run a task file across a list file:
92
+
93
+ ```bash
94
+ codexapi foreach list.txt task.yaml
95
+ codexapi foreach list.txt task.yaml -n 4
96
+ ```
97
+
88
98
  ## API
89
99
 
90
100
  ### `agent(prompt, cwd=None, yolo=True, flags=None) -> str`
@@ -129,7 +139,7 @@ Runs a Codex task with checker-driven retries. Subclass it and implement
129
139
  - `__call__() -> TaskResult`: run the task.
130
140
  - `set_up()`: optional setup hook.
131
141
  - `tear_down()`: optional cleanup hook.
132
- - `check() -> str | None`: return an error description or `None`/`""`.
142
+ - `check(output=None) -> str | None`: return an error description or `None`/`""`. `output` is the last agent response.
133
143
  - `on_success(result)`: optional success hook.
134
144
  - `on_failure(result)`: optional failure hook.
135
145
 
@@ -151,6 +161,26 @@ Exception raised by `task()` when retries are exhausted.
151
161
  - `attempts` (int | None): attempts made when the task failed.
152
162
  - `errors` (str | None): last checker error, if any.
153
163
 
164
+ ### `foreach(list_file, task_file, n=None, cwd=None, yolo=True, flags=None) -> ForeachResult`
165
+
166
+ Runs a task file over a list of items, updating the list file in place.
167
+
168
+ - `list_file` (str | PathLike): path to the list file to process.
169
+ - `task_file` (str | PathLike): YAML task file (must include `prompt`).
170
+ - `n` (int | None): limit parallelism to N (default: run all items in parallel).
171
+ - `cwd` (str | PathLike | None): working directory for the Codex session.
172
+ - `yolo` (bool): pass `--yolo` to Codex when true (defaults to true).
173
+ - `flags` (str | None): extra CLI flags to pass to Codex.
174
+
175
+ ### `ForeachResult(succeeded, failed, skipped, results)`
176
+
177
+ Simple result object returned by `foreach()`.
178
+
179
+ - `succeeded` (int): number of successful items.
180
+ - `failed` (int): number of failed items.
181
+ - `skipped` (int): number of items skipped (already marked in the list file).
182
+ - `results` (list[tuple]): `(item, success, summary)` entries for items that ran.
183
+
154
184
  ## Behavior notes
155
185
 
156
186
  - Uses `codex exec --json` and parses JSONL events for `agent_message` items.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "codexapi"
7
- version = "0.4.0"
7
+ version = "0.5.1"
8
8
  description = "Minimal Python API for running the Codex CLI."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -15,7 +15,10 @@ classifiers = [
15
15
  "Operating System :: OS Independent",
16
16
  ]
17
17
 
18
- dependencies = []
18
+ dependencies = [
19
+ "PyYAML>=6.0",
20
+ "tqdm>=4.64",
21
+ ]
19
22
 
20
23
  [project.scripts]
21
24
  codexapi = "codexapi.cli:main"
@@ -1,15 +1,18 @@
1
1
  """Minimal Python API for running the Codex CLI."""
2
2
 
3
3
  from .agent import Agent, agent
4
+ from .foreach import ForeachResult, foreach
4
5
  from .task import Task, TaskFailed, TaskResult, task, task_result
5
6
 
6
7
  __all__ = [
7
8
  "Agent",
9
+ "ForeachResult",
8
10
  "Task",
9
11
  "TaskFailed",
10
12
  "TaskResult",
11
13
  "agent",
14
+ "foreach",
12
15
  "task",
13
16
  "task_result",
14
17
  ]
15
- __version__ = "0.4.0"
18
+ __version__ = "0.5.1"
@@ -12,8 +12,10 @@ from datetime import datetime
12
12
  from pathlib import Path
13
13
 
14
14
  from .agent import Agent, agent
15
+ from .foreach import foreach
15
16
  from .ralph import cancel_ralph_loop, run_ralph_loop
16
17
  from .task import TaskFailed, task
18
+ from .taskfile import AutoTask, load_task_file
17
19
 
18
20
  _SESSION_ID_RE = re.compile(
19
21
  r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
@@ -38,6 +40,7 @@ _COLUMN_TITLES = {
38
40
  "in": "IN",
39
41
  "out": "OUT",
40
42
  "turn": "TURN",
43
+ "turns": "NTRN",
41
44
  "model": "MODEL",
42
45
  "effort": "EFF",
43
46
  "perm": "PERM",
@@ -121,6 +124,27 @@ def _tail_lines(path):
121
124
  return text.splitlines()
122
125
 
123
126
 
127
+ def _count_turns(path):
128
+ event_count = 0
129
+ response_count = 0
130
+ try:
131
+ with open(path, "r", encoding="utf-8", errors="replace") as handle:
132
+ for line in handle:
133
+ if "\"type\":\"event_msg\"" in line and "\"type\":\"user_message\"" in line:
134
+ event_count += 1
135
+ continue
136
+ if "\"type\":\"response_item\"" in line and "\"role\":\"user\"" in line and "\"type\":\"message\"" in line:
137
+ response_count += 1
138
+ except OSError:
139
+ return None
140
+
141
+ if event_count:
142
+ return event_count
143
+ if response_count:
144
+ return response_count
145
+ return None
146
+
147
+
124
148
  def _extract_text(content):
125
149
  if isinstance(content, str):
126
150
  return content
@@ -364,6 +388,7 @@ def _summarize_session(path, mtime):
364
388
  total_usage = None
365
389
  meta = {}
366
390
  subagent = None
391
+ turns = _count_turns(path)
367
392
 
368
393
  for line in _tail_lines(path):
369
394
  try:
@@ -485,6 +510,7 @@ def _summarize_session(path, mtime):
485
510
  "last_user_ts": last_user_ts,
486
511
  "last_agent_ts": last_agent_ts,
487
512
  "last_event_kind": last_event_kind,
513
+ "turns": turns,
488
514
  "meta": meta,
489
515
  }
490
516
 
@@ -604,6 +630,7 @@ def _layout_columns(width, id_width, show):
604
630
  ("in", ">"),
605
631
  ("out", ">"),
606
632
  ("turn", ">"),
633
+ ("turns", ">"),
607
634
  ]
608
635
  widths = {
609
636
  "id": id_width,
@@ -612,6 +639,7 @@ def _layout_columns(width, id_width, show):
612
639
  "in": 7,
613
640
  "out": 7,
614
641
  "turn": 7,
642
+ "turns": 5,
615
643
  }
616
644
  mins = {}
617
645
 
@@ -684,6 +712,8 @@ def _format_session(session, layout):
684
712
  else:
685
713
  turn_seconds = None
686
714
  turn_str = _format_duration(turn_seconds)
715
+ turns = session.get("turns")
716
+ turns_str = "-" if turns is None else str(turns)
687
717
  meta = session.get("meta") or {}
688
718
  model = meta.get("model") or meta.get("model_provider") or "-"
689
719
  effort = meta.get("effort") or "-"
@@ -702,6 +732,7 @@ def _format_session(session, layout):
702
732
  "in": total_in,
703
733
  "out": total_out,
704
734
  "turn": turn_str,
735
+ "turns": _truncate_head(str(turns_str), widths.get("turns", 0)),
705
736
  "model": _truncate_head(str(model), widths.get("model", 0)),
706
737
  "effort": _truncate_head(str(effort), widths.get("effort", 0)),
707
738
  "perm": _truncate_head(str(perm), widths.get("perm", 0)),
@@ -889,8 +920,8 @@ def main(argv=None):
889
920
  " --completion-promise after trimming/collapsing whitespace. CRITICAL RULE:\n"
890
921
  " Only output the promise when it is completely and unequivocally TRUE.\n"
891
922
  " Cancel by deleting .codexapi/ralph-loop.local.md or running codexapi ralph --cancel.\n"
892
- " Default reuses a single Codex thread; use --ralph-fresh for a new Agent\n"
893
- " each iteration (no shared context).\n"
923
+ " Default starts each iteration with a fresh Agent context; use --ralph-reuse\n"
924
+ " to reuse a single Codex thread across iterations.\n"
894
925
  )
895
926
  parser = argparse.ArgumentParser(
896
927
  prog="codexapi",
@@ -932,6 +963,11 @@ def main(argv=None):
932
963
  "task",
933
964
  help="Run a task with verification retries.",
934
965
  )
966
+ task_parser.add_argument(
967
+ "-f",
968
+ "--task-file",
969
+ help="YAML task file to run.",
970
+ )
935
971
  task_parser.add_argument(
936
972
  "prompt",
937
973
  nargs="?",
@@ -944,8 +980,8 @@ def main(argv=None):
944
980
  task_parser.add_argument(
945
981
  "--max-iterations",
946
982
  type=int,
947
- default=10,
948
- help="Max verification retries after a failed check (0 means no retries).",
983
+ default=None,
984
+ help="Max verification retries after a failed check (0 means no retries). Defaults to 10.",
949
985
  )
950
986
  task_parser.add_argument("--cwd", help="Working directory for the Codex session.")
951
987
  task_parser.add_argument(
@@ -990,10 +1026,20 @@ def main(argv=None):
990
1026
  "--completion-promise",
991
1027
  help="Promise text to match in <promise>...</promise>.",
992
1028
  )
993
- ralph_parser.add_argument(
1029
+ ralph_fresh_group = ralph_parser.add_mutually_exclusive_group()
1030
+ ralph_fresh_group.add_argument(
994
1031
  "--ralph-fresh",
995
1032
  action="store_true",
996
- help="Start each iteration with a fresh Agent context.",
1033
+ dest="ralph_fresh",
1034
+ default=None,
1035
+ help="Start each iteration with a fresh Agent context (default).",
1036
+ )
1037
+ ralph_fresh_group.add_argument(
1038
+ "--ralph-reuse",
1039
+ action="store_false",
1040
+ dest="ralph_fresh",
1041
+ default=None,
1042
+ help="Reuse the same Agent context each iteration.",
997
1043
  )
998
1044
  ralph_parser.add_argument("--cwd", help="Working directory for the Codex session.")
999
1045
  ralph_parser.add_argument(
@@ -1007,6 +1053,35 @@ def main(argv=None):
1007
1053
  help="Additional raw CLI flags to pass to Codex (quoted as needed).",
1008
1054
  )
1009
1055
 
1056
+ foreach_parser = subparsers.add_parser(
1057
+ "foreach",
1058
+ help="Run a task file over a list file.",
1059
+ )
1060
+ foreach_parser.add_argument(
1061
+ "list_file",
1062
+ help="Path to the list file to process.",
1063
+ )
1064
+ foreach_parser.add_argument(
1065
+ "task_file",
1066
+ help="Path to the YAML task file.",
1067
+ )
1068
+ foreach_parser.add_argument(
1069
+ "-n",
1070
+ type=int,
1071
+ help="Limit parallelism to N.",
1072
+ )
1073
+ foreach_parser.add_argument("--cwd", help="Working directory for the Codex session.")
1074
+ foreach_parser.add_argument(
1075
+ "--no-yolo",
1076
+ action="store_false",
1077
+ dest="yolo",
1078
+ help="Disable --yolo and use --full-auto.",
1079
+ )
1080
+ foreach_parser.add_argument(
1081
+ "--flags",
1082
+ help="Additional raw CLI flags to pass to Codex (quoted as needed).",
1083
+ )
1084
+
1010
1085
  subparsers.add_parser(
1011
1086
  "top",
1012
1087
  help="Show running Codex sessions.",
@@ -1020,16 +1095,58 @@ def main(argv=None):
1020
1095
  _run_top([])
1021
1096
  return
1022
1097
 
1098
+ if args.command == "foreach":
1099
+ if args.n is not None and args.n < 1:
1100
+ raise SystemExit("-n must be >= 1.")
1101
+ result = foreach(
1102
+ args.list_file,
1103
+ args.task_file,
1104
+ args.n,
1105
+ args.cwd,
1106
+ args.yolo,
1107
+ args.flags,
1108
+ )
1109
+ if result.failed:
1110
+ raise SystemExit(1)
1111
+ return
1112
+
1023
1113
  if args.command == "ralph":
1024
1114
  if args.cancel:
1025
1115
  if args.prompt:
1026
1116
  raise SystemExit("ralph --cancel takes no prompt.")
1027
- if args.completion_promise or args.ralph_fresh:
1028
- raise SystemExit("--completion-promise/--ralph-fresh are not allowed with --cancel.")
1117
+ if args.completion_promise or args.ralph_fresh is not None:
1118
+ raise SystemExit(
1119
+ "--completion-promise/--ralph-fresh/--ralph-reuse are not allowed with --cancel."
1120
+ )
1029
1121
  if args.max_iterations != 0:
1030
1122
  raise SystemExit("--max-iterations is not allowed with --cancel.")
1031
1123
  print(cancel_ralph_loop(args.cwd))
1032
1124
  return
1125
+ if args.ralph_fresh is None:
1126
+ args.ralph_fresh = True
1127
+
1128
+ if args.command == "task" and args.task_file:
1129
+ if args.prompt:
1130
+ raise SystemExit("task -f does not take a prompt.")
1131
+ if args.check is not None:
1132
+ raise SystemExit("--check is not allowed with -f.")
1133
+ if args.max_iterations is not None:
1134
+ raise SystemExit("--max-iterations is not allowed with -f.")
1135
+ task_def = load_task_file(args.task_file)
1136
+ task_runner = AutoTask(
1137
+ task_def,
1138
+ None,
1139
+ 10,
1140
+ args.cwd,
1141
+ args.yolo,
1142
+ None,
1143
+ args.flags,
1144
+ )
1145
+ result = task_runner()
1146
+ print(result.summary)
1147
+ if not result.success:
1148
+ raise SystemExit(1)
1149
+ return
1033
1150
 
1034
1151
  prompt = _read_prompt(args.prompt)
1035
1152
  exit_code = 0
@@ -1048,6 +1165,8 @@ def main(argv=None):
1048
1165
  )
1049
1166
  return
1050
1167
  if args.command == "task":
1168
+ if args.max_iterations is None:
1169
+ args.max_iterations = 10
1051
1170
  if args.max_iterations < 0:
1052
1171
  raise SystemExit("--max-iterations must be >= 0.")
1053
1172
  check = args.check if args.check is not None else prompt
@@ -0,0 +1,230 @@
1
+ """Run a task file over a list of items with resumable progress."""
2
+
3
+ import sys
4
+ import threading
5
+ from concurrent.futures import ThreadPoolExecutor, as_completed
6
+
7
+ from tqdm import tqdm
8
+
9
+ from .taskfile import AutoTask, load_task_file
10
+
11
+ _STATUS_RUNNING = "⏳"
12
+ _STATUS_SUCCESS = "✅"
13
+ _STATUS_FAILED = "❌"
14
+ _STATUS_SET = {_STATUS_RUNNING, _STATUS_SUCCESS, _STATUS_FAILED}
15
+
16
+
17
+ class ForeachResult:
18
+ """Outcome summary for a foreach run."""
19
+
20
+ def __init__(self, succeeded, failed, skipped, results):
21
+ self.succeeded = succeeded
22
+ self.failed = failed
23
+ self.skipped = skipped
24
+ self.results = results
25
+
26
+ def __repr__(self):
27
+ return (
28
+ "ForeachResult("
29
+ f"succeeded={self.succeeded}, "
30
+ f"failed={self.failed}, "
31
+ f"skipped={self.skipped}, "
32
+ f"results={self.results!r}"
33
+ ")"
34
+ )
35
+
36
+
37
+ def foreach(
38
+ list_file,
39
+ task_file,
40
+ n=None,
41
+ cwd=None,
42
+ yolo=True,
43
+ flags=None,
44
+ ):
45
+ """Run a task file over each item in list_file and update the file."""
46
+ task_def = load_task_file(task_file)
47
+ lines, ends_with_newline = _read_lines(list_file)
48
+ items, skipped = _collect_items(lines)
49
+
50
+ if not items:
51
+ return ForeachResult(0, 0, skipped, [])
52
+
53
+ max_workers = _max_workers(n, len(items))
54
+ lock = threading.Lock()
55
+ results = []
56
+ counts = {
57
+ "running": 0,
58
+ "success": 0,
59
+ "failed": 0,
60
+ }
61
+
62
+ progress = tqdm(total=len(items))
63
+ try:
64
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
65
+ futures = []
66
+ for index, item in items:
67
+ futures.append(
68
+ executor.submit(
69
+ _run_item,
70
+ index,
71
+ item,
72
+ task_def,
73
+ lines,
74
+ ends_with_newline,
75
+ list_file,
76
+ cwd,
77
+ yolo,
78
+ flags,
79
+ counts,
80
+ results,
81
+ progress,
82
+ lock,
83
+ )
84
+ )
85
+ for future in as_completed(futures):
86
+ future.result()
87
+ finally:
88
+ progress.close()
89
+
90
+ return ForeachResult(
91
+ counts["success"],
92
+ counts["failed"],
93
+ skipped,
94
+ results,
95
+ )
96
+
97
+
98
+ def _max_workers(n, total):
99
+ if n is None:
100
+ return total
101
+ if n < 1:
102
+ raise ValueError("n must be >= 1")
103
+ if n > total:
104
+ return total
105
+ return n
106
+
107
+
108
+ def _read_lines(path):
109
+ with open(path, "r", encoding="utf-8") as handle:
110
+ data = handle.read()
111
+ ends_with_newline = data.endswith("\n")
112
+ return data.splitlines(), ends_with_newline
113
+
114
+
115
+ def _write_lines(path, lines, ends_with_newline):
116
+ text = "\n".join(lines)
117
+ if ends_with_newline:
118
+ text += "\n"
119
+ with open(path, "w", encoding="utf-8") as handle:
120
+ handle.write(text)
121
+
122
+
123
+ def _collect_items(lines):
124
+ items = []
125
+ skipped = 0
126
+ for index, line in enumerate(lines):
127
+ if not line.strip():
128
+ continue
129
+ if _status_marker(line):
130
+ skipped += 1
131
+ continue
132
+ items.append((index, line))
133
+ return items, skipped
134
+
135
+
136
+ def _status_marker(line):
137
+ if not line:
138
+ return None
139
+ marker = line[0]
140
+ if marker in _STATUS_SET:
141
+ return marker
142
+ return None
143
+
144
+
145
+ def _status_text(counts):
146
+ return (
147
+ f"{_STATUS_RUNNING}: {counts['running']}, "
148
+ f"{_STATUS_SUCCESS}: {counts['success']}, "
149
+ f"{_STATUS_FAILED}: {counts['failed']}"
150
+ )
151
+
152
+
153
+ def _single_line(text):
154
+ if not text:
155
+ return ""
156
+ return text.replace("\r", " ").replace("\n", " ")
157
+
158
+
159
+ def _format_turns(used, total):
160
+ used_text = "?" if used is None else str(used)
161
+ total_text = "?" if total is None else str(total)
162
+ return f"[turns: {used_text}/{total_text}]"
163
+
164
+
165
+ def _run_item(
166
+ index,
167
+ item,
168
+ task_def,
169
+ lines,
170
+ ends_with_newline,
171
+ list_file,
172
+ cwd,
173
+ yolo,
174
+ flags,
175
+ counts,
176
+ results,
177
+ progress,
178
+ lock,
179
+ ):
180
+ running_line = f"{_STATUS_RUNNING} {item}"
181
+ with lock:
182
+ lines[index] = running_line
183
+ _write_lines(list_file, lines, ends_with_newline)
184
+ counts["running"] += 1
185
+ progress.set_postfix_str(_status_text(counts))
186
+
187
+ summary = ""
188
+ success = False
189
+ attempts = None
190
+ max_attempts = None
191
+ try:
192
+ task = AutoTask(
193
+ task_def,
194
+ item,
195
+ 10,
196
+ cwd,
197
+ yolo,
198
+ None,
199
+ flags,
200
+ )
201
+ max_attempts = task.max_attempts
202
+ result = task()
203
+ success = result.success
204
+ attempts = result.attempts
205
+ summary = result.summary or ""
206
+ except Exception as exc:
207
+ summary = f"{type(exc).__name__}: {exc}"
208
+ success = False
209
+
210
+ summary = _single_line(summary)
211
+ turns = _format_turns(attempts, max_attempts)
212
+ if summary:
213
+ summary = f"{summary} {turns}"
214
+ else:
215
+ summary = turns
216
+ status = _STATUS_SUCCESS if success else _STATUS_FAILED
217
+ final_line = f"{status} {item} | {summary}"
218
+
219
+ with lock:
220
+ lines[index] = final_line
221
+ _write_lines(list_file, lines, ends_with_newline)
222
+ counts["running"] -= 1
223
+ if success:
224
+ counts["success"] += 1
225
+ else:
226
+ counts["failed"] += 1
227
+ results.append((item, success, summary))
228
+ progress.update(1)
229
+ progress.set_postfix_str(_status_text(counts))
230
+ tqdm.write(final_line, file=sys.stdout)
@@ -19,7 +19,7 @@ def run_ralph_loop(
19
19
  flags=None,
20
20
  max_iterations=0,
21
21
  completion_promise=None,
22
- fresh=False,
22
+ fresh=True,
23
23
  ):
24
24
  """Run a Ralph Wiggum-style loop that repeats the same prompt.
25
25
 
@@ -37,8 +37,8 @@ def run_ralph_loop(
37
37
  may ONLY output it when the statement is completely and unequivocally TRUE.
38
38
  Do not output false promises to escape the loop.
39
39
 
40
- By default a single Agent instance is reused for shared context. Set
41
- `fresh=True` to create a new Agent each iteration for a clean context.
40
+ By default each iteration uses a fresh Agent for a clean context. Set
41
+ `fresh=False` to reuse a single Agent instance for shared context.
42
42
  Cancel by deleting the state file or running `codexapi ralph --cancel`.
43
43
  """
44
44
  if not isinstance(prompt, str) or not prompt.strip():
@@ -10,8 +10,9 @@ _logger = logging.getLogger(__name__)
10
10
 
11
11
  _CHECK_PREFIX = (
12
12
  "You are a verification agent. Explore this workspace and carefully evaluate it "
13
- "against the check below. Collect evidence by running any tests and/or reading "
13
+ "against the task below. Collect evidence by running any tests and/or reading "
14
14
  "and tracing through code, but do not change any of the code.\n"
15
+ "Act as a collaborator who wants to give the task owner all the information they need to succeed.\n"
15
16
  "Return only JSON with keys: success (boolean) and reason (string).\n"
16
17
  "Set success to true only if everything matches the intent."
17
18
  )
@@ -141,9 +142,11 @@ def _print_progress(
141
142
 
142
143
  def _fix_prompt(error):
143
144
  return (
144
- "The verification check failed:\n"
145
+ "Thanks for your work. An automated verifier reported these issues:\n"
145
146
  f"{error}\n\n"
146
- "Please fix the issues while staying close to the original intent."
147
+ "Take another look and see whether you agree and, if so, please take this "
148
+ "feedback into consideration and use it to continue to make progress "
149
+ "towards our original goal and intent."
147
150
  )
148
151
 
149
152
 
@@ -328,6 +331,7 @@ class Task:
328
331
  self.prompt = prompt
329
332
  self.max_attempts = max_attempts
330
333
  self.cwd = cwd
334
+ self.last_output = None
331
335
  self.agent = Agent(
332
336
  cwd,
333
337
  yolo,
@@ -341,8 +345,9 @@ class Task:
341
345
  def tear_down(self):
342
346
  """Delete the directory etc."""
343
347
 
344
- def check(self):
348
+ def check(self, output=None):
345
349
  """ Check if the task is done, return a string describing the problems if not.
350
+ The output argument is the last agent response.
346
351
  This can be any combination of running tests, python code or running an agent
347
352
  with a specific prompt in self.cwd.
348
353
  """
@@ -356,9 +361,11 @@ class Task:
356
361
  def fix_prompt(self, error):
357
362
  """Build a prompt that asks the agent to fix checker failures."""
358
363
  return (
359
- "The following checks failed:\n"
364
+ "Thanks for your work. An automated verifier reported these issues:\n"
360
365
  f"{error}\n\n"
361
- "Can you please dive in and see if you agree with this assessment, then fix these issues while staying as close as you can to the spirit of the original task?"
366
+ "Take another look and see whether you agree and, if so, please take "
367
+ "this feedback into consideration and use it to continue to make "
368
+ "progress towards our original goal and intent."
362
369
  )
363
370
 
364
371
  def success_prompt(self):
@@ -382,18 +389,20 @@ class Task:
382
389
 
383
390
  # Start with the initial prompt
384
391
  output = self.agent(self.prompt)
392
+ self.last_output = output
385
393
  if debug:
386
394
  _logger.debug("Initial output: %s", output)
387
395
 
388
396
  # Try correcting it up to max_attempts times
389
397
  for attempt in range(self.max_attempts):
390
- error = self.check()
398
+ error = self.check(self.last_output)
391
399
  if debug:
392
400
  _logger.debug("Check error: %s", error)
393
401
 
394
402
  if error:
395
403
  # if there were errors, tell the agent to fix them
396
404
  output = self.agent(self.fix_prompt(error))
405
+ self.last_output = output
397
406
  if debug:
398
407
  _logger.debug("Fix output: %s", output)
399
408
  else:
@@ -0,0 +1,108 @@
1
+ """Load YAML task files and map them onto Task hooks."""
2
+
3
+ import yaml
4
+
5
+ from .agent import agent
6
+ from .task import Task
7
+
8
+ _ITEM_TOKEN = "{{item}}"
9
+
10
+
11
+ def load_task_file(path):
12
+ """Load a YAML task file and return a normalized task definition."""
13
+ if not path:
14
+ raise ValueError("task file path is required")
15
+ with open(path, "r", encoding="utf-8") as handle:
16
+ data = yaml.safe_load(handle) or {}
17
+ if not isinstance(data, dict):
18
+ raise ValueError("Task file must be a YAML mapping.")
19
+
20
+ prompt = data.get("prompt")
21
+ if not isinstance(prompt, str) or not prompt.strip():
22
+ raise ValueError("Task file missing non-empty 'prompt'.")
23
+
24
+ return {
25
+ "prompt": prompt,
26
+ "set_up": _optional_str(data.get("set_up")),
27
+ "tear_down": _optional_str(data.get("tear_down")),
28
+ "check": _optional_str(data.get("check")),
29
+ "on_success": _optional_str(data.get("on_success")),
30
+ "on_failure": _optional_str(data.get("on_failure")),
31
+ }
32
+
33
+
34
+ def _optional_str(value):
35
+ if value is None:
36
+ return None
37
+ if isinstance(value, str):
38
+ return value if value.strip() else None
39
+ raise ValueError("Task file values must be strings.")
40
+
41
+
42
+ def _render(text, item):
43
+ if text is None:
44
+ return None
45
+ if item is None:
46
+ return text
47
+ return text.replace(_ITEM_TOKEN, item)
48
+
49
+
50
+ class AutoTask(Task):
51
+ """Task subclass that maps YAML strings onto Task hooks."""
52
+
53
+ def __init__(
54
+ self,
55
+ config,
56
+ item=None,
57
+ max_attempts=10,
58
+ cwd=None,
59
+ yolo=True,
60
+ thread_id=None,
61
+ flags=None,
62
+ ):
63
+ if not isinstance(config, dict):
64
+ raise TypeError("config must be a task definition dict")
65
+ self._config = config
66
+ self._item = "" if item is None else str(item)
67
+ self._yolo = yolo
68
+ self._flags = flags
69
+ prompt = _render(config.get("prompt"), self._item)
70
+ super().__init__(prompt, max_attempts, cwd, yolo, thread_id, flags)
71
+
72
+ def _hook(self, name):
73
+ return _render(self._config.get(name), self._item)
74
+
75
+ def set_up(self):
76
+ text = self._hook("set_up")
77
+ if text:
78
+ agent(text, self.cwd, self._yolo, self._flags)
79
+
80
+ def tear_down(self):
81
+ text = self._hook("tear_down")
82
+ if text:
83
+ agent(text, self.cwd, self._yolo, self._flags)
84
+
85
+ def check(self, output=None):
86
+ text = self._hook("check")
87
+ if not text:
88
+ return None
89
+ last_output = output if output is not None else self.last_output
90
+ last_output = last_output or ""
91
+ if last_output:
92
+ prompt = f"{text}\n\nAGENT OUTPUT:\n{last_output}"
93
+ else:
94
+ prompt = text
95
+ result = agent(prompt, self.cwd, self._yolo, self._flags)
96
+ if not isinstance(result, str) or not result.strip():
97
+ return None
98
+ return result
99
+
100
+ def on_success(self, result):
101
+ text = self._hook("on_success")
102
+ if text:
103
+ agent(text, self.cwd, self._yolo, self._flags)
104
+
105
+ def on_failure(self, result):
106
+ text = self._hook("on_failure")
107
+ if text:
108
+ agent(text, self.cwd, self._yolo, self._flags)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: codexapi
3
- Version: 0.4.0
3
+ Version: 0.5.1
4
4
  Summary: Minimal Python API for running the Codex CLI.
5
5
  License: MIT
6
6
  Keywords: codex,agent,cli,openai
@@ -9,6 +9,8 @@ Classifier: Operating System :: OS Independent
9
9
  Requires-Python: >=3.8
10
10
  Description-Content-Type: text/markdown
11
11
  License-File: LICENSE
12
+ Requires-Dist: PyYAML>=6.0
13
+ Requires-Dist: tqdm>=4.64
12
14
 
13
15
  # CodexAPI
14
16
 
@@ -70,6 +72,7 @@ echo "Say hello." | codexapi run
70
72
 
71
73
  ```bash
72
74
  codexapi task "Fix the failing tests." --max-iterations 5
75
+ codexapi task -f task.yaml
73
76
  ```
74
77
 
75
78
  Show running sessions and their latest activity:
@@ -90,13 +93,22 @@ Use `--no-yolo` to run Codex with `--full-auto` instead.
90
93
  Ralph loop mode repeats the same prompt until a completion promise or a max
91
94
  iteration cap is hit (0 means unlimited). Cancel by deleting
92
95
  `.codexapi/ralph-loop.local.md` or running `codexapi ralph --cancel`.
96
+ By default each iteration starts with a fresh Agent context; use
97
+ `--ralph-reuse` to keep a single shared context across iterations.
93
98
 
94
99
  ```bash
95
100
  codexapi ralph "Fix the bug." --completion-promise DONE --max-iterations 5
96
- codexapi ralph --ralph-fresh "Try again from scratch." --max-iterations 3
101
+ codexapi ralph --ralph-reuse "Try again from the same context." --max-iterations 3
97
102
  codexapi ralph --cancel --cwd /path/to/project
98
103
  ```
99
104
 
105
+ Run a task file across a list file:
106
+
107
+ ```bash
108
+ codexapi foreach list.txt task.yaml
109
+ codexapi foreach list.txt task.yaml -n 4
110
+ ```
111
+
100
112
  ## API
101
113
 
102
114
  ### `agent(prompt, cwd=None, yolo=True, flags=None) -> str`
@@ -141,7 +153,7 @@ Runs a Codex task with checker-driven retries. Subclass it and implement
141
153
  - `__call__() -> TaskResult`: run the task.
142
154
  - `set_up()`: optional setup hook.
143
155
  - `tear_down()`: optional cleanup hook.
144
- - `check() -> str | None`: return an error description or `None`/`""`.
156
+ - `check(output=None) -> str | None`: return an error description or `None`/`""`. `output` is the last agent response.
145
157
  - `on_success(result)`: optional success hook.
146
158
  - `on_failure(result)`: optional failure hook.
147
159
 
@@ -163,6 +175,26 @@ Exception raised by `task()` when retries are exhausted.
163
175
  - `attempts` (int | None): attempts made when the task failed.
164
176
  - `errors` (str | None): last checker error, if any.
165
177
 
178
+ ### `foreach(list_file, task_file, n=None, cwd=None, yolo=True, flags=None) -> ForeachResult`
179
+
180
+ Runs a task file over a list of items, updating the list file in place.
181
+
182
+ - `list_file` (str | PathLike): path to the list file to process.
183
+ - `task_file` (str | PathLike): YAML task file (must include `prompt`).
184
+ - `n` (int | None): limit parallelism to N (default: run all items in parallel).
185
+ - `cwd` (str | PathLike | None): working directory for the Codex session.
186
+ - `yolo` (bool): pass `--yolo` to Codex when true (defaults to true).
187
+ - `flags` (str | None): extra CLI flags to pass to Codex.
188
+
189
+ ### `ForeachResult(succeeded, failed, skipped, results)`
190
+
191
+ Simple result object returned by `foreach()`.
192
+
193
+ - `succeeded` (int): number of successful items.
194
+ - `failed` (int): number of failed items.
195
+ - `skipped` (int): number of items skipped (already marked in the list file).
196
+ - `results` (list[tuple]): `(item, success, summary)` entries for items that ran.
197
+
166
198
  ## Behavior notes
167
199
 
168
200
  - Uses `codex exec --json` and parses JSONL events for `agent_message` items.
@@ -5,10 +5,13 @@ src/codexapi/__init__.py
5
5
  src/codexapi/__main__.py
6
6
  src/codexapi/agent.py
7
7
  src/codexapi/cli.py
8
+ src/codexapi/foreach.py
8
9
  src/codexapi/ralph.py
9
10
  src/codexapi/task.py
11
+ src/codexapi/taskfile.py
10
12
  src/codexapi.egg-info/PKG-INFO
11
13
  src/codexapi.egg-info/SOURCES.txt
12
14
  src/codexapi.egg-info/dependency_links.txt
13
15
  src/codexapi.egg-info/entry_points.txt
16
+ src/codexapi.egg-info/requires.txt
14
17
  src/codexapi.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ PyYAML>=6.0
2
+ tqdm>=4.64
File without changes
File without changes
File without changes