Skip to content

Commit 7a83381

Browse files
committed
Add watch mode
Add a watch subcommand that ticks a long-lived Agent every N minutes and expects JSON status updates with status/continue/comments. Bump version to 0.6.3.
1 parent c25c42b commit 7a83381

5 files changed

Lines changed: 235 additions & 3 deletions

File tree

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,14 @@ codexapi run --thread-id THREAD_ID --print-thread-id "Continue where we left off
111111

112112
Use `--no-yolo` to run Codex with `--full-auto` instead.
113113

114+
Watch mode periodically ticks a long-running agent session with the current time
115+
and prints JSON status updates. The agent controls the loop by setting
116+
`continue` to true/false in its JSON response.
117+
118+
```bash
119+
codexapi watch 5 "Run the benchmark and wait for results."
120+
```
121+
114122
Ralph loop mode repeats the same prompt until a completion promise or a max
115123
iteration cap is hit (0 means unlimited). Cancel by deleting
116124
`.codexapi/ralph-loop.local.md` or running `codexapi ralph --cancel`.
@@ -174,6 +182,13 @@ the same conversation and returns only the agent's message.
174182
- `welfare` (bool): when true, append welfare stop instructions to each prompt
175183
and raise `WelfareStop` if the agent outputs `MAKE IT STOP`.
176184

185+
### `watch(minutes, prompt, cwd=None, yolo=True, flags=None) -> dict`
186+
187+
Runs a long-lived agent session and periodically "ticks" it with the current
188+
local time and a reminder of `prompt`. Each tick expects JSON with keys:
189+
`status` (one line), `continue` (bool), and `comments` (string). The loop stops
190+
when `continue` is false.
191+
177192
### `task(prompt, check=None, max_iterations=10, cwd=None, yolo=True, flags=None, progress=False, set_up=None, tear_down=None, on_success=None, on_failure=None) -> str`
178193

179194
Runs a task with checker-driven retries and returns the success summary.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "codexapi"
7-
version = "0.6.2"
7+
version = "0.6.3"
88
description = "Minimal Python API for running the Codex CLI."
99
readme = "README.md"
1010
requires-python = ">=3.8"

src/codexapi/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from .ralph import Ralph
88
from .science import Science
99
from .task import Task, TaskFailed, TaskResult, task, task_result
10+
from .watch import watch
1011

1112
__all__ = [
1213
"Agent",
@@ -24,5 +25,6 @@
2425
"foreach",
2526
"task",
2627
"task_result",
28+
"watch",
2729
]
28-
__version__ = "0.6.2"
30+
__version__ = "0.6.3"

src/codexapi/cli.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from .task import DEFAULT_MAX_ITERATIONS, TaskFailed, task
2020
from .taskfile import TaskFile, load_task_file, task_def_uses_item
2121
from .rate_limits import quota_line
22+
from .watch import watch
2223

2324
_SESSION_ID_RE = re.compile(
2425
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
@@ -1039,6 +1040,32 @@ def main(argv=None):
10391040
"--flags",
10401041
help="Additional raw CLI flags to pass to Codex (quoted as needed).",
10411042
)
1043+
1044+
watch_parser = subparsers.add_parser(
1045+
"watch",
1046+
help="Periodically tick an agent for long-running work.",
1047+
)
1048+
watch_parser.add_argument(
1049+
"minutes",
1050+
type=int,
1051+
help="Tick interval in minutes (integer, >= 1).",
1052+
)
1053+
watch_parser.add_argument(
1054+
"prompt",
1055+
nargs="?",
1056+
help="Prompt to send. Use '-' or omit to read from stdin.",
1057+
)
1058+
watch_parser.add_argument("--cwd", help="Working directory for the Codex session.")
1059+
watch_parser.add_argument(
1060+
"--no-yolo",
1061+
action="store_false",
1062+
dest="yolo",
1063+
help="Disable --yolo and use --full-auto.",
1064+
)
1065+
watch_parser.add_argument(
1066+
"--flags",
1067+
help="Additional raw CLI flags to pass to Codex (quoted as needed).",
1068+
)
10421069
run_parser.add_argument(
10431070
"--thread-id",
10441071
help="Resume an existing Codex thread id.",
@@ -1474,7 +1501,7 @@ def main(argv=None):
14741501

14751502
prompt_source = None
14761503
prompt = None
1477-
if args.command in ("run", "ralph"):
1504+
if args.command in ("run", "ralph", "watch"):
14781505
prompt_source = args.prompt
14791506
elif args.command == "science":
14801507
prompt_source = args.task
@@ -1509,6 +1536,14 @@ def main(argv=None):
15091536
args.ralph_fresh,
15101537
)()
15111538
return
1539+
if args.command == "watch":
1540+
if args.minutes < 1:
1541+
raise SystemExit("watch minutes must be >= 1.")
1542+
try:
1543+
watch(args.minutes, prompt, args.cwd, args.yolo, args.flags)
1544+
except KeyboardInterrupt:
1545+
raise SystemExit(130)
1546+
return
15121547
if args.command == "task":
15131548
if args.project:
15141549
raise SystemExit("task --project already handled earlier.")

