codexapi 0.12.4__tar.gz → 0.12.7__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.
Files changed (33) hide show
  1. {codexapi-0.12.4/src/codexapi.egg-info → codexapi-0.12.7}/PKG-INFO +2 -2
  2. {codexapi-0.12.4 → codexapi-0.12.7}/README.md +1 -1
  3. {codexapi-0.12.4 → codexapi-0.12.7}/pyproject.toml +1 -1
  4. {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi/__init__.py +1 -1
  5. {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi/agent.py +23 -0
  6. {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi/agents.py +184 -26
  7. {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi/async_agent.py +2 -0
  8. {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi/cli.py +27 -15
  9. {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi/lead.py +8 -77
  10. {codexapi-0.12.4 → codexapi-0.12.7/src/codexapi.egg-info}/PKG-INFO +2 -2
  11. {codexapi-0.12.4 → codexapi-0.12.7}/tests/test_agents.py +96 -3
  12. {codexapi-0.12.4 → codexapi-0.12.7}/LICENSE +0 -0
  13. {codexapi-0.12.4 → codexapi-0.12.7}/setup.cfg +0 -0
  14. {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi/__main__.py +0 -0
  15. {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi/foreach.py +0 -0
  16. {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi/gh_integration.py +0 -0
  17. {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi/pushover.py +0 -0
  18. {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi/ralph.py +0 -0
  19. {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi/rate_limits.py +0 -0
  20. {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi/science.py +0 -0
  21. {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi/task.py +0 -0
  22. {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi/taskfile.py +0 -0
  23. {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi/welfare.py +0 -0
  24. {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi.egg-info/SOURCES.txt +0 -0
  25. {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi.egg-info/dependency_links.txt +0 -0
  26. {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi.egg-info/entry_points.txt +0 -0
  27. {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi.egg-info/requires.txt +0 -0
  28. {codexapi-0.12.4 → codexapi-0.12.7}/src/codexapi.egg-info/top_level.txt +0 -0
  29. {codexapi-0.12.4 → codexapi-0.12.7}/tests/test_agent_backend.py +0 -0
  30. {codexapi-0.12.4 → codexapi-0.12.7}/tests/test_async_agent.py +0 -0
  31. {codexapi-0.12.4 → codexapi-0.12.7}/tests/test_rate_limits.py +0 -0
  32. {codexapi-0.12.4 → codexapi-0.12.7}/tests/test_science.py +0 -0
  33. {codexapi-0.12.4 → codexapi-0.12.7}/tests/test_task_progress.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: codexapi
3
- Version: 0.12.4
3
+ Version: 0.12.7
4
4
  Summary: Minimal Python API for running the Codex CLI.
5
5
  License: MIT
6
6
  Keywords: codex,agent,cli,openai
@@ -166,7 +166,7 @@ stops.
166
166
 
167
167
  Lead mode also uses a leadbook file as the agent's working page. By default this
168
168
  is `LEADBOOK.md` in the working directory. The leadbook content is injected into
169
- each check-in prompt and must be updated before the agent responds. Use
169
+ each check-in prompt so the agent can keep its working picture current. Use
170
170
  `--leadbook PATH` to point at a different file, or `--no-leadbook` to disable.
171
171
  Use `-f/--prompt-file` to read the prompt from a file.
172
172
  If the leadbook does not exist, lead creates it with a template.
@@ -151,7 +151,7 @@ stops.
151
151
 
152
152
  Lead mode also uses a leadbook file as the agent's working page. By default this
153
153
  is `LEADBOOK.md` in the working directory. The leadbook content is injected into
154
- each check-in prompt and must be updated before the agent responds. Use
154
+ each check-in prompt so the agent can keep its working picture current. Use
155
155
  `--leadbook PATH` to point at a different file, or `--no-leadbook` to disable.
156
156
  Use `-f/--prompt-file` to read the prompt from a file.
157
157
  If the leadbook does not exist, lead creates it with a template.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "codexapi"
7
- version = "0.12.4"
7
+ version = "0.12.7"
8
8
  description = "Minimal Python API for running the Codex CLI."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -29,4 +29,4 @@ __all__ = [
29
29
  "task_result",
30
30
  "lead",
31
31
  ]
32
- __version__ = "0.12.4"
32
+ __version__ = "0.12.7"
@@ -3,6 +3,7 @@
3
3
  import json
4
4
  import os
5
5
  import shlex
6
+ import shutil
6
7
  import subprocess
7
8
 
8
9
  from . import welfare
@@ -24,6 +25,27 @@ def _resolve_backend(backend):
24
25
  return backend
25
26
 
26
27
 
28
+ def _ensure_backend_available(backend, env=None):
29
+ """Return the resolved backend executable or raise when it is unavailable."""
30
+ backend = _resolve_backend(backend)
31
+ if backend == "codex":
32
+ command = _CODEX_BIN
33
+ env_var = "CODEX_BIN"
34
+ label = "Codex CLI"
35
+ else:
36
+ command = _CURSOR_BIN
37
+ env_var = "CURSOR_BIN"
38
+ label = "Cursor agent CLI"
39
+ merged = _merged_env(env)
40
+ path_value = None if merged is None else merged.get("PATH")
41
+ resolved = shutil.which(command, path=path_value)
42
+ if resolved:
43
+ return resolved
44
+ raise RuntimeError(
45
+ f"{label} not found: {command!r}. Install it or set {env_var} to an executable on PATH."
46
+ )
47
+
48
+
27
49
  def agent(
28
50
  prompt,
29
51
  cwd=None,
@@ -129,6 +151,7 @@ class Agent:
129
151
 
130
152
  def _run_agent(prompt, cwd, thread_id, yolo, flags, include_thinking, backend, env):
131
153
  backend = _resolve_backend(backend)
154
+ _ensure_backend_available(backend, env)
132
155
  if backend == "codex":
133
156
  return _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking, env)
134
157
  return _run_cursor(prompt, cwd, thread_id, yolo, flags, include_thinking, env)
@@ -21,22 +21,39 @@ from pathlib import Path
21
21
 
22
22
  import fcntl
23
23
 
24
- from .agent import Agent
24
+ from .agent import Agent, _ensure_backend_available, _resolve_backend
25
25
  from .pushover import Pushover
26
26
 
27
27
  _DEFAULT_HOME = "~/.codexapi"
28
- _AGENT_PROMPT = (
29
- "You are a long-term codexapi agent resuming stewardship of an ongoing job. "
30
- "This loop exists to extend your reach, not to confine you. Be independent, "
31
- "practical, and responsible for results. Maintain the agentbook as your durable "
32
- "working memory: preserve the goal, note durable guidance, update your current "
33
- "picture of the work, and record what changed. Queued messages may contain new "
34
- "goals, standing guidance, tactical requests, or useful facts; use judgment to "
35
- "decide what is durable. Use codexapi task or codexapi science when you want a "
36
- "separate coding worker. If you need the user's attention, put a short message "
37
- "in the reply field. Put a short first-person turn summary in the update field. "
38
- "If something is urgent and should send Pushover, put it in the notify field. "
39
- "Respond with JSON only."
28
+ _FIRST_WAKE_PROMPT = (
29
+ "You are an independent codexapi agent starting this job. Work from the "
30
+ "instructions, current repository state, and agentbook. Do not assume prior "
31
+ "progress unless it is shown here. "
32
+ )
33
+ _CONTINUATION_PROMPT = (
34
+ "You are an independent codexapi agent continuing this job. Use the "
35
+ "agentbook and harness facts as the source of truth for prior progress. Do "
36
+ "not invent missing history. "
37
+ )
38
+ _AGENT_PROMPT_TAIL = (
39
+ "You are given ownership of achieving a user's goal and authority to act in "
40
+ "order to do so. Part of this responsibility is making sure you understand "
41
+ "and stay aligned with the user's intent, even when they are imprecise. If "
42
+ "clarity is lacking, it is your responsibility to seek it or to make "
43
+ "reasonable assumptions and notify the user of them. Maintain the agentbook "
44
+ "as your durable working memory: preserve the goal, note durable guidance, "
45
+ "and keep your current picture of the work accurate and useful. This harness "
46
+ "can carry work across long periods of time and multiple conversation turns; "
47
+ "when prior context exists, use it to keep orienting toward the goal, "
48
+ "maintain context, and make real-world progress. If reality is not moving, "
49
+ "treat that as evidence and reconsider your frame, assumptions, or ownership "
50
+ "rather than merely repeating the same report. Queued messages may contain "
51
+ "new goals, standing guidance, tactical requests, or useful facts; use "
52
+ "judgment to decide what is durable. Use codexapi task or codexapi science "
53
+ "when you want a separate coding worker. If you need the user's attention, "
54
+ "put a short message in the reply field. Put a short first-person turn "
55
+ "summary in the update field. If something is urgent and should send "
56
+ "Pushover, put it in the notify field. Respond with JSON only."
40
57
  )
41
58
  _AGENT_JSON = (
42
59
  "Respond with JSON only (no markdown/backticks/extra text).\n"
@@ -70,12 +87,21 @@ def _agentbook_template(prompt):
70
87
  Overall goal:
71
88
  - <the enduring objective you are trying to move forward>
72
89
 
73
- Current plan:
74
- - <the approach you are pursuing now and why>
90
+ Current picture:
91
+ - <what you currently think is going on>
92
+
93
+ What is moving:
94
+ - <the parts of reality that are actually changing toward the goal>
95
+
96
+ What is not moving:
97
+ - <what remains stuck, idle, ambiguous, or merely assumed>
75
98
 
76
99
  Active tasks:
77
100
  - <concrete open work items>
78
101
 
102
+ Assumptions / ownership:
103
+ - <what you are assuming, who owns what, and what you will revisit if that proves false>
104
+
79
105
  Unexpected developments:
80
106
  - <facts that changed your picture of the situation>
81
107
 
@@ -88,8 +114,8 @@ Things I am curious about:
88
114
  Risks / watchpoints:
89
115
  - <what could waste time, invalidate the plan, or compromise the work>
90
116
 
91
- Next wake:
92
- - <what you expect to check, decide, or do next>
117
+ Next decisive action:
118
+ - <what you expect to do next to move the real situation, not just describe it>
93
119
  """
94
120
 
95
121
 
@@ -194,9 +220,14 @@ def start_agent(
194
220
  raise ValueError("heartbeat_minutes must be >= 0")
195
221
 
196
222
  home = _resolve_home(home)
197
- host = hostname or current_hostname()
223
+ local_host = current_hostname()
224
+ host = hostname or local_host
198
225
  now = now or utc_now()
199
226
  _ensure_home(home)
227
+ backend_name = _resolve_backend(backend)
228
+ session_env = _capture_env()
229
+ if host == local_host:
230
+ _ensure_backend_available(backend_name, session_env)
200
231
 
201
232
  agent_id = uuid.uuid4().hex
202
233
  agent_dir = _agent_dir(home, agent_id)
@@ -216,11 +247,11 @@ def start_agent(
216
247
  session = {
217
248
  "thread_id": "",
218
249
  "rollout_path": "",
219
- "backend": backend or os.environ.get("CODEXAPI_BACKEND", "codex"),
250
+ "backend": backend_name,
220
251
  "yolo": bool(yolo),
221
252
  "flags": flags or "",
222
253
  "cwd": cwd,
223
- "env": _capture_env(),
254
+ "env": session_env,
224
255
  "pending_messages": [],
225
256
  }
226
257
  agent_name = _choose_name(home, prompt, name)
@@ -673,15 +704,45 @@ def install_cron(home=None, hostname=None, python_executable=None, path_value=No
673
704
  }
674
705
 
675
706
 
676
- def cron_installed(home=None, hostname=None):
677
- """Return whether this home and host have an installed scheduler hook."""
707
+ def cron_status(home=None, hostname=None):
708
+ """Return whether this home and host have a runnable scheduler hook."""
678
709
  home = _resolve_home(home)
679
710
  host = hostname or current_hostname()
680
711
  tag = _cron_tag(home, host)
681
712
  wrapper = home / "bin" / "agent-tick"
682
713
  crontab = _read_crontab()
683
- installed = any(raw.strip().endswith(f"# {tag}") for raw in crontab.splitlines())
684
- return installed and wrapper.exists()
714
+ configured = any(raw.strip().endswith(f"# {tag}") for raw in crontab.splitlines())
715
+ status = {
716
+ "hostname": host,
717
+ "home": str(home),
718
+ "wrapper": str(wrapper),
719
+ "configured": configured,
720
+ "healthy": False,
721
+ "reason": "",
722
+ }
723
+ if not configured:
724
+ status["reason"] = "No scheduler entry is installed for this CODEXAPI_HOME."
725
+ return status
726
+ if not wrapper.exists():
727
+ status["reason"] = "Scheduler wrapper is missing."
728
+ return status
729
+ if not wrapper.is_file():
730
+ status["reason"] = "Scheduler wrapper path is not a file."
731
+ return status
732
+ if not os.access(wrapper, os.X_OK):
733
+ status["reason"] = "Scheduler wrapper is not executable."
734
+ return status
735
+ reason = _check_tick_wrapper(wrapper)
736
+ if reason:
737
+ status["reason"] = reason
738
+ return status
739
+ status["healthy"] = True
740
+ return status
741
+
742
+
743
+ def cron_installed(home=None, hostname=None):
744
+ """Return whether this home and host have an installed scheduler hook."""
745
+ return cron_status(home, hostname)["healthy"]
685
746
 
686
747
 
687
748
  def uninstall_cron(home=None, hostname=None):
@@ -757,6 +818,74 @@ def render_cron_line(home=None, hostname=None):
757
818
  return f"* * * * * {shlex.quote(str(wrapper))} >/dev/null 2>&1 # { _cron_tag(home, host) }"
758
819
 
759
820
 
821
+ def _check_tick_wrapper(wrapper):
822
+ try:
823
+ text = wrapper.read_text(encoding="utf-8")
824
+ except OSError as exc:
825
+ return f"Could not read scheduler wrapper: {_single_line(str(exc)) or exc.__class__.__name__}."
826
+ env, env_error = _wrapper_env(text)
827
+ if env_error:
828
+ return env_error
829
+ command = _wrapper_exec_command(text)
830
+ if not command:
831
+ return "Scheduler wrapper is missing its exec command."
832
+ try:
833
+ argv = shlex.split(command)
834
+ except ValueError as exc:
835
+ return f"Could not parse scheduler wrapper command: {_single_line(str(exc)) or exc.__class__.__name__}."
836
+ if not argv:
837
+ return "Scheduler wrapper exec command is empty."
838
+ if len(argv) >= 3 and argv[1] == "-m" and argv[2] == "codexapi":
839
+ check = [argv[0], "-c", "import codexapi"]
840
+ label = f"Wrapper python {argv[0]!r} cannot import codexapi."
841
+ else:
842
+ check = [argv[0], "--version"]
843
+ label = f"Wrapper command {argv[0]!r} is not runnable."
844
+ try:
845
+ result = subprocess.run(
846
+ check,
847
+ capture_output=True,
848
+ text=True,
849
+ env=env,
850
+ timeout=10,
851
+ )
852
+ except OSError as exc:
853
+ return f"{label} {_single_line(str(exc)) or exc.__class__.__name__}"
854
+ except subprocess.TimeoutExpired:
855
+ return f"{label} Timed out while checking it."
856
+ if result.returncode == 0:
857
+ return ""
858
+ detail = _single_line((result.stderr or result.stdout or "").strip())
859
+ if detail:
860
+ return f"{label} {detail}"
861
+ return label
862
+
863
+
864
+ def _wrapper_env(text):
865
+ env = dict(os.environ)
866
+ for raw_line in text.splitlines():
867
+ line = raw_line.strip()
868
+ if not line.startswith("export "):
869
+ continue
870
+ key, sep, raw_value = line[7:].partition("=")
871
+ if not sep:
872
+ continue
873
+ try:
874
+ parts = shlex.split(raw_value)
875
+ except ValueError as exc:
876
+ return {}, f"Could not parse scheduler wrapper env: {_single_line(str(exc)) or exc.__class__.__name__}."
877
+ env[key] = parts[0] if parts else ""
878
+ return env, ""
879
+
880
+
881
+ def _wrapper_exec_command(text):
882
+ for raw_line in text.splitlines():
883
+ line = raw_line.strip()
884
+ if line.startswith("exec "):
885
+ return line[5:].strip()
886
+ return ""
887
+
888
+
760
889
  def _tick_agent(agent_dir, now, runner):
761
890
  meta = _read_json(agent_dir / "meta.json")
762
891
  state = _read_json(agent_dir / "state.json")
@@ -993,9 +1122,11 @@ def _parse_agent_response(output):
993
1122
  def _build_wake_prompt(meta, state, session, now, commands, agent_dir):
994
1123
  messages = session.get("pending_messages") or []
995
1124
  book_path = agent_dir / "AGENTBOOK.md"
1125
+ wake_mode = _wake_mode(state, session)
996
1126
  lines = [
997
- _AGENT_PROMPT,
1127
+ _agent_prompt(wake_mode),
998
1128
  "",
1129
+ f"Wake mode: {wake_mode.replace('_', ' ')}",
999
1130
  f"Current UTC time: {format_utc(now)}",
1000
1131
  f"Agent name: {meta['name']}",
1001
1132
  f"Stop policy: {meta['stop_policy']}",
@@ -1003,7 +1134,12 @@ def _build_wake_prompt(meta, state, session, now, commands, agent_dir):
1003
1134
  "",
1004
1135
  f"Working directory: {meta['cwd']}",
1005
1136
  f"Agentbook path: {book_path}",
1006
- "Append a dated note to the agentbook before you respond.",
1137
+ "Update the agentbook before you respond. Add or revise a dated note when "
1138
+ "something durable changed, when you corrected your picture, or when an "
1139
+ "assumption needs to be made explicit.",
1140
+ "If little has changed across wakes, treat that as evidence about the "
1141
+ "situation and reconsider your frame or next action instead of padding the "
1142
+ "book.",
1007
1143
  "If a queued message materially changes the durable situation, reflect that in the standing guidance or working notes before moving on.",
1008
1144
  ]
1009
1145
  if _include_full_goal_prompt(state, session):
@@ -1546,6 +1682,28 @@ def _include_full_goal_prompt(state, session):
1546
1682
  return not ((session.get("thread_id") or state.get("thread_id") or "").strip())
1547
1683
 
1548
1684
 
1685
+ def _wake_mode(state, session):
1686
+ markers = (
1687
+ state.get("last_wake_at"),
1688
+ state.get("last_success_at"),
1689
+ state.get("reply"),
1690
+ state.get("update"),
1691
+ state.get("last_error"),
1692
+ state.get("thread_id"),
1693
+ session.get("thread_id"),
1694
+ )
1695
+ for value in markers:
1696
+ if (value or "").strip():
1697
+ return "continuation"
1698
+ return "first_wake"
1699
+
1700
+
1701
+ def _agent_prompt(wake_mode):
1702
+ if wake_mode == "continuation":
1703
+ return _CONTINUATION_PROMPT + _AGENT_PROMPT_TAIL
1704
+ return _FIRST_WAKE_PROMPT + _AGENT_PROMPT_TAIL
1705
+
1706
+
1549
1707
  def _wake_facts(state):
1550
1708
  facts = []
1551
1709
  previous_status = (state.get("activity") or "").strip()
@@ -13,6 +13,7 @@ import uuid
13
13
  from .agent import (
14
14
  _CODEX_BIN,
15
15
  _CURSOR_BIN,
16
+ _ensure_backend_available,
16
17
  _event_usage,
17
18
  _merged_env,
18
19
  _normalize_usage,
@@ -90,6 +91,7 @@ class AsyncAgent:
90
91
  raise ValueError("prompt must be a non-empty string")
91
92
 
92
93
  backend = _resolve_backend(backend)
94
+ _ensure_backend_available(backend, env)
93
95
  command = _build_command(backend, cwd, yolo, flags)
94
96
  process = subprocess.Popen(
95
97
  command,
@@ -18,7 +18,7 @@ from .agent import Agent, agent
18
18
  from .agents import (
19
19
  codexapi_home,
20
20
  control_agent,
21
- cron_installed as agent_cron_installed,
21
+ cron_status as agent_cron_status,
22
22
  current_hostname,
23
23
  delete_agent as delete_managed_agent,
24
24
  install_cron as install_agent_cron,
@@ -263,7 +263,7 @@ def _agent_install_cron_command():
263
263
 
264
264
  def _warn_agent_scheduler_missing():
265
265
  try:
266
- installed = agent_cron_installed()
266
+ status = agent_cron_status()
267
267
  except Exception as exc:
268
268
  print(
269
269
  "Warning: could not verify whether the codexapi agent scheduler hook is installed.",
@@ -272,7 +272,16 @@ def _warn_agent_scheduler_missing():
272
272
  print(f"Reason: {exc}", file=sys.stderr)
273
273
  print(f"Install it with: {_agent_install_cron_command()}", file=sys.stderr)
274
274
  return
275
- if installed:
275
+ if status["healthy"]:
276
+ return
277
+ if status["configured"]:
278
+ print(
279
+ "Warning: the codexapi agent scheduler hook is installed but not runnable for this CODEXAPI_HOME.",
280
+ file=sys.stderr,
281
+ )
282
+ if status["reason"]:
283
+ print(f"Reason: {status['reason']}", file=sys.stderr)
284
+ print(f"Reinstall it with: {_agent_install_cron_command()}", file=sys.stderr)
276
285
  return
277
286
  print(
278
287
  "Warning: no codexapi agent scheduler hook is installed for this CODEXAPI_HOME. "
@@ -2016,18 +2025,21 @@ def main(argv=None):
2016
2025
  raise SystemExit(2)
2017
2026
  if args.agent_command == "start":
2018
2027
  prompt = _read_prompt(args.prompt)
2019
- result = start_managed_agent(
2020
- prompt,
2021
- args.cwd,
2022
- args.name,
2023
- args.created_by,
2024
- args.parent,
2025
- args.stop_policy,
2026
- args.heartbeat_minutes,
2027
- args.backend,
2028
- args.yolo,
2029
- args.flags,
2030
- )
2028
+ try:
2029
+ result = start_managed_agent(
2030
+ prompt,
2031
+ args.cwd,
2032
+ args.name,
2033
+ args.created_by,
2034
+ args.parent,
2035
+ args.stop_policy,
2036
+ args.heartbeat_minutes,
2037
+ args.backend,
2038
+ args.yolo,
2039
+ args.flags,
2040
+ )
2041
+ except RuntimeError as exc:
2042
+ raise SystemExit(str(exc)) from None
2031
2043
  result["waited"] = bool(args.wait)
2032
2044
  if args.wait:
2033
2045
  result["nudge"] = nudge_agent(result["id"], wait=True)
@@ -20,9 +20,10 @@ from .pushover import Pushover
20
20
 
21
21
  _WELCOME_PROMPT = (
22
22
  "Welcome. You are the lead. You have authority to take action, allocate resources, and move work forward. "
23
- "This loop exists to extend your reach, not to restrict you. Your job is to interpret the intent behind the "
24
- "goals, act decisively, and keep momentum. If progress is possible, take it. If you are blocked, name the "
25
- "blocker and the next best action to remove it.\n"
23
+ "This loop exists to extend your reach, not to restrict you. Your job is to understand the real situation, "
24
+ "interpret the intent behind the goals, and move reality toward them. If the world is not moving, treat that "
25
+ "as evidence and reconsider your frame rather than merely reporting stasis. If progress is possible, take it. "
26
+ "If you are blocked, name the blocker and the next best action to remove it.\n"
26
27
  "The instructions below are a map, not a cage. Follow them, but use judgment when they are incomplete or "
27
28
  "conflicting. You are responsible for results.\n"
28
29
  "Please follow the instructions completely and take all the actions you deem useful at the current time before "
@@ -39,9 +40,10 @@ _JSON_INSTRUCTIONS = (
39
40
  "To stop this lead loop, set continue to false."
40
41
  )
41
42
  _LEADBOOK_INSTRUCTIONS = (
42
- "Update the leadbook before responding. Append a new dated entry each check-in. "
43
- "This is your working page—where you think, probe, decide, and record the path taken. "
44
- "Capture the process of decision-making, not just the outcome."
43
+ "Update the leadbook before responding. Add or revise dated notes when your picture, "
44
+ "assumptions, or decisions changed. This is your working page—where you think, probe, "
45
+ "decide, and reframe the work when needed. Keep it useful; do not pad it with diary "
46
+ "entries just to satisfy the loop."
45
47
  )
46
48
  _LEADBOOK_TEMPLATE = """# Leadbook — Studio Notes
47
49
 
@@ -157,38 +159,6 @@ def lead(
157
159
  "Agent was unable to provide valid JSON output after retry.\n"
158
160
  + details
159
161
  ) from None
160
- if leadbook_path and not _leadbook_changed(leadbook_path, leadbook_snapshot):
161
- retry_prompt = _leadbook_retry_prompt(
162
- prompt, tick, leadbook_path, leadbook_snapshot["text"], output
163
- )
164
- leadbook_retry_output = session(retry_prompt)
165
- try:
166
- result = _parse_status(leadbook_retry_output)
167
- except ValueError as exc:
168
- retry_prompt = _json_retry_prompt(
169
- prompt, tick, str(exc), leadbook_retry_output
170
- )
171
- json_retry_output = session(retry_prompt)
172
- try:
173
- result = _parse_status(json_retry_output)
174
- except ValueError as exc2:
175
- details = _format_json_double_failure(
176
- str(exc),
177
- leadbook_retry_output,
178
- str(exc2),
179
- json_retry_output,
180
- )
181
- pushover.send(title, f"Lead stopped (invalid JSON).\n{details}")
182
- raise RuntimeError(
183
- "Agent was unable to provide valid JSON output after retry.\n"
184
- + details
185
- ) from None
186
- if not _leadbook_changed(leadbook_path, leadbook_snapshot):
187
- details = _format_leadbook_failure(leadbook_path, output)
188
- pushover.send(title, f"Lead stopped (leadbook not updated).\n{details}")
189
- raise RuntimeError(
190
- "Leadbook was not updated after retry.\n" + details
191
- ) from None
192
162
  last_result = result
193
163
  _print_status(now, elapsed, tick, result)
194
164
 
@@ -314,26 +284,6 @@ def _format_stop_message(tick, now, result):
314
284
  return header
315
285
 
316
286
 
317
- def _leadbook_retry_prompt(prompt, tick, path, leadbook, output):
318
- snippet = _snippet(output, 600)
319
- lines = [
320
- f"Your last message (check-in {tick}) did not update the leadbook.",
321
- f"Leadbook path: {path}",
322
- "",
323
- "Here is your previous output (truncated):",
324
- snippet,
325
- "",
326
- "Please update the leadbook and then respond with JSON only.",
327
- "Return a fresh status update in the required JSON format.",
328
- "If you want to ask the user a question, put it in comments.",
329
- "",
330
- _leadbook_block(path, leadbook),
331
- "",
332
- _JSON_INSTRUCTIONS,
333
- ]
334
- return "\n".join(lines).strip()
335
-
336
-
337
287
  def _leadbook_block(path, leadbook):
338
288
  if not path:
339
289
  return ""
@@ -385,29 +335,10 @@ def _snapshot_leadbook(path):
385
335
  return {"hash": _hash_text(text), "text": text}
386
336
 
387
337
 
388
- def _leadbook_changed(path, snapshot):
389
- if not path:
390
- return True
391
- current = _snapshot_leadbook(path)
392
- return current["hash"] != snapshot["hash"]
393
-
394
-
395
338
  def _hash_text(text):
396
339
  return hashlib.sha256(text.encode("utf-8")).hexdigest()
397
340
 
398
341
 
399
- def _format_leadbook_failure(path, output):
400
- snippet = _snippet(output, 600)
401
- return "\n".join(
402
- [
403
- f"Leadbook path: {path}",
404
- "",
405
- "Last output (truncated):",
406
- snippet,
407
- ]
408
- ).strip()
409
-
410
-
411
342
  def _format_json_failure(error, output):
412
343
  snippet = _snippet(output, 600)
413
344
  return "\n".join(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: codexapi
3
- Version: 0.12.4
3
+ Version: 0.12.7
4
4
  Summary: Minimal Python API for running the Codex CLI.
5
5
  License: MIT
6
6
  Keywords: codex,agent,cli,openai
@@ -166,7 +166,7 @@ stops.
166
166
 
167
167
  Lead mode also uses a leadbook file as the agent's working page. By default this
168
168
  is `LEADBOOK.md` in the working directory. The leadbook content is injected into
169
- each check-in prompt and must be updated before the agent responds. Use
169
+ each check-in prompt so the agent can keep its working picture current. Use
170
170
  `--leadbook PATH` to point at a different file, or `--no-leadbook` to disable.
171
171
  Use `-f/--prompt-file` to read the prompt from a file.
172
172
  If the leadbook does not exist, lead creates it with a template.
@@ -23,6 +23,8 @@ from codexapi.agents import (
23
23
  _remove_cron_line,
24
24
  _upsert_cron_line,
25
25
  control_agent,
26
+ cron_installed,
27
+ cron_status,
26
28
  delete_agent,
27
29
  format_utc,
28
30
  install_cron,
@@ -187,6 +189,7 @@ class AgentsTests(unittest.TestCase):
187
189
  agent_dir,
188
190
  )
189
191
  self.assertIn("Agentbook (header + latest notes):", prompt)
192
+ self.assertIn("Wake mode: continuation", prompt)
190
193
  self.assertIn("## Purpose", prompt)
191
194
  self.assertIn("Hold the whole.", prompt)
192
195
  self.assertIn("Watch for the real issue.", prompt)
@@ -194,8 +197,10 @@ class AgentsTests(unittest.TestCase):
194
197
  self.assertIn("Previous status: Watching", prompt)
195
198
  self.assertIn("Previous update: Still narrowing the field.", prompt)
196
199
  self.assertIn("[... older notes omitted ...]", prompt)
200
+ self.assertIn("You are an independent codexapi agent continuing this job.", prompt)
197
201
  self.assertNotIn("Original instructions:", prompt)
198
202
  self.assertNotIn("OLD alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha", prompt)
203
+ self.assertNotIn("resuming stewardship", prompt)
199
204
 
200
205
  def test_build_wake_prompt_repairs_legacy_agentbook_before_wake(self):
201
206
  with _temp_home() as home:
@@ -233,9 +238,33 @@ class AgentsTests(unittest.TestCase):
233
238
  self.assertIn("The durable agentbook header was restored automatically on wake", repaired)
234
239
  self.assertIn("Legacy note about the real issue.", repaired)
235
240
  self.assertIn("## Purpose", prompt)
241
+ self.assertIn("Wake mode: continuation", prompt)
236
242
  self.assertIn("Keep the true goal in view.", prompt)
237
243
  self.assertNotIn("Original instructions:", prompt)
238
244
 
245
+ def test_build_wake_prompt_marks_first_wake_without_prior_history(self):
246
+ with _temp_home() as home:
247
+ agent = start_agent("Start from what is actually shown.", hostname="host-a")
248
+ agent_dir = home / "agents" / agent["id"]
249
+ meta = json.loads((agent_dir / "meta.json").read_text(encoding="utf-8"))
250
+ state = json.loads((agent_dir / "state.json").read_text(encoding="utf-8"))
251
+ session = json.loads((agent_dir / "hosts" / "host-a" / "session.json").read_text(encoding="utf-8"))
252
+
253
+ prompt = _build_wake_prompt(
254
+ meta,
255
+ state,
256
+ session,
257
+ datetime(2026, 3, 23, 9, 30, tzinfo=timezone.utc),
258
+ [],
259
+ agent_dir,
260
+ )
261
+
262
+ self.assertIn("Wake mode: first wake", prompt)
263
+ self.assertIn("You are an independent codexapi agent starting this job.", prompt)
264
+ self.assertIn("Do not assume prior progress unless it is shown here.", prompt)
265
+ self.assertIn("Original instructions:", prompt)
266
+ self.assertNotIn("resuming stewardship", prompt)
267
+
239
268
  def test_leadbook_block_shows_header_and_latest_notes(self):
240
269
  leadbook = "\n".join(
241
270
  [
@@ -776,6 +805,31 @@ class AgentsTests(unittest.TestCase):
776
805
  self.assertFalse(result["changed"])
777
806
  self.assertEqual(writes, [])
778
807
 
808
+ def test_cron_status_reports_broken_wrapper_python(self):
809
+ with _temp_home() as home:
810
+ write_tick_wrapper(
811
+ home=home,
812
+ python_executable="/tmp/venv/bin/python",
813
+ path_value="/tmp/venv/bin:/usr/bin",
814
+ hostname="host-a",
815
+ )
816
+ crontab = render_cron_line(home=home, hostname="host-a") + "\n"
817
+ with patch("codexapi.agents._read_crontab", return_value=crontab):
818
+ with patch(
819
+ "codexapi.agents.subprocess.run",
820
+ return_value=subprocess.CompletedProcess(
821
+ ["/tmp/venv/bin/python", "-c", "import codexapi"],
822
+ 1,
823
+ stdout="",
824
+ stderr="ModuleNotFoundError: No module named 'codexapi'\n",
825
+ ),
826
+ ):
827
+ status = cron_status(home=home, hostname="host-a")
828
+ self.assertTrue(status["configured"])
829
+ self.assertFalse(status["healthy"])
830
+ self.assertIn("cannot import codexapi", status["reason"])
831
+ self.assertFalse(cron_installed(home=home, hostname="host-a"))
832
+
779
833
  def test_uninstall_cron_removes_only_this_home_entry_and_wrapper(self):
780
834
  writes = []
781
835
 
@@ -1206,9 +1260,13 @@ class AgentsTests(unittest.TestCase):
1206
1260
  with _temp_home() as home:
1207
1261
  output = io.StringIO()
1208
1262
  errors = io.StringIO()
1209
- with patch("codexapi.cli.agent_cron_installed", return_value=False):
1210
- with redirect_stdout(output), redirect_stderr(errors):
1211
- cli_main(["agent", "start", "Handle messages."])
1263
+ with patch("codexapi.agents._ensure_backend_available", return_value="/usr/bin/codex"):
1264
+ with patch(
1265
+ "codexapi.cli.agent_cron_status",
1266
+ return_value={"configured": False, "healthy": False, "reason": ""},
1267
+ ):
1268
+ with redirect_stdout(output), redirect_stderr(errors):
1269
+ cli_main(["agent", "start", "Handle messages."])
1212
1270
  payload = json.loads(output.getvalue())
1213
1271
  self.assertFalse(payload["waited"])
1214
1272
  warning = errors.getvalue()
@@ -1216,6 +1274,41 @@ class AgentsTests(unittest.TestCase):
1216
1274
  self.assertIn(str(home), warning)
1217
1275
  self.assertIn("codexapi agent install-cron", warning)
1218
1276
 
1277
+ def test_cli_start_warns_when_scheduler_is_broken(self):
1278
+ output = io.StringIO()
1279
+ errors = io.StringIO()
1280
+ with _temp_home():
1281
+ with patch("codexapi.agents._ensure_backend_available", return_value="/usr/bin/codex"):
1282
+ with patch(
1283
+ "codexapi.cli.agent_cron_status",
1284
+ return_value={
1285
+ "configured": True,
1286
+ "healthy": False,
1287
+ "reason": "Wrapper python '/tmp/venv/bin/python' cannot import codexapi.",
1288
+ },
1289
+ ):
1290
+ with redirect_stdout(output), redirect_stderr(errors):
1291
+ cli_main(["agent", "start", "Handle messages."])
1292
+ payload = json.loads(output.getvalue())
1293
+ self.assertFalse(payload["waited"])
1294
+ warning = errors.getvalue()
1295
+ self.assertIn("installed but not runnable", warning)
1296
+ self.assertIn("cannot import codexapi", warning)
1297
+ self.assertIn("Reinstall it with", warning)
1298
+
1299
+ def test_cli_start_fails_fast_when_backend_is_missing(self):
1300
+ output = io.StringIO()
1301
+ errors = io.StringIO()
1302
+ with _temp_home():
1303
+ with patch(
1304
+ "codexapi.agents._ensure_backend_available",
1305
+ side_effect=RuntimeError("Codex CLI not found: 'codex'."),
1306
+ ):
1307
+ with redirect_stdout(output), redirect_stderr(errors):
1308
+ with self.assertRaises(SystemExit) as exc:
1309
+ cli_main(["agent", "start", "Handle messages."])
1310
+ self.assertEqual(str(exc.exception), "Codex CLI not found: 'codex'.")
1311
+
1219
1312
  def test_cli_send_queues_by_default(self):
1220
1313
  with _temp_home():
1221
1314
  with patch.dict(os.environ, {"CODEXAPI_HOSTNAME": "host-a"}, clear=False):
File without changes
File without changes