codexapi 0.1.7__tar.gz → 0.1.9__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.1.7
3
+ Version: 0.1.9
4
4
  Summary: Minimal Python API for running the Codex CLI.
5
5
  License: MIT
6
6
  Keywords: codex,agent,cli,openai
@@ -81,6 +81,16 @@ Resume a session and print the thread id to stderr:
81
81
  codexapi --thread-id THREAD_ID --print-thread-id "Continue where we left off."
82
82
  ```
83
83
 
84
+ Ralph loop mode repeats the same prompt until a completion promise or a max
85
+ iteration cap is hit (0 means unlimited). Cancel by deleting
86
+ `.codexapi/ralph-loop.local.md` or running `codexapi --ralph-cancel`.
87
+
88
+ ```bash
89
+ codexapi --ralph "Fix the bug." --completion-promise DONE --max-iterations 5
90
+ codexapi --ralph --ralph-fresh "Try again from scratch." --max-iterations 3
91
+ codexapi --ralph-cancel --cwd /path/to/project
92
+ ```
93
+
84
94
  ## API
85
95
 
86
96
  ### `agent(prompt, cwd=None, yolo=False, flags=None) -> str`
@@ -69,6 +69,16 @@ Resume a session and print the thread id to stderr:
69
69
  codexapi --thread-id THREAD_ID --print-thread-id "Continue where we left off."
70
70
  ```
71
71
 
72
+ Ralph loop mode repeats the same prompt until a completion promise or a max
73
+ iteration cap is hit (0 means unlimited). Cancel by deleting
74
+ `.codexapi/ralph-loop.local.md` or running `codexapi --ralph-cancel`.
75
+
76
+ ```bash
77
+ codexapi --ralph "Fix the bug." --completion-promise DONE --max-iterations 5
78
+ codexapi --ralph --ralph-fresh "Try again from scratch." --max-iterations 3
79
+ codexapi --ralph-cancel --cwd /path/to/project
80
+ ```
81
+
72
82
  ## API
73
83
 
74
84
  ### `agent(prompt, cwd=None, yolo=False, flags=None) -> str`
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "codexapi"
7
- version = "0.1.7"
7
+ version = "0.1.9"
8
8
  description = "Minimal Python API for running the Codex CLI."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -12,4 +12,4 @@ __all__ = [
12
12
  "task",
13
13
  "task_result",
14
14
  ]
15
- __version__ = "0.1.7"
15
+ __version__ = "0.1.8"
@@ -12,6 +12,7 @@ from datetime import datetime
12
12
  from pathlib import Path
13
13
 
14
14
  from .agent import Agent, agent
15
+ from .ralph import cancel_ralph_loop, run_ralph_loop
15
16
  from .task import TaskFailed, task
16
17
 