src/codexapi/watch.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
"""Periodic watch loop for long-running Codex work.
2+
3+
watch keeps a single Codex thread alive and periodically "ticks" it with the
4+
current time and a reminder of the original instructions. Each tick expects a
5+
small JSON status payload so the loop can decide whether to continue.
6+
"""
7+
8+
import json
9+
import time
10+
from datetime import datetime
11+
12+
from .agent import Agent
13+
14+
_JSON_INSTRUCTIONS = (
15+
"Respond with JSON only (no markdown/backticks/extra text).\n"
16+
"Return a single JSON object with keys:\n"
17+
" status: string (one line)\n"
18+
" continue: boolean\n"
19+
" comments: string\n"
20+
"To stop this watch loop, set continue to false."
21+
)
22+
23+
24+
def watch(minutes, prompt, cwd=None, yolo=True, flags=None):
25+
"""Run a periodic watch loop.
26+
27+
Args:
28+
minutes: Tick interval in whole minutes (>= 1).
29+
prompt: The original instruction prompt.
30+
cwd: Optional working directory for the Codex session.
31+
yolo: Whether to pass --yolo to Codex.
32+
flags: Additional raw CLI flags to pass to Codex.
33+
34+
Returns:
35+
The last parsed JSON status object.
36+
"""
37+
if not isinstance(minutes, int):
38+
raise TypeError("minutes must be an integer")
39+
if minutes < 1:
40+
raise ValueError("minutes must be >= 1")
41+
if not isinstance(prompt, str) or not prompt.strip():
42+
raise ValueError("prompt must be a non-empty string")
43+
44+
interval = minutes * 60
45+
session = Agent(cwd, yolo, None, flags)
46+
47+
last_sent = None
48+
last_result = None
49+
tick = 0
50+
51+
while True:
52+
tick += 1
53+
sent_at = time.monotonic()
54+
elapsed = None if last_sent is None else sent_at - last_sent
55+
last_sent = sent_at
56+
57+
now = datetime.now().astimezone().isoformat(timespec="seconds")
58+
message = _build_tick_prompt(prompt, now, elapsed, tick)
59+
output = session(message)
60+
result = _parse_status(output)
61+
last_result = result
62+
_print_status(now, elapsed, tick, result)
63+
64+
if not result["continue"]:
65+
return last_result
66+
67+
next_tick = sent_at + interval
68+
sleep_seconds = next_tick - time.monotonic()
69+
if sleep_seconds > 0:
70+
time.sleep(sleep_seconds)
71+
72+
73+
def _build_tick_prompt(prompt, now, elapsed, tick):
74+
lines = [
75+
f"Tick {tick}.",
76+
f"Local time now: {now}",
77+
]
78+
if elapsed is not None:
79+
lines.append(
80+
"Time since last tick: "
81+
f"{_format_minutes_seconds(elapsed)} ({int(round(elapsed))}s)"
82+
)
83+
lines.extend(
84+
[
85+
"",
86+
"A reminder: your instructions are:",
87+
prompt.strip(),
88+
"",
89+
_JSON_INSTRUCTIONS,
90+
]
91+
)
92+
return "\n".join(lines).strip()
93+
94+
95+
def _format_minutes_seconds(seconds):
96+
if seconds is None:
97+
return ""
98+
seconds = int(round(seconds))
99+
if seconds < 0:
100+
seconds = 0
101+
minutes, seconds = divmod(seconds, 60)
102+
return f"{minutes}m{seconds:02d}s"
103+
104+
105+
def _parse_status(output):
106+
text = _maybe_strip_code_fence(str(output or "").strip())
107+
data = _try_parse_json(text)
108+
if data is None:
109+
snippet = text[:200].replace("\n", "\\n")
110+
raise ValueError(f"Invalid JSON response. Snippet: {snippet}")
111+
if not isinstance(data, dict):
112+
raise ValueError("Status JSON must be an object.")
113+
114+
status = data.get("status")
115+
cont = data.get("continue")
116+
comments = data.get("comments")
117+
118+
if not isinstance(status, str):
119+
raise ValueError("Status JSON missing string 'status'.")
120+
if not isinstance(cont, bool):
121+
raise ValueError("Status JSON missing boolean 'continue'.")
122+
if comments is None:
123+
comments = ""
124+
if not isinstance(comments, str):
125+
raise ValueError("Status JSON missing string 'comments'.")
126+
127+
return {
128+
"status": _single_line(status),
129+
"continue": cont,
130+
"comments": comments,
131+
}
132+
133+
134+
def _maybe_strip_code_fence(text):
135+
if not text.startswith("```"):
136+
return text
137+
lines = text.splitlines()
138+
if not lines:
139+
return text
140+
if lines[0].startswith("```"):
141+
lines = lines[1:]
142+
if lines and lines[-1].strip() == "```":
143+
lines = lines[:-1]
144+
return "\n".join(lines).strip()
145+
146+
147+
def _try_parse_json(text):
148+
if not text:
149+
return None
150+
try:
151+
return json.loads(text)
152+
except json.JSONDecodeError:
153+
pass
154+
155+
start = text.find("{")
156+
end = text.rfind("}")
157+
if start == -1 or end == -1 or end <= start:
158+
return None
159+
try:
160+
return json.loads(text[start : end + 1])
161+
except json.JSONDecodeError:
162+
return None
163+
164+
165+
def _single_line(text):
166+
return " ".join(text.replace("\r", " ").split())
167+
168+
169+
def _print_status(now, elapsed, tick, result):
170+
delta = ""
171+
if elapsed is not None:
172+
delta = f" +{_format_minutes_seconds(elapsed)}"
173+
status = result.get("status", "")
174+
cont = result.get("continue")
175+
line = f"[watch {tick} {now}{delta}] {status} (continue={cont})".rstrip()
176+
print(line)
177+
comments = result.get("comments") or ""
178+
if comments.strip():
179+
print(comments.rstrip())
180+

0 commit comments

Comments
 (0)