17
18
  _SESSION_ID_RE = re.compile(
@@ -36,6 +37,7 @@ _COLUMN_TITLES = {
36
37
  "tok": "TOK/S",
37
38
  "in": "IN",
38
39
  "out": "OUT",
40
+ "turn": "TURN",
39
41
  "model": "MODEL",
40
42
  "effort": "EFF",
41
43
  "perm": "PERM",
@@ -309,6 +311,24 @@ def _format_token_total(value):
309
311
  return str(value)
310
312
 
311
313
 
314
+ def _format_duration(seconds):
315
+ if seconds is None:
316
+ return "-"
317
+ if seconds < 0:
318
+ return "-"
319
+ total = int(seconds)
320
+ days, rem = divmod(total, 86400)
321
+ hours, rem = divmod(rem, 3600)
322
+ minutes, secs = divmod(rem, 60)
323
+ if days:
324
+ return f"{days}d{hours:02d}h"
325
+ if hours:
326
+ return f"{hours}h{minutes:02d}m"
327
+ if minutes:
328
+ return f"{minutes}m{secs:02d}s"
329
+ return f"{secs}s"
330
+
331
+
312
332
  def _summarize_session(path, mtime):
313
333
  prompt = None
314
334
  prompt_fallback = None
@@ -569,6 +589,7 @@ def _layout_columns(width, id_width, show):
569
589
  ("tok", ">"),
570
590
  ("in", ">"),
571
591
  ("out", ">"),
592
+ ("turn", ">"),
572
593
  ]
573
594
  widths = {
574
595
  "id": id_width,
@@ -576,6 +597,7 @@ def _layout_columns(width, id_width, show):
576
597
  "tok": 7,
577
598
  "in": 7,
578
599
  "out": 7,
600
+ "turn": 7,
579
601
  }
580
602
  mins = {}
581
603
 
@@ -638,6 +660,16 @@ def _format_session(session, layout):
638
660
  status = "RUN" if session.get("status") == "running" else "IDLE"
639
661
  tok_s = session["tok_s"]
640
662
  tok_s_str = "-" if tok_s is None else f"{tok_s:5.1f}"
663
+ last_user_ts = session.get("last_user_ts")
664
+ last_agent_ts = session.get("last_agent_ts")
665
+ if status == "RUN":
666
+ turn_seconds = (datetime.now() - last_user_ts).total_seconds() if last_user_ts else None
667
+ else:
668
+ if last_user_ts and last_agent_ts:
669
+ turn_seconds = (last_agent_ts - last_user_ts).total_seconds()
670
+ else:
671
+ turn_seconds = None
672
+ turn_str = _format_duration(turn_seconds)
641
673
  meta = session.get("meta") or {}
642
674
  model = meta.get("model") or meta.get("model_provider") or "-"
643
675
  effort = meta.get("effort") or "-"
@@ -655,6 +687,7 @@ def _format_session(session, layout):
655
687
  "tok": tok_s_str,
656
688
  "in": total_in,
657
689
  "out": total_out,
690
+ "turn": turn_str,
658
691
  "model": _truncate_head(str(model), widths.get("model", 0)),
659
692
  "effort": _truncate_head(str(effort), widths.get("effort", 0)),
660
693
  "perm": _truncate_head(str(perm), widths.get("perm", 0)),
@@ -837,9 +870,22 @@ def main(argv=None):
837
870
  if argv and argv[0] == "top":
838
871
  _run_top(argv[1:])
839
872
  return
873
+ ralph_help = (
874
+ "Ralph loop mode (--ralph):\n"
875
+ " Repeats the exact same prompt each iteration until a completion promise\n"
876
+ " is detected or --max-iterations is reached (0 means unlimited).\n"
877
+ " Completion promise: output <promise>TEXT</promise> where TEXT matches\n"
878
+ " --completion-promise after trimming/collapsing whitespace. CRITICAL RULE:\n"
879
+ " Only output the promise when it is completely and unequivocally TRUE.\n"
880
+ " Cancel by deleting .codexapi/ralph-loop.local.md or running --ralph-cancel.\n"
881
+ " Default reuses a single Codex thread; use --ralph-fresh for a new Agent\n"
882
+ " each iteration (no shared context).\n"
883
+ )
840
884
  parser = argparse.ArgumentParser(
841
885
  prog="codexapi",
842
886
  description="Run Codex via the codexapi wrapper.",
887
+ epilog=ralph_help,
888
+ formatter_class=argparse.RawDescriptionHelpFormatter,
843
889
  )
844
890
  parser.add_argument(
845
891
  "prompt",
@@ -870,10 +916,67 @@ def main(argv=None):
870
916
  action="store_true",
871
917
  help="Print the current thread id to stderr after running.",
872
918
  )
919
+ parser.add_argument(
920
+ "--ralph",
921
+ action="store_true",
922
+ help="Run a Ralph loop that repeats the same prompt each iteration.",
923
+ )
924
+ parser.add_argument(
925
+ "--max-iterations",
926
+ type=int,
927
+ default=None,
928
+ help="Max iterations for --ralph (0 means unlimited).",
929
+ )
930
+ parser.add_argument(
931
+ "--completion-promise",
932
+ help="Promise text for --ralph to match in <promise>...</promise>.",
933
+ )
934
+ parser.add_argument(
935
+ "--ralph-fresh",
936
+ action="store_true",
937
+ help="With --ralph, start each iteration with a fresh Agent context.",
938
+ )
939
+ parser.add_argument(
940
+ "--ralph-cancel",
941
+ action="store_true",
942
+ help=(
943
+ "Cancel a Ralph loop by removing .codexapi/ralph-loop.local.md "
944
+ "(respects --cwd)."
945
+ ),
946
+ )
873
947
 
874
948
  args = parser.parse_args(argv)
949
+ if args.ralph_cancel:
950
+ if (
951
+ args.ralph
952
+ or args.task
953
+ or args.thread_id
954
+ or args.print_thread_id
955
+ or args.max_iterations is not None
956
+ or args.completion_promise is not None
957
+ or args.ralph_fresh
958
+ or args.check is not None
959
+ or args.prompt
960
+ ):
961
+ raise SystemExit(
962
+ "--ralph-cancel cannot be combined with prompts or other modes."
963
+ )
964
+ print(cancel_ralph_loop(args.cwd))
965
+ return
875
966
  if args.check is not None and not args.task:
876
967
  raise SystemExit("--check requires --task.")
968
+ if not args.ralph and (
969
+ args.max_iterations is not None
970
+ or args.completion_promise is not None
971
+ or args.ralph_fresh
972
+ ):
973
+ raise SystemExit(
974
+ "--max-iterations/--completion-promise/--ralph-fresh require --ralph."
975
+ )
976
+ if args.ralph and (args.task or args.thread_id or args.print_thread_id):
977
+ raise SystemExit(
978
+ "--task/--thread-id/--print-thread-id are not supported with --ralph."
979
+ )
877
980
  if args.task and (args.thread_id or args.print_thread_id):
878
981
  raise SystemExit("--thread-id/--print-thread-id are not supported with --task.")
879
982
 
@@ -881,6 +984,20 @@ def main(argv=None):
881
984
 
882
985
  exit_code = 0
883
986
 
987
+ if args.ralph:
988
+ max_iterations = args.max_iterations if args.max_iterations is not None else 0
989
+ if max_iterations < 0:
990
+ raise SystemExit("--max-iterations must be >= 0.")
991
+ run_ralph_loop(
992
+ prompt,
993
+ args.cwd,
994
+ args.yolo,
995
+ args.flags,
996
+ max_iterations,
997
+ args.completion_promise,
998
+ args.ralph_fresh,
999
+ )
1000
+ return
884
1001
  if args.task:
885
1002
  check = args.check if args.check is not None else prompt
886
1003
  try:
@@ -0,0 +1,333 @@
1
+ """Ralph Wiggum-style loop for Codex runs."""
2
+
3
+ import os
4
+ import re
5
+ import sys
6
+ from datetime import datetime, timezone
7
+
8
+ from .agent import Agent
9
+
10
+ _STATE_DIR = ".codexapi"
11
+ _STATE_FILE = "ralph-loop.local.md"
12
+ _PROMISE_RE = re.compile(r"<promise>(.*?)</promise>", re.DOTALL)
13
+
14
+
15
+ def run_ralph_loop(
16
+ prompt,
17
+ cwd=None,
18
+ yolo=False,
19
+ flags=None,
20
+ max_iterations=0,
21
+ completion_promise=None,
22
+ fresh=False,
23
+ ):
24
+ """Run a Ralph Wiggum-style loop that repeats the same prompt.
25
+
26
+ The loop writes `.codexapi/ralph-loop.local.md` in the target cwd and keeps
27
+ sending the exact same prompt each iteration until one of these happens:
28
+ - A completion promise is matched.
29
+ - `max_iterations` is reached (0 means unlimited).
30
+ - The state file is removed (cancel).
31
+ - An error or KeyboardInterrupt.
32
+
33
+ To complete with a promise, the agent must output:
34
+ <promise>TEXT</promise>
35
+ `TEXT` is trimmed and whitespace-collapsed before an exact match against
36
+ `completion_promise`. CRITICAL RULE: If a completion promise is set, you
37
+ may ONLY output it when the statement is completely and unequivocally TRUE.
38
+ Do not output false promises to escape the loop.
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.
42
+ Cancel by deleting the state file or running `codexapi --ralph-cancel`.
43
+ """
44
+ if not isinstance(prompt, str) or not prompt.strip():
45
+ raise ValueError("prompt must be a non-empty string")
46
+ if completion_promise is not None and not isinstance(completion_promise, str):
47
+ raise TypeError("completion_promise must be a string or None")
48
+ if max_iterations < 0:
49
+ raise ValueError("max_iterations must be >= 0")
50
+
51
+ state_path = _state_path(cwd)
52
+ _ensure_state_dir(state_path)
53
+
54
+ started_at = _utc_now()
55
+ iteration = 1
56
+ _write_state(
57
+ state_path,
58
+ iteration,
59
+ max_iterations,
60
+ completion_promise,
61
+ started_at,
62
+ prompt,
63
+ )
64
+
65
+ max_label = str(max_iterations) if max_iterations > 0 else "unlimited"
66
+ if completion_promise is None:
67
+ promise_label = "none (runs forever)"
68
+ else:
69
+ promise_label = (
70
+ f"{completion_promise} (ONLY output when TRUE - do not lie!)"
71
+ )
72
+
73
+ print(
74
+ "\n".join(
75
+ [
76
+ "Ralph loop activated.",
77
+ "",
78
+ f"Iteration: {iteration}",
79
+ f"Max iterations: {max_label}",
80
+ f"Completion promise: {promise_label}",
81
+ "",
82
+ "The loop will resend the SAME PROMPT each iteration.",
83
+ "Cancel by deleting .codexapi/ralph-loop.local.md or running",
84
+ "codexapi --ralph-cancel.",
85
+ "No manual stop beyond max iterations or completion promise.",
86
+ "",
87
+ "To monitor: head -10 .codexapi/ralph-loop.local.md",
88
+ "",
89
+ ]
90
+ )
91
+ )
92
+ print(prompt)
93
+
94
+ if completion_promise is not None:
95
+ print(
96
+ "\n".join(
97
+ [
98
+ "",
99
+ "CRITICAL - Ralph Loop Completion Promise",
100
+ "",
101
+ "To complete this loop, output this EXACT text:",
102
+ f" <promise>{completion_promise}</promise>",
103
+ "",
104
+ "STRICT REQUIREMENTS (DO NOT VIOLATE):",
105
+ " - Use <promise> XML tags EXACTLY as shown above",
106
+ " - The statement MUST be completely and unequivocally TRUE",
107
+ " - Do NOT output false statements to exit the loop",
108
+ " - Do NOT lie even if you think you should exit",
109
+ "",
110
+ "CRITICAL RULE: If a completion promise is set, you may ONLY",
111
+ "output it when the statement is completely and unequivocally",
112
+ "TRUE. Do not output false promises to escape the loop, even if",
113
+ "you think you're stuck or should exit for other reasons. The",
114
+ "loop is designed to continue until genuine completion.",
115
+ "",
116
+ ]
117
+ )
118
+ )
119
+
120
+ runner = None
121
+ last_message = None
122
+ state_missing = False
123
+
124
+ try:
125
+ while True:
126
+ if not os.path.exists(state_path):
127
+ state_missing = True
128
+ print("Ralph loop canceled: state file removed.")
129
+ return last_message
130
+
131
+ print(_status_line(iteration, completion_promise))
132
+
133
+ if fresh:
134
+ runner = Agent(cwd, yolo, None, flags)
135
+ elif runner is None:
136
+ runner = Agent(cwd, yolo, None, flags)
137
+
138
+ message = runner(prompt)
139
+ print(message)
140
+ last_message = message
141
+
142
+ if not os.path.exists(state_path):
143
+ state_missing = True
144
+ print("Ralph loop canceled: state file removed.")
145
+ return last_message
146
+
147
+ if max_iterations > 0 and iteration >= max_iterations:
148
+ print(f"Ralph loop: Max iterations ({max_iterations}) reached.")
149
+ return message
150
+
151
+ if promise_matches(message, completion_promise):
152
+ print(
153
+ "Ralph loop: Detected "
154
+ f"<promise>{completion_promise}</promise>"
155
+ )
156
+ return message
157
+
158
+ if not os.path.exists(state_path):
159
+ state_missing = True
160
+ print("Ralph loop canceled: state file removed.")
161
+ return last_message
162
+
163
+ iteration += 1
164
+ _write_state(
165
+ state_path,
166
+ iteration,
167
+ max_iterations,
168
+ completion_promise,
169
+ started_at,
170
+ prompt,
171
+ )
172
+ except KeyboardInterrupt:
173
+ print("Ralph loop interrupted.", file=sys.stderr)
174
+ raise SystemExit(130)
175
+ except Exception as exc:
176
+ print(f"Ralph loop stopped: {exc}", file=sys.stderr)
177
+ raise SystemExit(1)
178
+ finally:
179
+ if not state_missing:
180
+ _cleanup_state(state_path)
181
+
182
+
183
+ def cancel_ralph_loop(cwd=None):
184
+ """Cancel the Ralph loop by removing the state file."""
185
+ state_path = _state_path(cwd)
186
+ if not os.path.exists(state_path):
187
+ return "No active Ralph loop state found."
188
+
189
+ iteration = None
190
+ try:
191
+ fields = _read_state_fields(state_path)
192
+ value = fields.get("iteration")
193
+ if value and value.isdigit():
194
+ iteration = int(value)
195
+ except OSError:
196
+ iteration = None
197
+
198
+ _cleanup_state(state_path)
199
+
200
+ if iteration is None:
201
+ return "Canceled Ralph loop."
202
+ return f"Canceled Ralph loop at iteration {iteration}."
203
+
204
+
205
+ def promise_matches(message, completion_promise):
206
+ """Return True when the message contains the matching completion promise."""
207
+ if completion_promise is None:
208
+ return False
209
+ extracted = _extract_promise(message)
210
+ if extracted is None:
211
+ return False
212
+ return extracted == completion_promise
213
+
214
+
215
+ def _extract_promise(message):
216
+ """Extract and normalize the first <promise>...</promise> tag from text."""
217
+ match = _PROMISE_RE.search(message)
218
+ if not match:
219
+ return None
220
+ return _normalize_whitespace(match.group(1))
221
+
222
+
223
+ def _normalize_whitespace(text):
224
+ """Trim and collapse whitespace to match the Claude plugin behavior."""
225
+ return " ".join(text.split())
226
+
227
+
228
+ def _state_path(cwd):
229
+ """Return the absolute path for the Ralph loop state file."""
230
+ root = os.fspath(cwd) if cwd else os.getcwd()
231
+ return os.path.join(root, _STATE_DIR, _STATE_FILE)
232
+
233
+
234
+ def _ensure_state_dir(state_path):
235
+ """Ensure the Ralph loop state directory exists."""
236
+ os.makedirs(os.path.dirname(state_path), exist_ok=True)
237
+
238
+
239
+ def _write_state(
240
+ state_path,
241
+ iteration,
242
+ max_iterations,
243
+ completion_promise,
244
+ started_at,
245
+ prompt,
246
+ ):
247
+ """Write the Ralph loop state file atomically."""
248
+ content = _state_content(
249
+ iteration,
250
+ max_iterations,
251
+ completion_promise,
252
+ started_at,
253
+ prompt,
254
+ )
255
+ temp_path = f"{state_path}.tmp.{os.getpid()}"
256
+ with open(temp_path, "w", encoding="utf-8") as handle:
257
+ handle.write(content)
258
+ os.replace(temp_path, state_path)
259
+
260
+
261
+ def _state_content(
262
+ iteration,
263
+ max_iterations,
264
+ completion_promise,
265
+ started_at,
266
+ prompt,
267
+ ):
268
+ """Build the YAML frontmatter state file content."""
269
+ completion_value = _format_completion_promise(completion_promise)
270
+ lines = [
271
+ "---",
272
+ "active: true",
273
+ f"iteration: {iteration}",
274
+ f"max_iterations: {max_iterations}",
275
+ f"completion_promise: {completion_value}",
276
+ f"started_at: \"{started_at}\"",
277
+ "---",
278
+ "",
279
+ prompt,
280
+ ]
281
+ return "\n".join(lines)
282
+
283
+
284
+ def _format_completion_promise(completion_promise):
285
+ """Format the completion promise to match the plugin frontmatter."""
286
+ if completion_promise is None:
287
+ return "null"
288
+ return f"\"{completion_promise}\""
289
+
290
+
291
+ def _read_state_fields(state_path):
292
+ """Read YAML frontmatter fields from the Ralph loop state file."""
293
+ with open(state_path, "r", encoding="utf-8") as handle:
294
+ lines = handle.read().splitlines()
295
+ if not lines or lines[0].strip() != "---":
296
+ return {}
297
+
298
+ fields = {}
299
+ for line in lines[1:]:
300
+ if line.strip() == "---":
301
+ break
302
+ if ":" not in line:
303
+ continue
304
+ key, value = line.split(":", 1)
305
+ fields[key.strip()] = value.strip()
306
+ return fields
307
+
308
+
309
+ def _cleanup_state(state_path):
310
+ """Remove the Ralph loop state file if it exists."""
311
+ try:
312
+ os.remove(state_path)
313
+ except FileNotFoundError:
314
+ return
315
+
316
+
317
+ def _status_line(iteration, completion_promise):
318
+ """Build the per-iteration status line for the Ralph loop."""
319
+ if completion_promise is None:
320
+ return (
321
+ f"Ralph iteration {iteration} | "
322
+ "No completion promise set - loop runs infinitely"
323
+ )
324
+ return (
325
+ f"Ralph iteration {iteration} | To stop: output "
326
+ f"<promise>{completion_promise}</promise> "
327
+ "(ONLY when statement is TRUE - do not lie to exit!)"
328
+ )
329
+
330
+
331
+ def _utc_now():
332
+ """Return a UTC timestamp string matching the Claude plugin."""
333
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: codexapi
3
- Version: 0.1.7
3
+ Version: 0.1.9
4
4
  Summary: Minimal Python API for running the Codex CLI.
5
5
  License: MIT
6
6
  Keywords: codex,agent,cli,openai
@@ -81,6 +81,16 @@ Resume a session and print the thread id to stderr:
81
81
  codexapi --thread-id THREAD_ID --print-thread-id "Continue where we left off."
82
82
  ```
83
83
 
84
+ Ralph loop mode repeats the same prompt until a completion promise or a max
85
+ iteration cap is hit (0 means unlimited). Cancel by deleting
86
+ `.codexapi/ralph-loop.local.md` or running `codexapi --ralph-cancel`.
87
+
88
+ ```bash
89
+ codexapi --ralph "Fix the bug." --completion-promise DONE --max-iterations 5
90
+ codexapi --ralph --ralph-fresh "Try again from scratch." --max-iterations 3
91
+ codexapi --ralph-cancel --cwd /path/to/project
92
+ ```
93
+
84
94
  ## API
85
95
 
86
96
  ### `agent(prompt, cwd=None, yolo=False, flags=None) -> str`
@@ -5,6 +5,7 @@ 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/ralph.py
8
9
  src/codexapi/task.py
9
10
  src/codexapi.egg-info/PKG-INFO
10
11
  src/codexapi.egg-info/SOURCES.txt
File without changes
File without changes
File without changes
File without changes