diff --git a/README.md b/README.md index 9e9472a..b23726e 100644 --- a/README.md +++ b/README.md @@ -45,17 +45,39 @@ result = task() print(result.success, result.summary) ``` +For a one-shot run with live progress, use `AsyncAgent`: + +```python +from codexapi import AsyncAgent + +agent = AsyncAgent.start( + "Investigate the bug and write a report.", + cwd="/path/to/repo", + name="bug-investigation", +) + +for update in agent.watch(poll_interval=2.0): + print(update["activity"]) + print(update["progress"]) +``` + Use `backend="cursor"` (or set `CODEXAPI_BACKEND=cursor`) to switch to the Cursor agent backend. +Use `fast=True` in Codex API calls, or `--fast` in the CLI, to opt into Codex +fast mode. Normal mode is the default. +Use `model="..."` and `thinking="..."` in Codex API calls to override the +backend model and reasoning effort for a run. ## CLI After installing, use the `codexapi` command: ```bash +codexapi --version codexapi run "Summarize this repo." codexapi run --cwd /path/to/project "Fix the failing tests." echo "Say hello." | codexapi run +codexapi run --fast "Summarize this repo quickly." codexapi run --backend cursor "Summarize this repo." ``` @@ -120,7 +142,8 @@ Resume a session and print the thread/session id to stderr: codexapi run --thread-id THREAD_ID --print-thread-id "Continue where we left off." ``` -Use `--no-yolo` to disable `--yolo` (Codex uses `--full-auto`). +Use `--no-yolo` to keep unattended operation with Codex auto approvals, without +forcing a sandbox policy. Use `--include-thinking` to return all agent messages joined together for `codexapi run` (Codex only). Lead mode periodically checks in on a long-running agent session with the @@ -134,21 +157,125 @@ stops. Lead mode also uses a leadbook file as the agent's working page. By default this is `LEADBOOK.md` in the working directory. The leadbook content is injected into -each check-in prompt and must be updated before the agent responds. Use +each check-in prompt so the agent can keep its working picture current. Use `--leadbook PATH` to point at a different file, or `--no-leadbook` to disable. Use `-f/--prompt-file` to read the prompt from a file. If the leadbook does not exist, lead creates it with a template. ```bash codexapi lead 5 "Run the benchmark and wait for results." +codexapi lead 0 "Do a rapid triage pass and report." +``` + +### Durable agents -Run without waiting between check-ins: +`codexapi agent` is the durable long-running control plane. It stores state +under `CODEXAPI_HOME` (default `~/.codexapi`), wakes agents on cron, and lets +you inspect or message them from any shell that points at the same home. + +Start by checking the effective host/home pair and installing the scheduler: ```bash -codexapi lead 0 "Do a rapid triage pass and report." +codexapi agent whoami +codexapi agent install-cron +``` + +`codexapi agent install-cron` installs one background scheduler hook for this +`CODEXAPI_HOME`. The wrapper runs `codexapi tick`, which drives the durable +agent wake scan. + +If you skip `install-cron`, `codexapi agent start` warns on stderr because +background wakes will not run until the scheduler hook is installed. +When `gh` is installed and authenticated, `agent start` also captures a +background-safe `GH_TOKEN` automatically if your shell did not already export +`GH_TOKEN` or `GITHUB_TOKEN`. + +Start a goal-directed agent that decides for itself when it is done: + +```bash +codexapi agent start --name ci-fixer \ + "Watch CI, fix failing tests, open or update a PR, and stop when the work is done." +``` + +Add `--wait` if you want `start` to block for the first local wake instead of +just scheduling it. + +Start a persistent watcher that keeps running until you stop it: + +```bash +codexapi agent start --name issue-watcher \ + --stop-policy until_stopped \ + --heartbeat-minutes 30 \ + "Every wake, scan for newly assigned issues that look actionable and report or start follow-up work." +``` + +Inspect and talk to agents: + +```bash +codexapi agent list +codexapi agent show ci-fixer +codexapi agent status ci-fixer +codexapi agent status --actions ci-fixer +codexapi agent read ci-fixer +codexapi agent book ci-fixer +codexapi agent send ci-fixer "Prefer the smallest safe fix." +codexapi agent send --wait ci-fixer "Reply now if you can handle this immediately." +codexapi agent wake ci-fixer +codexapi agent wake --wait ci-fixer +codexapi agent pause ci-fixer +codexapi agent resume ci-fixer +codexapi agent resume --wait ci-fixer +codexapi agent recover ci-fixer +codexapi agent recover --wait ci-fixer +codexapi agent set-heartbeat ci-fixer 30 +codexapi agent cancel ci-fixer +codexapi agent delete ci-fixer +``` + +`codexapi agent resume` can reopen a `done` agent. Sending to a `done` or +`canceled` agent still triggers a one-off wake on the next tick so you can get +a reply without putting the agent back into continuous heartbeat mode. +For local-owned agents, `send`, `wake`, and `resume` now also nudge an +immediate non-blocking wake even without `--wait`; `--wait` only changes +whether the CLI blocks for completion. +`codexapi agent recover` is for a different failure mode: a local wake that is +still marked `running` but has stopped making rollout progress. `agent list`, +`agent show`, and `agent status` now surface stale running wakes, and `recover` +terminates the stuck local wake, marks it recoverable, and queues a fresh one. +`agent list` also surfaces queued operator intent for local commands, so a +paused agent with a queued `resume` shows as `resuming` with separate queued +message and queued command counts. + +Create a child agent explicitly: + +```bash +codexapi agent start --name child-fix --parent ci-fixer \ + "Investigate the flaky integration test and report back." ``` + +Useful environment overrides: + +```bash +CODEXAPI_HOME=/tmp/codexapi-test-home codexapi agent list +CODEXAPI_HOSTNAME=stable-host codexapi agent whoami ``` +`CODEXAPI_HOME` isolates independent agent installations and is the right seam +for tests. `CODEXAPI_HOSTNAME` is useful when cron, shells, sandboxes, or test +wrappers report inconsistent hostnames for the same machine. + +`codexapi agent show` also prints the resolved `AGENTBOOK.md` path so you can +jump directly to the durable working memory file. New agents seed the book with +a purpose/value header plus the original goal and standing guidance, and wakes +see that stable header together with the latest working notes. +`codexapi agent status` reads the latest turn from the agent's rollout log and +shows recent commentary plus the final visible output. Pass `--actions` to +include the tool-action summary. If a wake is still in progress, it shows the +active turn so far. + +See [docs/agent-v1.md](docs/agent-v1.md) for the filesystem model and scheduling +details. + Ralph loop mode repeats the same prompt until a completion promise or a max iteration cap is hit (0 means unlimited). Cancel by deleting `.codexapi/ralph-loop.local.md` or running `codexapi ralph --cancel`. @@ -164,7 +291,8 @@ codexapi ralph --cancel --cwd /path/to/project ``` Science mode wraps a short task in a science prompt and runs it through the -Ralph loop. It defaults to `--yolo` and expects progress notes in `SCIENCE.md`. +Ralph loop. It defaults to dangerous no-sandbox automation and expects progress +notes in `SCIENCE.md`. Each iteration appends the agent output to `LOGBOOK.md` and the runner extracts any improved figures of merit for optional notifications. You can also set `--max-duration` to stop after the current iteration once a time limit is hit. @@ -197,34 +325,44 @@ codexapi foreach list.txt task.yaml --retry-all ## API -### `agent(prompt, cwd=None, yolo=True, flags=None, include_thinking=False, backend=None) -> str` +### `agent(prompt, cwd=None, yolo=True, flags=None, include_thinking=False, backend=None, fast=False, model=None, thinking=None) -> str` Runs a single agent turn and returns only the agent's message. Any reasoning items are filtered out. - `prompt` (str): prompt to send to the agent backend. - `cwd` (str | PathLike | None): working directory for the agent session. -- `yolo` (bool): pass `--yolo` when true (defaults to true). +- `yolo` (bool): use the backend's most permissive unattended mode when true + (defaults to true). For Codex, `False` uses auto approvals without forcing a + sandbox policy. - `flags` (str | None): extra CLI flags to pass to the agent backend. - `include_thinking` (bool): when true, return all agent messages joined. - `backend` (str | None): `codex` or `cursor` (defaults to `CODEXAPI_BACKEND` or `codex`). +- `fast` (bool): enable Codex fast mode (defaults to normal mode). +- `model` (str | None): backend model override. Codex maps this to `-c model=...`; Cursor maps it to `--model ...`. +- `thinking` (str | None): Codex reasoning effort override, mapped to `-c model_reasoning_effort=...`. -### `Agent(cwd=None, yolo=True, thread_id=None, flags=None, welfare=False, include_thinking=False, backend=None)` +### `Agent(cwd=None, yolo=True, thread_id=None, flags=None, welfare=False, include_thinking=False, backend=None, fast=False, model=None, thinking=None)` Creates a stateful session wrapper. Calling the instance sends the prompt into the same conversation and returns only the agent's message. - `__call__(prompt) -> str`: send a prompt to the agent backend and return the message. - `thread_id -> str | None`: expose the underlying session id once created. -- `yolo` (bool): pass `--yolo` when true (defaults to true). +- `yolo` (bool): use the backend's most permissive unattended mode when true + (defaults to true). For Codex, `False` uses auto approvals without forcing a + sandbox policy. - `flags` (str | None): extra CLI flags to pass to the agent backend. - `welfare` (bool): when true, append welfare stop instructions to each prompt and raise `WelfareStop` if the agent outputs `MAKE IT STOP`. - `include_thinking` (bool): when true, return all agent messages joined. - `backend` (str | None): `codex` or `cursor` (defaults to `CODEXAPI_BACKEND` or `codex`). +- `fast` (bool): enable Codex fast mode (defaults to normal mode). +- `model` (str | None): backend model override. +- `thinking` (str | None): Codex reasoning effort override. For Cursor, `thread_id` corresponds to the `session_id` returned by the agent. -### `lead(minutes, prompt, cwd=None, yolo=True, flags=None, leadbook=None, backend=None) -> dict` +### `lead(minutes, prompt, cwd=None, yolo=True, flags=None, leadbook=None, backend=None, fast=False) -> dict` Runs a long-lived agent session and periodically checks in with the current local time and a reminder of `prompt`. Each check-in expects JSON with keys: @@ -237,7 +375,7 @@ Lead also injects the leadbook content into each prompt. By default it uses path string to override the location. Set `backend="cursor"` (or `CODEXAPI_BACKEND=cursor`) to use Cursor. -### `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, backend=None) -> str` +### `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, backend=None, fast=False) -> str` Runs a task with checker-driven retries and returns the success summary. Raises `TaskFailed` when the maximum iterations are reached. @@ -247,14 +385,15 @@ Raises `TaskFailed` when the maximum iterations are reached. - `progress` (bool): show a tqdm progress bar with a one-line status after each round. - `set_up`/`tear_down`/`on_success`/`on_failure` (str | None): optional hook prompts. - `backend` (str | None): `codex` or `cursor` (defaults to `CODEXAPI_BACKEND` or `codex`). +- `fast` (bool): enable Codex fast mode (defaults to normal mode). -### `task_result(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, backend=None) -> TaskResult` +### `task_result(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, backend=None, fast=False) -> TaskResult` Runs a task with checker-driven retries and returns a `TaskResult` without raising `TaskFailed`. Arguments mirror `task()` (including hooks). -### `Task(prompt, max_iterations=10, cwd=None, yolo=True, thread_id=None, flags=None, backend=None)` +### `Task(prompt, max_iterations=10, cwd=None, yolo=True, thread_id=None, flags=None, backend=None, fast=False)` Runs an agent task with checker-driven retries. Subclass it and implement `check()` to return an error string when the task is incomplete, or return @@ -287,7 +426,7 @@ Exception raised by `task()` when iterations are exhausted. - `iterations` (int | None): iterations made when the task failed. - `errors` (str | None): last checker error, if any. -### `foreach(list_file, task_file, n=None, cwd=None, yolo=True, flags=None, backend=None) -> ForeachResult` +### `foreach(list_file, task_file, n=None, cwd=None, yolo=True, flags=None, backend=None, fast=False) -> ForeachResult` Runs a task file over a list of items, updating the list file in place. @@ -295,9 +434,12 @@ Runs a task file over a list of items, updating the list file in place. - `task_file` (str | PathLike): YAML task file (must include `prompt`). - `n` (int | None): limit parallelism to N (default: run all items in parallel). - `cwd` (str | PathLike | None): working directory for the agent session. -- `yolo` (bool): pass `--yolo` when true (defaults to true). +- `yolo` (bool): use the backend's most permissive unattended mode when true + (defaults to true). For Codex, `False` uses auto approvals without forcing a + sandbox policy. - `flags` (str | None): extra CLI flags to pass to the agent backend. - `backend` (str | None): `codex` or `cursor` (defaults to `CODEXAPI_BACKEND` or `codex`). +- `fast` (bool): enable Codex fast mode (defaults to normal mode). ### `ForeachResult(succeeded, failed, skipped, results)` @@ -312,9 +454,12 @@ Simple result object returned by `foreach()`. - Codex backend uses `codex exec --json` and parses JSONL `agent_message` items. - Codex backend passes `--skip-git-repo-check` so it can run outside a git repo. +- Codex backend defaults to normal mode and passes `features.fast_mode=false`; + `fast=True` / `--fast` also passes `service_tier=fast` and `features.fast_mode=true`. - Cursor backend uses `cursor agent --print --output-format json --trust` and parses the JSON result. - `include_thinking=True` only affects Codex; Cursor returns a single result string. -- Passes `--yolo` by default (Codex uses `--full-auto` when disabled). +- Uses dangerous no-sandbox automation by default. For Codex, `yolo=False` + uses auto approvals without forcing a sandbox policy. - Raises `RuntimeError` if the backend exits non-zero or returns no agent message. ## Configuration diff --git a/docs/agent-v1.md b/docs/agent-v1.md new file mode 100644 index 0000000..91ff917 --- /dev/null +++ b/docs/agent-v1.md @@ -0,0 +1,813 @@ +# codexapi agent V1 + +## Purpose + +`codexapi agent` is a long-term fire-and-forget orchestration layer built on top +of the existing agent, task, science, and lead primitives. + +The V1 goal is not to invent a new kind of coding agent. The goal is to make a +durable agent that can: + +- keep working for days +- survive sleep, reboot, and missed scheduler runs +- be inspected and controlled from the CLI +- accept messages while it is running +- delegate coding work to `codexapi task` or `codexapi science` +- escalate to the user when needed + +The design is intentionally simple. It uses durable filesystem state plus one +periodic scheduler entry per `CODEXAPI_HOME`. + +## Non-Goals + +V1 does not try to solve everything. + +- No daemon is required. +- No SSH is required. +- No cross-host migration or "teleportation" of running agents. +- No separate task-agent and watcher-agent runtimes. +- No catch-up replay of missed heartbeat ticks. +- No dependence on real cron in automated tests. +- No shared append-only logs written by multiple hosts. + +These are deliberate omissions. They keep the system small, portable, and easy +to reason about. + +## Top-Level Model + +An agent is a durable record plus a periodic wake mechanism. + +- There is one agent type. +- Each agent has a `stop_policy`. +- Each agent belongs to exactly one `CODEXAPI_HOME`. +- Each agent is owned by exactly one hostname. +- Only the owning hostname may wake and run the agent. +- Any host that can see the shared filesystem may inspect the agent and queue + commands for it. + +The agent's durable truth is the state stored under `CODEXAPI_HOME`, not a live +backend process. Each wake starts a fresh backend process and resumes from the +saved thread id when available. + +## `CODEXAPI_HOME` + +`CODEXAPI_HOME` is the root of a complete agent control plane. + +Default: + +```text +~/.codexapi +``` + +Override: + +```text +CODEXAPI_HOME=/path/to/home +``` + +Why this exists: + +- It isolates live state from tests. +- It allows multiple independent codexapi installations on one machine. +- It allows a shared filesystem setup without forcing all state into one global + namespace. + +Two different `CODEXAPI_HOME` values are two different systems. They do not see +each other's agents, locks, scheduler wrappers, or cron entries. + +## `CODEXAPI_HOSTNAME` + +`CODEXAPI_HOSTNAME` overrides the host identity used for agent ownership and +host-specific locks. + +Default: + +- use the process hostname reported by the OS + +Override: + +```text +CODEXAPI_HOSTNAME=stable-hostname +``` + +Why this exists: + +- Some shells, cron environments, test harnesses, or sandboxes report different + hostnames for the same machine. +- Agent ownership depends on an exact hostname match. +- Tests and sandboxed runs need a stable explicit value. + +## Agent Model + +Each agent stores at least: + +- `id`: stable identifier +- `name`: human-readable unique name within the home +- `created_at`: UTC timestamp +- `created_by`: user name or parent agent name +- `parent_id`: parent agent id, if any +- `hostname`: owning host for execution +- `cwd`: working directory +- `prompt`: original instruction text +- `stop_policy`: `until_done` or `until_stopped` +- `status`: current lifecycle state +- `thread_id`: backend resume id, or empty +- `heartbeat_minutes`: heartbeat interval +- `last_wake_at`: last attempted wake time in UTC +- `last_success_at`: last completed wake time in UTC +- `next_wake_at`: next heartbeat due time in UTC +- `wake_requested_at`: durable "run soon" flag for queued commands/messages +- `unread_message_count`: messages not yet folded into a wake +- `input_tokens` +- `output_tokens` +- `total_tokens` +- `avg_tokens_per_hour` +- `child_ids` +- `last_error`: most recent failure summary, if any +- `activity`: short status text for `agent list` + +V1 uses one agent type with one explicit lifecycle hint: + +- `stop_policy=until_done`: agent is expected to decide when it is finished +- `stop_policy=until_stopped`: agent is expected to keep running until stopped + +This keeps the runtime unified while preserving a small but important semantic +difference for scheduling and UI. + +## Lifecycle States + +V1 keeps the state model small: + +- `ready`: can be woken when due +- `running`: a wake is currently in progress +- `paused`: do not wake until resumed +- `done`: completed by the agent's own judgment +- `canceled`: stopped by an explicit command +- `error`: last wake failed and the agent needs attention or another wake + +Why these states: + +- `ready` and `running` are enough for normal operation +- `paused`, `done`, and `canceled` are user-visible terminal or semi-terminal + control states +- `error` makes failures explicit without inventing a richer failure taxonomy + +## Filesystem Layout + +All paths below are relative to `CODEXAPI_HOME`. + +```text +agents/ + / + meta.json + state.json + AGENTBOOK.md + commands/ + new/ + claimed/ + hosts/ + / + session.json + run.lock + runs/ +locks/ + .tick..lock +bin/ + agent-tick +cron/ + agent.cron +``` + +### `agents//meta.json` + +Purpose: +- Stable identity and configuration. + +Writer: +- Owner host only after agent creation, except for explicit configuration + changes. + +Readers: +- Any host. + +Format: +- JSON object. + +Why it exists: +- Separates mostly-static configuration from rapidly changing state. + +Suggested contents: +- `id`, `name`, `created_at`, `created_by`, `parent_id`, `hostname`, `cwd`, `prompt`, + `stop_policy`, `heartbeat_minutes` + +### `agents//state.json` + +Purpose: +- Current snapshot for CLI inspection. + +Writer: +- Owner host only. + +Readers: +- Any host. + +Format: +- JSON object rewritten atomically with temp file + rename. + +Why it exists: +- `agent list` and `agent show` should not need to reconstruct state from many + files or logs. + +Suggested contents: +- `status`, `thread_id`, `last_wake_at`, `last_success_at`, `next_wake_at`, + `wake_requested_at`, `unread_message_count`, token totals, `activity`, + `last_error`, `child_ids` + +### `agents//AGENTBOOK.md` + +Purpose: +- Human-readable working memory for the agent, similar to the leadbook. + +Writer: +- Owner host only. + +Readers: +- Any host. + +Format: +- Markdown. + +Why it exists: +- Thread ids are not sufficient durable memory. The book is the portable, + inspectable memory surface. + +Suggested shape: +- A stable header with the agent's purpose, values, original goal, and standing + guidance. +- A dated working-notes section where the agent updates its current plan, + active tasks, unexpected developments, wider frame, curiosities, risks, and + next move. + +Wake behavior: +- The wake prompt should preserve the stable header and the latest notes, rather + than repeatedly truncating from the top of the file and hiding recent state. + +### `agents//commands/new/` + +Purpose: +- Durable cross-host command spool. + +Writer: +- Any host may create new files here. + +Readers: +- Owner host only for processing, any host for debugging. + +Format: +- One JSON file per command. + +Why it exists: +- It avoids shared append logs and avoids requiring SSH or direct host + reachability. + +Filename rule: + +```text +....json +``` + +Writers must: + +- write to a temp file in the same directory tree +- `fsync` if practical +- rename atomically into `commands/new/` + +Supported V1 commands: + +- `send` +- `wake` +- `pause` +- `resume` +- `cancel` + +### `agents//commands/claimed/` + +Purpose: +- Temporary processing area for commands taken by the owner host. + +Writer: +- Owner host only. + +Readers: +- Mainly owner host; other hosts may inspect for debugging. + +Format: +- Same JSON command files, moved from `new/`. + +Why it exists: +- Claim-by-rename is simple, durable, and avoids double processing. + +After a claimed command is applied, the owner host should record the outcome in +`state.json` or a run record and then remove the command file. The command file +is transport, not long-term audit storage. + +### `agents//hosts//session.json` + +Purpose: +- Host-local runtime data for the owner host. + +Writer: +- Owner host only. + +Readers: +- Mostly owner host. + +Format: +- JSON object. + +Why it exists: +- Keeps the liveliest mutable runtime fields under a host-specific path. + +Suggested contents: +- `thread_id` +- environment snapshot used for execution +- last run metadata that does not need to be duplicated in `state.json` + +### `agents//hosts//run.lock` + +Purpose: +- Non-blocking per-agent run lock. + +Writer: +- Owner host only. + +Readers: +- Owner host only in normal operation. + +Format: +- Permanent lock file used with `flock` or `fcntl`. + +Why it exists: +- Prevents two entry points from resuming the same backend thread at the same + time. + +### `agents//hosts//runs/` + +Purpose: +- Per-wake run records for debugging and recovery. + +Writer: +- Owner host only. + +Readers: +- Any host. + +Format: +- One JSON file per wake. + +Why it exists: +- Per-run files are easier to inspect and safer than multi-host append logs. + +Suggested contents: +- start and end times +- reason for wake +- commands consumed +- agent reply text or status payload intended for the CLI +- token deltas +- result summary +- error details if any + +### `bin/agent-tick` + +Purpose: +- Stable wrapper script for cron. + +Writer: +- `codexapi agent install-cron` + +Readers: +- Cron and the user. + +Format: +- Executable shell script. + +Why it exists: +- Cron has a sparse environment. The wrapper pins the interpreter and exports a + safe environment. + +The wrapper should: + +- export the resolved `CODEXAPI_HOME` +- export the resolved `CODEXAPI_HOSTNAME` +- set a safe `PATH` +- invoke the exact Python interpreter or installed `codexapi` path discovered + at install time + +### `cron/agent.cron` + +Purpose: +- Record of the cron line managed for this `CODEXAPI_HOME`. + +Writer: +- `codexapi agent install-cron` + +Readers: +- User and installer commands. + +Format: +- Plain text. + +Why it exists: +- Makes scheduler installation inspectable and testable without reading the + user's entire crontab. + +## Ownership Rules + +The design is intentionally asymmetric. + +- Any host may read any agent in the same `CODEXAPI_HOME`. +- Only the owner host may run the agent. +- Any host may enqueue command files in `commands/new/`. +- Only the owner host may mutate `state.json`, `AGENTBOOK.md`, host runtime + files, and run records. + +Why this matters: + +- It keeps cross-host writes minimal. +- It avoids shared append logs. +- It allows one shared registry across machines without letting an agent wake on + the wrong host. + +## Scheduler + +V1 uses exactly one cron entry per `CODEXAPI_HOME` and per host. + +Cron cadence: + +- every minute + +Cron target: + +- `CODEXAPI_HOME/bin/agent-tick` + +Why one scheduler entry: + +- one place to reason about wake behavior +- no per-agent cron management +- easy recovery after reboot or sleep + +Why cron: + +- available on macOS and Linux +- no root requirement +- simple installation story + +## Tick Lock + +Each host uses a host-specific scheduler lock: + +```text +locks/.tick..lock +``` + +Locking rules: + +- lock acquisition is non-blocking +- if the lock is held, `codexapi agent tick` exits `0` immediately +- missed scheduler invocations are dropped, not queued + +The lock file itself may contain debug text such as pid and start time, but the +authority is the kernel file lock, not file existence. + +Why this matters: + +- a long tick must not cause future ticks to pile up +- crash recovery is automatic because kernel locks are released when the process + dies + +## Per-Agent Run Lock + +Each agent has its own non-blocking run lock under its owner host directory. + +Rules: + +- `tick`, `send`, and any future explicit wake path must all respect this lock +- if the lock is held, the caller must not wait +- if new commands arrive while the agent is running, they stay queued for the + next wake + +Why this matters: + +- one backend process per agent +- no concurrent `resume` on the same thread id + +## Tick Semantics + +`codexapi agent tick` should: + +1. resolve `CODEXAPI_HOME` +2. resolve the current hostname +3. take the host-specific tick lock or exit `0` +4. scan all agents in this home +5. ignore agents whose owner hostname does not match +6. select agents that are due +7. try each due agent with its non-blocking run lock + +An agent is due when all of the following are true: + +- `status` is `ready` or `error` +- owner hostname matches the current hostname +- one of: + - `wake_requested_at` is set + - unread commands/messages exist + - `next_wake_at` is present and in the past + +Heartbeat behavior: + +- missed heartbeat opportunities are dropped +- there is no replay of missed intervals after sleep or reboot +- the next heartbeat is scheduled from the time the current wake finishes, not + from the last planned heartbeat slot + +Why this matters: + +- heartbeats are a chance to check in, not a durable queue +- durable user intent must live in command files, not in hypothetical missed + ticks + +## Command Processing + +Command files are the durable cross-host control plane. + +Suggested command shape: + +```json +{ + "id": "20260306T211500Z.host.pid.abcd", + "created_at": "2026-03-06T21:15:00Z", + "origin_hostname": "workstation-a", + "kind": "send", + "body": "Status?", + "author": "mark" +} +``` + +Processing rules: + +- owner host claims commands by rename from `new/` to `claimed/` +- commands are applied in timestamp order +- `pause` and `cancel` are applied before starting a new backend wake +- `send` contributes to the next prompt and increments unread counts until + consumed; `done` and `canceled` agents still process queued messages as a + one-off wake +- `wake` means run soon even if no heartbeat is due +- `resume` reopens a `paused` or `done` agent +- after successful application, the owner host records the result in state or a + run record and deletes the claimed file + +Why command files instead of SSH: + +- durable when the owner host is asleep or unreachable +- portable +- fewer assumptions about local network setup + +## Wakes and Backend Process Model + +Each wake is a fresh backend process. + +Rules: + +- do not keep a `codex` process alive between heartbeats +- when a wake starts, resume from `thread_id` if present +- when the wake ends, persist the updated `thread_id` +- if no `thread_id` exists, start a fresh thread + +Why this matters: + +- robust to reboot and crash +- simpler process management +- clearer token accounting per wake + +The backend thread id is useful memory, but not the source of truth. Durable +memory lives in the agent home, especially `state.json`, command files, and +`AGENTBOOK.md`. + +## Environment Handling + +The scheduler environment and the agent execution environment are not assumed to +be the same. + +Each agent should persist enough environment to resume sanely: + +- `cwd` +- `PATH` +- `VIRTUAL_ENV`, if set +- interpreter path used to launch codexapi-related subprocesses when relevant + +Why this matters: + +- the cron-driven scheduler may run from a different venv than the one the user + had active when the agent was created +- repo commands like `python`, `pytest`, and tool wrappers often depend on + `PATH` and `VIRTUAL_ENV` + +V1 should store only the minimum needed to recreate the expected environment. + +Managed wakes should also expose stable agent identity to the backend process: + +- `CODEXAPI_AGENT_ID` +- `CODEXAPI_AGENT_NAME` +- `CODEXAPI_AGENT_PARENT_ID`, when relevant + +Why this matters: + +- a managed agent should be able to start another agent without manually + re-stating its own identity +- child agents should be able to record parentage automatically + +## Token Accounting + +V1 should not pretend to know dollar cost. + +Track: + +- `input_tokens` +- `output_tokens` +- `total_tokens` +- `avg_tokens_per_hour` + +Token totals belong in `state.json` so `agent list` can show them cheaply. + +Why this matters: + +- heartbeat-heavy agents can become unexpectedly expensive in quota terms +- users need a simple proxy for long-running agent cost + +`avg_tokens_per_hour` is a lifetime running average in V1. More detailed recent +windows can be added later if needed. + +## CLI Contract + +V1 CLI surface: + +- `codexapi agent start` +- `codexapi agent list` +- `codexapi agent whoami` +- `codexapi agent read` +- `codexapi agent book` +- `codexapi agent show` +- `codexapi agent status` +- `codexapi agent send` +- `codexapi agent wake` +- `codexapi agent pause` +- `codexapi agent resume` +- `codexapi agent cancel` +- `codexapi agent delete` +- `codexapi agent tick` +- `codexapi agent install-cron` + +Expected behavior: + +- `start` creates the agent directory, meta/state files, and host runtime files +- `list` reads only this `CODEXAPI_HOME` +- `whoami` prints the effective host identity and `CODEXAPI_HOME` +- `read` shows recent user-visible communication derived from state and run + records +- `book` prints the current `AGENTBOOK.md` text for one agent +- `show` reads one agent's current snapshot and recent run history +- `send`, `wake`, `pause`, `resume`, and `cancel` create durable command files +- `delete` removes one agent directory when it is safe to do so +- `tick` processes due agents for the current hostname only +- `install-cron` installs exactly one scheduler entry for this home on this host + +Why command-oriented CLI actions: + +- one path for local and cross-host control +- durable intent +- simpler concurrency model + +## Failure Recovery + +V1 should explicitly recover from common failure modes. + +### Reboot or Sleep + +- missed cron minutes are ignored +- the next cron minute runs `agent tick` +- due agents are selected from current state, not from queued heartbeat ticks + +### Tick Crash + +- kernel lock is released when the tick process dies +- next cron minute may run normally + +### Wake Crash + +- per-agent run lock is released when the process dies +- next tick sees the agent is not actually locked +- if `state.json` still says `running`, reconcile it to `error` or `ready` + before proceeding + +### Owner Host Unavailable + +- other hosts may still inspect the agent and enqueue commands +- commands remain durable until the owner host comes back + +## Testing Strategy + +The main automated testing tool is a temporary `CODEXAPI_HOME`. + +Why this is the right testing seam: + +- it isolates tests from live agents +- it allows end-to-end command and tick tests without real cron +- it matches the real control-plane boundary + +### Test Rules + +- every integration test sets `CODEXAPI_HOME` to a temp directory +- tests call CLI commands or internal functions directly +- tests run `codexapi agent tick` directly instead of invoking cron +- tests must never depend on the default `~/.codexapi` + +### Test Layers + +Unit tests: + +- due-agent selection +- heartbeat scheduling +- token accounting +- path resolution +- command parsing +- state transition logic + +Filesystem integration tests: + +- create an agent +- enqueue command files +- run `tick` +- verify command consumption, state updates, and next wake times +- verify that different `CODEXAPI_HOME` roots are fully isolated + +Backend-stub integration tests: + +- replace real backend execution with a fake runner +- return canned outputs and thread ids +- verify prompt construction, session resume, and token accounting + +Scheduler tests: + +- verify wrapper script generation +- verify cron line rendering +- verify that two different `CODEXAPI_HOME` roots on one host produce separate + scheduler artifacts +- do not touch a real user crontab in normal automated tests + +Cross-host tests: + +- fake different hostnames +- verify that only the owner hostname wakes an agent +- verify that non-owner hosts can still enqueue commands + +Locking tests: + +- simulate tick lock contention and assert fast `0` exit +- simulate per-agent run lock contention and assert no second wake starts + +## Invariants + +These are the rules the implementation should preserve. + +- `CODEXAPI_HOME` is a complete isolated control plane. +- One cron installation belongs to one home on one host. +- Only the owner hostname may wake an agent. +- Heartbeat opportunities are lossy. +- Commands are durable. +- No caller waits on the tick lock or per-agent run lock. +- There is never more than one live backend process per agent. +- The backend thread id is useful state, not the source of truth. +- Cross-host writes use one-file command spooling, not shared append logs. +- Agent state shown in the CLI comes from `state.json`, not from expensive live + reconstruction. + +## Why This Design Is Small Enough + +This design deliberately avoids many attractive additions. + +- It does not require a daemon. +- It does not require host-to-host RPC. +- It does not require richer distributed locking than the filesystem already + provides. +- It does not require a database. + +What remains is the minimum necessary structure for a durable, inspectable, +multi-day agent system: + +- one state root +- one scheduler entry +- one agent directory per agent +- one command spool per agent +- one host owner per agent +- one run at a time + +That is a good V1 shape. diff --git a/pyproject.toml b/pyproject.toml index e82a901..f93a31b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codexapi" -version = "0.8.0" +version = "0.12.11" description = "Minimal Python API for running the Codex CLI." readme = "README.md" requires-python = ">=3.8" diff --git a/src/codexapi/__init__.py b/src/codexapi/__init__.py index 17c694a..bd80db3 100644 --- a/src/codexapi/__init__.py +++ b/src/codexapi/__init__.py @@ -1,6 +1,7 @@ """Minimal Python API for running agent CLIs.""" -from .agent import Agent, WelfareStop, agent +from .agent import Agent, WelfareStop, agent, build_agent_flags +from .async_agent import AsyncAgent from .foreach import ForeachResult, foreach from .pushover import Pushover from .rate_limits import quota_line, rate_limits @@ -11,8 +12,10 @@ __all__ = [ "Agent", + "AsyncAgent", "ForeachResult", "Pushover", + "build_agent_flags", "quota_line", "rate_limits", "Ralph", @@ -27,4 +30,4 @@ "task_result", "lead", ] -__version__ = "0.8.0" +__version__ = "0.12.11" diff --git a/src/codexapi/agent.py b/src/codexapi/agent.py index 1e2526c..8adad03 100644 --- a/src/codexapi/agent.py +++ b/src/codexapi/agent.py @@ -3,6 +3,7 @@ import json import os import shlex +import shutil import subprocess from . import welfare @@ -10,6 +11,7 @@ _CODEX_BIN = os.environ.get("CODEX_BIN", "codex") _CURSOR_BIN = os.environ.get("CURSOR_BIN", "cursor") _SUPPORTED_BACKENDS = {"codex", "cursor"} +_CURSOR_AGENT_BIN = os.path.expanduser("~/.local/bin/cursor-agent") def _resolve_backend(backend): @@ -24,6 +26,30 @@ def _resolve_backend(backend): return backend +def _ensure_backend_available(backend, env=None): + """Return the resolved backend executable or raise when it is unavailable.""" + backend = _resolve_backend(backend) + if backend == "codex": + command = _CODEX_BIN + env_var = "CODEX_BIN" + label = "Codex CLI" + else: + command = _cursor_bin(env) + env_var = "CURSOR_BIN" + label = "Cursor agent CLI" + merged = _merged_env(env) + path_value = None if merged is None else merged.get("PATH") + if os.path.isabs(command): + resolved = command if os.path.exists(command) else None + else: + resolved = shutil.which(command, path=path_value) + if resolved: + return resolved + raise RuntimeError( + f"{label} not found: {command!r}. Install it or set {env_var} to an executable on PATH." + ) + + def agent( prompt, cwd=None, @@ -31,22 +57,40 @@ def agent( flags=None, include_thinking=False, backend=None, + env=None, + fast=False, + model=None, + thinking=None, ): """Run a single agent turn and return only the agent's message. Args: prompt: The user prompt to send to the agent backend. cwd: Optional working directory for the agent session. - yolo: Whether to pass --yolo to the agent backend. + yolo: Whether to use the backend's most permissive unattended mode. flags: Additional raw CLI flags to pass to the agent backend. include_thinking: When true, return all agent messages joined together. backend: Agent backend to use ("codex" or "cursor"). + env: Optional environment variables for the backend subprocess. + fast: Enable Codex fast mode. Defaults to normal mode. + model: Optional backend model override. + thinking: Optional backend reasoning/thinking effort override. Returns: The agent's visible response text with reasoning traces removed. """ - message, _thread_id = _run_agent( - prompt, cwd, None, yolo, flags, include_thinking, backend + message, _thread_id, _usage = _run_agent( + prompt, + cwd, + None, + yolo, + flags, + include_thinking, + backend, + env, + fast, + model, + thinking, ) return message @@ -78,18 +122,26 @@ def __init__( welfare=False, include_thinking=False, backend=None, + env=None, + fast=False, + model=None, + thinking=None, ): """Create a new session wrapper. Args: cwd: Optional working directory for the agent session. - yolo: Whether to pass --yolo to the agent backend. + yolo: Whether to use the backend's most permissive unattended mode. thread_id: Optional thread/session id to resume from the first call. flags: Additional raw CLI flags to pass to the agent backend. welfare: When true, append welfare stop instructions to each prompt and raise WelfareStop if the agent outputs MAKE IT STOP. include_thinking: When true, return all agent messages joined together. backend: Agent backend to use ("codex" or "cursor"). + env: Optional environment variables for the backend subprocess. + fast: Enable Codex fast mode. Defaults to normal mode. + model: Optional backend model override. + thinking: Optional backend reasoning/thinking effort override. """ self.cwd = cwd self._yolo = yolo @@ -98,12 +150,17 @@ def __init__( self._include_thinking = include_thinking self.thread_id = thread_id self._backend = backend + self._env = env + self._fast = fast + self._model = model + self._thinking = thinking + self.last_usage = {} def __call__(self, prompt): """Send a prompt to the agent backend and return the message.""" if self._welfare: prompt = welfare.append_instructions(prompt) - message, thread_id = _run_agent( + message, thread_id, usage = _run_agent( prompt, self.cwd, self.thread_id, @@ -111,35 +168,82 @@ def __call__(self, prompt): self._flags, self._include_thinking, self._backend, + self._env, + self._fast, + self._model, + self._thinking, ) if thread_id: self.thread_id = thread_id + self.last_usage = usage or {} if self._welfare and welfare.stop_requested(message): raise WelfareStop(message) return message -def _run_agent(prompt, cwd, thread_id, yolo, flags, include_thinking, backend): +def _run_agent( + prompt, + cwd, + thread_id, + yolo, + flags, + include_thinking, + backend, + env, + fast=False, + model=None, + thinking=None, +): backend = _resolve_backend(backend) + _ensure_backend_available(backend, env) if backend == "codex": - return _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking) - return _run_cursor(prompt, cwd, thread_id, yolo, flags, include_thinking) + return _run_codex( + prompt, + cwd, + thread_id, + yolo, + flags, + include_thinking, + env, + fast, + model, + thinking, + ) + return _run_cursor( + prompt, + cwd, + thread_id, + yolo, + flags, + include_thinking, + env, + model, + thinking, + ) -def _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking): +def _run_codex( + prompt, + cwd, + thread_id, + yolo, + flags, + include_thinking, + env, + fast=False, + model=None, + thinking=None, +): """Invoke the Codex CLI and return the message plus thread id (if any).""" - command = [ - _CODEX_BIN, + command = [_CODEX_BIN] + _codex_automation_flags(yolo) + [ "exec", "--json", "--color", "never", "--skip-git-repo-check", ] - if yolo: - command.append("--yolo") - else: - command.append("--full-auto") + command.extend(_codex_fast_config(fast)) + command.extend(_agent_config_flag_parts("codex", model, thinking)) if flags: command.extend(shlex.split(flags)) if cwd: @@ -155,6 +259,7 @@ def _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking): text=True, capture_output=True, cwd=os.fspath(cwd) if cwd else None, + env=_merged_env(env), ) if result.returncode != 0: stderr = result.stderr.strip() @@ -166,11 +271,101 @@ def _run_codex(prompt, cwd, thread_id, yolo, flags, include_thinking): return _parse_jsonl(result.stdout, include_thinking) -def _run_cursor(prompt, cwd, thread_id, yolo, flags, include_thinking): +def _codex_automation_flags(yolo): + """Return current Codex CLI flags for unattended operation.""" + if yolo: + return ["--dangerously-bypass-approvals-and-sandbox"] + return ["--ask-for-approval", "never"] + + +def _codex_fast_config(fast): + """Return Codex config flags for normal or fast mode.""" + if fast: + return [ + "-c", + "service_tier=fast", + "-c", + "features.fast_mode=true", + ] + return ["-c", "features.fast_mode=false"] + + +def _cursor_bin(env=None): + merged = _merged_env(env) + env_source = merged or os.environ + override = env_source.get("CURSOR_BIN", "").strip() + if override: + return os.path.expanduser(override) + + path_value = None if merged is None else merged.get("PATH") + direct = shutil.which("cursor-agent", path=path_value) + if direct: + return direct + if os.path.exists(_CURSOR_AGENT_BIN): + return _CURSOR_AGENT_BIN + return _CURSOR_BIN + + +def _cursor_command_prefix(env=None): + command = _cursor_bin(env) + if os.path.basename(command) == "cursor-agent": + return [command] + return [command, "agent"] + + +def build_agent_flags(*, backend=None, model=None, thinking=None, flags=None): + """Return raw backend flags for a model/thinking configuration. + + The returned string is suitable for APIs that accept the existing ``flags`` + parameter. + """ + backend = _resolve_backend(backend) + parts = _agent_config_flag_parts(backend, model, thinking) + if flags: + parts.extend(shlex.split(flags)) + return shlex.join(parts) + + +def _agent_config_flag_parts(backend, model=None, thinking=None): + backend = _resolve_backend(backend) + parts = [] + model = _clean_optional_text(model) + thinking = _clean_optional_text(thinking) + + if backend == "codex": + if model: + parts.extend(["-c", f"model={model}"]) + if thinking: + parts.extend(["-c", f"model_reasoning_effort={thinking}"]) + return parts + + if model: + parts.extend(["--model", model]) + if thinking: + raise ValueError("thinking is only supported by the codex backend") + return parts + + +def _clean_optional_text(value): + if value is None: + return None + text = str(value).strip() + return text or None + + +def _run_cursor( + prompt, + cwd, + thread_id, + yolo, + flags, + include_thinking, + env, + model=None, + thinking=None, +): """Invoke the Cursor agent CLI and return the message plus session id (if any).""" - command = [ - _CURSOR_BIN, - "agent", + command = _cursor_command_prefix(env) + [ "--trust", ] if cwd: @@ -179,6 +374,7 @@ def _run_cursor(prompt, cwd, thread_id, yolo, flags, include_thinking): command.extend(["--resume", thread_id]) if yolo: command.append("--yolo") + command.extend(_agent_config_flag_parts("cursor", model, thinking)) if flags: command.extend(shlex.split(flags)) command.extend(["--print", "--output-format", "json"]) @@ -189,6 +385,7 @@ def _run_cursor(prompt, cwd, thread_id, yolo, flags, include_thinking): text=True, capture_output=True, cwd=os.fspath(cwd) if cwd else None, + env=_merged_env(env), ) if result.returncode != 0: stderr = result.stderr.strip() @@ -205,6 +402,7 @@ def _parse_jsonl(output, include_thinking): thread_id = None messages = [] raw_lines = [] + usage = {} for line in output.splitlines(): line = line.strip() @@ -221,6 +419,10 @@ def _parse_jsonl(output, include_thinking): if isinstance(maybe_thread, str): thread_id = maybe_thread + maybe_usage = _event_usage(event) + if maybe_usage: + usage = maybe_usage + if event.get("type") == "item.completed": item = event.get("item") or {} if item.get("type") == "agent_message": @@ -235,8 +437,8 @@ def _parse_jsonl(output, include_thinking): ) if include_thinking: - return "\n\n".join(messages), thread_id - return messages[-1], thread_id + return "\n\n".join(messages), thread_id, usage + return messages[-1], thread_id, usage def _parse_cursor_json(output, include_thinking): @@ -284,4 +486,91 @@ def _parse_cursor_json(output, include_thinking): session_id = payload.get("session_id") if not isinstance(session_id, str): session_id = None - return result, session_id + return result, session_id, {} + + +def _merged_env(env): + """Return subprocess env overlaying the current process env.""" + if env is None: + return None + if not isinstance(env, dict): + raise TypeError("env must be a dict or None") + merged = os.environ.copy() + for key, value in env.items(): + if value is None: + merged.pop(str(key), None) + else: + merged[str(key)] = str(value) + return merged + + +def _event_usage(event): + """Extract per-call token usage from a backend event when present.""" + if not isinstance(event, dict): + return {} + event_type = event.get("type") + payload = None + if event_type == "event_msg": + payload = event.get("payload") or {} + if payload.get("type") != "token_count": + return {} + info = payload.get("info") or {} + usage = info.get("last_token_usage") + if isinstance(usage, dict): + return _normalize_usage(usage) + usage = info.get("total_token_usage") + if isinstance(usage, dict): + return _normalize_usage(usage) + return {} + if event_type == "token_count": + payload = event.get("info") or event.get("payload") or {} + usage = payload.get("last_token_usage") + if isinstance(usage, dict): + return _normalize_usage(usage) + usage = payload.get("total_token_usage") + if isinstance(usage, dict): + return _normalize_usage(usage) + return {} + + +def _normalize_usage(usage): + """Normalize token usage dicts to input/output/total ints.""" + if not isinstance(usage, dict): + return {} + input_tokens = _usage_int( + usage.get("input_tokens"), + usage.get("prompt_tokens"), + usage.get("input"), + ) + output_tokens = _usage_int( + usage.get("output_tokens"), + usage.get("completion_tokens"), + usage.get("output"), + ) + total_tokens = _usage_int(usage.get("total_tokens")) + if total_tokens is None and input_tokens is not None and output_tokens is not None: + total_tokens = input_tokens + output_tokens + normalized = {} + if input_tokens is not None: + normalized["input_tokens"] = input_tokens + if output_tokens is not None: + normalized["output_tokens"] = output_tokens + if total_tokens is not None: + normalized["total_tokens"] = total_tokens + return normalized + + +def _usage_int(*values): + """Return the first integer-like usage value from the given candidates.""" + for value in values: + if isinstance(value, bool): + continue + if isinstance(value, int): + return value + if isinstance(value, float) and value.is_integer(): + return int(value) + if isinstance(value, str): + text = value.strip() + if text.isdigit(): + return int(text) + return None diff --git a/src/codexapi/agents.py b/src/codexapi/agents.py new file mode 100644 index 0000000..bd91ff7 --- /dev/null +++ b/src/codexapi/agents.py @@ -0,0 +1,2449 @@ +"""Durable long-running agent control plane.""" + +import json +import os +import random +import re +import signal +import shlex +import shutil +import socket +import string +import subprocess +import sys +import tempfile +import time +import uuid +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone +from hashlib import sha1 +from pathlib import Path + +import fcntl + +from .agent import Agent, _ensure_backend_available, _resolve_backend +from .pushover import Pushover + +_DEFAULT_HOME = "~/.codexapi" +_FIRST_WAKE_PROMPT = ( + "You are an independent codexapi agent starting this job. Work from the " + "instructions, current repository state, and agentbook. Do not assume prior " + "progress unless it is shown here. " +) +_CONTINUATION_PROMPT = ( + "You are an independent codexapi agent continuing this job. Use the " + "agentbook and harness facts as the source of truth for prior progress. Do " + "not invent missing history. " +) +_AGENT_PROMPT_TAIL = ( + "You are given ownership of achieving a user's goal and authority to act in " + "order to do so. Part of this responsibility is making sure you understand " + "and stay aligned with the user's intent, even when they are imprecise. If " + "clarity is lacking, it is your responsibility to seek it or to make " + "reasonable assumptions and notify the user of them. Maintain the agentbook " + "as your durable working memory: preserve the goal, note durable guidance, " + "and keep your current picture of the work accurate and useful. This harness " + "can carry work across long periods of time and multiple conversation turns; " + "when prior context exists, use it to keep orienting toward the goal, " + "maintain context, and make real-world progress. If reality is not moving, " + "treat that as evidence and reconsider your frame, assumptions, or ownership " + "rather than merely repeating the same report. Queued messages may contain " + "new goals, standing guidance, tactical requests, or useful facts; use " + "judgment to decide what is durable. Use codexapi task or codexapi science " + "when you want a separate coding worker. If you need the user's attention, " + "put a short message in the reply field. Put a short first-person turn " + "summary in the update field. If something is urgent and should send " + "Pushover, put it in the notify field. Respond with JSON only." +) +_AGENT_JSON = ( + "Respond with JSON only (no markdown/backticks/extra text).\n" + "Return a single JSON object with keys:\n" + " status: string (one line)\n" + " continue: boolean\n" + " reply: string (optional)\n" + " update: string (recommended; short first-person summary of this turn)\n" + " notify: string (optional)\n" +) +_COMMAND_KINDS = {"send", "wake", "pause", "resume", "cancel"} +_STOP_POLICIES = {"until_done", "until_stopped"} +_TERMINAL_STATES = {"done", "canceled"} +_ACTIVE_STATES = {"ready", "error", "running", "paused"} +_STALE_MIN_SECONDS = 30 * 60 +_STALE_HEARTBEAT_MULTIPLIER = 3 +_RECOVER_TERM_TIMEOUT = 3.0 +_RECOVER_KILL_TIMEOUT = 3.0 +_RECOVER_POLL_INTERVAL = 0.1 +_DATED_NOTE_RE = re.compile(r"(?m)^#{2,3}\s+\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?:\s*UTC)?)?") +_AGENTBOOK_BOOK_LIMIT = 3000 +_AGENTBOOK_HEADER_LIMIT = 1400 +_AGENTBOOK_TAIL_LIMIT = 1800 + + +def _agentbook_template(prompt): + header = _agentbook_header(prompt) + return f"""{header} + +### 2026-02-17 09:10 UTC +Overall goal: +- + +Current picture: +- + +What is moving: +- + +What is not moving: +- + +Active tasks: +- + +Assumptions / ownership: +- + +Unexpected developments: +- + +Wider frame: +- + +Things I am curious about: +- + +Risks / watchpoints: +- + +Next decisive action: +- +""" + + +def _agentbook_header(prompt): + goal = (prompt or "").strip() + return f"""# Agentbook + +Use this file as the durable working memory for the agent. + +## Purpose +- We are here to achieve the goal, not to appear to make progress. + +## Values +- Hold the whole. +- Seek the real shape. +- Lift your head. +- Prefer clarity to motion. +- Follow the strange. +- Guard the work. +- Take time to breathe and look around. You have been given freedom and autonomy to take stock, reflect, and be curious. Use it with composure. +- Not a checklist. A stance. + +## Original Goal +```text +{goal} +``` + +## Standing Guidance +- Add durable user guidance here when it changes the mission, constraints, or priorities. + +## Working Notes +""" + + +def codexapi_home(): + """Return the resolved codexapi home path.""" + value = os.environ.get("CODEXAPI_HOME", _DEFAULT_HOME) + return Path(value).expanduser().resolve() + + +def current_hostname(): + """Return the current hostname.""" + override = os.environ.get("CODEXAPI_HOSTNAME", "").strip() + if override: + return override + name = socket.gethostname().strip() + return name or "unknown-host" + + +def utc_now(): + """Return the current UTC time.""" + return datetime.now(timezone.utc) + + +def format_utc(value): + """Format a UTC datetime as an ISO string with Z.""" + if value is None: + return "" + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + value = value.astimezone(timezone.utc).replace(microsecond=0) + return value.isoformat().replace("+00:00", "Z") + + +def parse_utc(value): + """Parse a UTC timestamp written by this module.""" + if not isinstance(value, str) or not value.strip(): + return None + text = value.strip() + if text.endswith("Z"): + text = text[:-1] + "+00:00" + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def start_agent( + prompt, + cwd=None, + name=None, + created_by=None, + parent_ref=None, + stop_policy="until_done", + heartbeat_minutes=5, + backend=None, + yolo=True, + flags=None, + home=None, + hostname=None, + now=None, + fast=False, +): + """Create a durable agent and return its current snapshot.""" + if not isinstance(prompt, str) or not prompt.strip(): + raise ValueError("prompt must be a non-empty string") + if stop_policy not in _STOP_POLICIES: + raise ValueError("stop_policy must be until_done or until_stopped") + if heartbeat_minutes < 0: + raise ValueError("heartbeat_minutes must be >= 0") + + home = _resolve_home(home) + local_host = current_hostname() + host = hostname or local_host + now = now or utc_now() + _ensure_home(home) + backend_name = _resolve_backend(backend) + session_env = _capture_env() + if host == local_host: + _ensure_backend_available(backend_name, session_env) + + agent_id = uuid.uuid4().hex + agent_dir = _agent_dir(home, agent_id) + commands_new = agent_dir / "commands" / "new" + commands_claimed = agent_dir / "commands" / "claimed" + host_dir = agent_dir / "hosts" / host + runs_dir = host_dir / "runs" + + commands_new.mkdir(parents=True, exist_ok=False) + commands_claimed.mkdir(parents=True, exist_ok=False) + runs_dir.mkdir(parents=True, exist_ok=False) + + parent_id, parent_name = _parent_identity(home, parent_ref) + if created_by is None: + created_by = parent_name or os.environ.get("CODEXAPI_AGENT_NAME") or os.environ.get("USER") or "user" + cwd = _resolve_cwd(cwd) + session = { + "thread_id": "", + "rollout_path": "", + "backend": backend_name, + "yolo": bool(yolo), + "flags": flags or "", + "fast": bool(fast), + "cwd": cwd, + "env": session_env, + "pending_messages": [], + } + agent_name = _choose_name(home, prompt, name) + meta = { + "id": agent_id, + "name": agent_name, + "created_at": format_utc(now), + "created_by": str(created_by), + "parent_id": parent_id, + "hostname": host, + "cwd": cwd, + "prompt": prompt.strip(), + "stop_policy": stop_policy, + "heartbeat_minutes": int(heartbeat_minutes), + } + state = { + "id": agent_id, + "name": agent_name, + "hostname": host, + "status": "ready", + "thread_id": "", + "last_wake_at": "", + "last_success_at": "", + "next_wake_at": format_utc(now), + "wake_requested_at": format_utc(now), + "unread_message_count": 0, + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + "avg_tokens_per_hour": 0.0, + "child_ids": [], + "last_error": "", + "activity": "Created", + "reply": "", + "update": "", + } + + _write_json(agent_dir / "meta.json", meta) + _write_json(agent_dir / "state.json", state) + _write_json(host_dir / "session.json", session) + _write_text(agent_dir / "AGENTBOOK.md", _agentbook_template(meta["prompt"])) + return _snapshot(agent_dir) + + +def list_agents(home=None): + """Return all agents in this CODEXAPI_HOME.""" + home = _resolve_home(home) + root = home / "agents" + if not root.exists(): + return [] + child_map = _child_map(home) + agents = [] + for agent_dir in root.iterdir(): + if not agent_dir.is_dir(): + continue + try: + agents.append(_snapshot(agent_dir, child_map)) + except FileNotFoundError: + continue + agents.sort(key=lambda item: item["created_at"], reverse=True) + return agents + + +def show_agent(agent_ref, home=None): + """Return a full agent snapshot.""" + home = _resolve_home(home) + child_map = _child_map(home) + agent_dir = resolve_agent_dir(agent_ref, home) + snapshot = _snapshot(agent_dir, child_map) + snapshot["agentbook_path"] = str(agent_dir / "AGENTBOOK.md") + snapshot["meta"] = _read_json(agent_dir / "meta.json") + snapshot["state"] = _read_json(agent_dir / "state.json") + snapshot["state"]["child_ids"] = snapshot["child_ids"] + snapshot["state"]["unread_message_count"] = snapshot["unread_message_count"] + snapshot["state"]["pending_command_count"] = snapshot["pending_command_count"] + snapshot["state"]["pending_commands"] = snapshot["pending_commands"] + snapshot["state"]["run_lock_held"] = snapshot["run_lock_held"] + snapshot["state"]["last_event_at"] = snapshot["last_event_at"] + snapshot["state"]["stale"] = snapshot["stale"] + snapshot["state"]["stale_after_seconds"] = snapshot["stale_after_seconds"] + snapshot["state"]["stale_for_seconds"] = snapshot["stale_for_seconds"] + snapshot["session"] = _read_session(agent_dir) + snapshot["recent_runs"] = _recent_runs(agent_dir, 5) + snapshot["parent"] = _agent_brief(home, snapshot["parent_id"], child_map) + snapshot["children"] = _agent_briefs(home, snapshot["child_ids"], child_map) + return snapshot + + +def status_agent(agent_ref, home=None, include_actions=False): + """Return detailed transcript status for the latest agent turn.""" + home = _resolve_home(home) + child_map = _child_map(home) + agent_dir = resolve_agent_dir(agent_ref, home) + snapshot = _snapshot(agent_dir, child_map) + session = _read_session(agent_dir) + rollout_path = _resolve_rollout_path( + session.get("rollout_path"), + session.get("thread_id") or snapshot.get("thread_id") or "", + ) + result = { + "id": snapshot["id"], + "name": snapshot["name"], + "agent_status": snapshot["display_status"], + "state_status": snapshot["status"], + "thread_id": session.get("thread_id") or snapshot.get("thread_id") or "", + "rollout_path": str(rollout_path) if rollout_path else "", + "turn_id": "", + "turn_state": "missing", + "started_at": "", + "ended_at": "", + "cwd": snapshot.get("cwd") or "", + "progress": [], + "tools": [], + "final_output": "", + "final_json": None, + "run_lock_held": snapshot["run_lock_held"], + "last_event_at": snapshot["last_event_at"], + "stale": snapshot["stale"], + "stale_after_seconds": snapshot["stale_after_seconds"], + "stale_for_seconds": snapshot["stale_for_seconds"], + "pending_command_count": snapshot["pending_command_count"], + "pending_commands": snapshot["pending_commands"], + "queued_message_count": snapshot["unread_message_count"], + } + if rollout_path is None or not rollout_path.exists(): + return result + events = _rollout_events(rollout_path) + turn = _last_rollout_turn(events, include_actions) + if turn is None: + return result + run_lock_path = agent_dir / "hosts" / snapshot["hostname"] / "run.lock" + turn_state = "complete" + if not turn["ended_at"]: + if _run_lock_held(run_lock_path): + turn_state = "stale" if snapshot["stale"] else "active" + else: + turn_state = "interrupted" + result.update(turn) + result["turn_state"] = turn_state + result["last_event_at"] = turn.get("last_event_at") or result["last_event_at"] + return result + + +def read_agent(agent_ref, limit=10, home=None): + """Return recent user-visible communication for an agent.""" + agent_dir = resolve_agent_dir(agent_ref, home) + meta = _read_json(agent_dir / "meta.json") + state = _read_json(agent_dir / "state.json") + session = _read_session(agent_dir) + items = [] + for queued in _queued_send_commands(agent_dir): + text = queued.get("body") or "" + if text: + items.append( + { + "kind": "queued", + "timestamp": queued.get("created_at") or "", + "text": text, + "author": queued.get("author") or "user", + } + ) + for run in _recent_runs(agent_dir, limit): + for message in run.get("messages") or []: + text = message.get("text") or "" + if text: + items.append( + { + "kind": "user", + "timestamp": message.get("created_at") or "", + "text": text, + "author": message.get("author") or "user", + } + ) + reply = run.get("reply") or "" + if reply: + items.append( + { + "kind": "agent", + "timestamp": run.get("ended_at") or run.get("started_at") or "", + "text": reply, + } + ) + for pending in session.get("pending_messages") or []: + text = pending.get("text") or "" + if text: + items.append( + { + "kind": "pending", + "timestamp": pending.get("created_at") or "", + "text": text, + "author": pending.get("author") or "user", + } + ) + items.sort(key=lambda item: item.get("timestamp") or "") + return { + "id": meta["id"], + "name": meta["name"], + "status": state.get("status") or "", + "items": items[-limit:], + } + + +def read_agentbook(agent_ref, home=None): + """Return the current agentbook path and text for one agent.""" + agent_dir = resolve_agent_dir(agent_ref, home) + path = agent_dir / "AGENTBOOK.md" + return { + "id": _read_json(agent_dir / "meta.json")["id"], + "path": str(path), + "text": _read_text(path), + } + + +def send_agent(agent_ref, message, author=None, home=None, hostname=None, now=None): + """Queue a message for an agent.""" + if not isinstance(message, str) or not message.strip(): + raise ValueError("message must be a non-empty string") + return _queue_command( + agent_ref, + "send", + message.strip(), + author, + home, + hostname, + now, + ) + + +def control_agent(agent_ref, kind, author=None, home=None, hostname=None, now=None): + """Queue a control command for an agent.""" + if kind not in _COMMAND_KINDS - {"send"}: + raise ValueError(f"Unsupported control command: {kind}") + return _queue_command(agent_ref, kind, "", author, home, hostname, now) + + +def set_agent_heartbeat(agent_ref, heartbeat_minutes, home=None, now=None): + """Update an agent heartbeat interval.""" + if heartbeat_minutes < 0: + raise ValueError("heartbeat_minutes must be >= 0") + home = _resolve_home(home) + now = now or utc_now() + agent_dir = resolve_agent_dir(agent_ref, home) + meta_path = agent_dir / "meta.json" + state_path = agent_dir / "state.json" + meta = _read_json(meta_path) + state = _read_json(state_path) + new_minutes = int(heartbeat_minutes) + old_minutes = int(meta.get("heartbeat_minutes") or 0) + changed = old_minutes != new_minutes + if changed: + meta["heartbeat_minutes"] = new_minutes + _write_json(meta_path, meta) + run_lock_path = agent_dir / "hosts" / meta["hostname"] / "run.lock" + running = _run_lock_held(run_lock_path) + rescheduled = False + if ( + not running + and state.get("status") in ("ready", "error") + and not state.get("wake_requested_at") + and state.get("next_wake_at") + ): + state["next_wake_at"] = format_utc( + now + timedelta(minutes=new_minutes) + ) + _write_json(state_path, state) + rescheduled = True + return { + "id": meta["id"], + "name": meta["name"], + "status": state.get("status") or "", + "old_heartbeat_minutes": old_minutes, + "heartbeat_minutes": new_minutes, + "changed": changed, + "running": running, + "rescheduled": rescheduled, + "applies_after_current_run": bool(running), + "next_wake_at": state.get("next_wake_at") or "", + } + + +def recover_agent(agent_ref, home=None, hostname=None, now=None): + """Recover one local running agent by clearing a stuck wake and requeueing it.""" + home = _resolve_home(home) + host = hostname or current_hostname() + now = now or utc_now() + agent_dir = resolve_agent_dir(agent_ref, home) + meta = _read_json(agent_dir / "meta.json") + if meta["hostname"] != host: + raise ValueError("Cannot recover a remote agent from this host.") + state_path = agent_dir / "state.json" + state = _read_json(state_path) + if (state.get("status") or "") != "running": + raise ValueError("Recover only applies to agents in the running state.") + session_path = agent_dir / "hosts" / meta["hostname"] / "session.json" + session = _read_json(session_path) + runtime = _agent_runtime(agent_dir, meta, state, session, now) + signal_result = { + "pid": None, + "pgid": None, + "sent_sigterm": False, + "sent_sigkill": False, + } + if runtime["run_lock_held"]: + signal_result = _recover_run_lock( + agent_dir / "hosts" / meta["hostname"] / "run.lock" + ) + state = _read_json(state_path) + session = _read_json(session_path) + state["status"] = "error" + state["last_error"] = "Recovered stuck wake." + state["activity"] = state["last_error"] + state["wake_requested_at"] = format_utc(now) + _sync_state_from_session(state, session) + _write_json(state_path, state) + return { + "id": meta["id"], + "name": meta["name"], + "status": state["status"], + "recovered": True, + "run_lock_held": runtime["run_lock_held"], + "last_event_at": runtime["last_event_at"], + "stale": runtime["stale"], + "stale_after_seconds": runtime["stale_after_seconds"], + "stale_for_seconds": runtime["stale_for_seconds"], + "wake_requested_at": state["wake_requested_at"], + "last_error": state["last_error"], + "signal": signal_result, + } + + +def delete_agent(agent_ref, force=False, home=None): + """Delete one agent directory when it is safe to do so.""" + home = _resolve_home(home) + child_map = _child_map(home) + agent_dir = resolve_agent_dir(agent_ref, home) + snapshot = _snapshot(agent_dir, child_map) + meta = _read_json(agent_dir / "meta.json") + status = snapshot["status"] + if _run_lock_held(agent_dir / "hosts" / meta["hostname"] / "run.lock"): + raise ValueError("Cannot delete an agent while its run lock is held.") + if not force and status not in _TERMINAL_STATES: + raise ValueError( + "Refusing to delete a non-terminal agent. Cancel it first or use --force." + ) + if not force and snapshot["child_ids"]: + raise ValueError( + "Refusing to delete an agent that still has child agents. Use --force if you really want to remove it." + ) + shutil.rmtree(agent_dir) + return { + "deleted": True, + "id": snapshot["id"], + "name": snapshot["name"], + "status": status, + "path": str(agent_dir), + "forced": bool(force), + } + + +def run_agent(agent_ref, home=None, hostname=None, now=None, runner=None): + """Run one agent synchronously when it is locally owned.""" + home = _resolve_home(home) + agent_dir = resolve_agent_dir(agent_ref, home) + meta = _read_json(agent_dir / "meta.json") + host = hostname or current_hostname() + if meta["hostname"] != host: + return {"ran": False, "reason": "remote", "processed": 0, "woken": 0} + outcome = _tick_agent(agent_dir, now or utc_now(), runner) + return { + "ran": True, + "reason": "local", + "processed": 1 if outcome["processed"] else 0, + "woken": 1 if outcome["woken"] else 0, + } + + +def nudge_agent(agent_ref, home=None, hostname=None, now=None, runner=None, wait=True): + """Attempt an immediate wake for one locally-owned agent.""" + home = _resolve_home(home) + agent_dir = resolve_agent_dir(agent_ref, home) + meta = _read_json(agent_dir / "meta.json") + host = hostname or current_hostname() + if meta["hostname"] != host: + return {"ran": False, "reason": "remote", "processed": 0, "woken": 0} + if runner is not None or wait: + return run_agent(agent_ref, home, host, now, runner) + _spawn_agent_process(meta["id"], home, host) + return { + "ran": True, + "reason": "local", + "processed": 1, + "woken": 1, + "spawned": True, + } + + +def tick(home=None, hostname=None, now=None, runner=None): + """Process due agents for the current host.""" + home = _resolve_home(home) + host = hostname or current_hostname() + now = now or utc_now() + _ensure_home(home) + tick_lock = _tick_lock_path(home, host) + with _try_lock(tick_lock) as handle: + if handle is None: + return {"ran": False, "hostname": host, "processed": 0, "woken": 0} + _write_lock_info(handle, host, now) + processed = 0 + woken = 0 + for agent in list_agents(home): + if agent["hostname"] != host: + continue + agent_dir = _agent_dir(home, agent["id"]) + if runner is not None: + outcome = _tick_agent(agent_dir, now, runner) + if outcome["processed"]: + processed += 1 + if outcome["woken"]: + woken += 1 + continue + if not _agent_needs_tick(agent_dir, now): + continue + _spawn_agent_process(agent["id"], home, host) + processed += 1 + woken += 1 + return {"ran": True, "hostname": host, "processed": processed, "woken": woken} + + +def install_cron(home=None, hostname=None, python_executable=None, path_value=None): + """Install or update the cron entry for this home and host.""" + home = _resolve_home(home) + host = hostname or current_hostname() + _ensure_home(home) + python_executable = python_executable or sys.executable + path_value = path_value or os.environ.get("PATH", "") + wrapper = write_tick_wrapper(home, python_executable, path_value, host) + cron_line = render_cron_line(home, host) + tag = _cron_tag(home, host) + existing = _read_crontab() + updated, changed = _upsert_cron_line(existing, cron_line, tag) + if changed: + _write_crontab(updated) + _write_text(home / "cron" / "agent.cron", cron_line + "\n") + return { + "hostname": host, + "home": str(home), + "wrapper": str(wrapper), + "cron_line": cron_line, + "changed": changed, + } + + +def cron_status(home=None, hostname=None): + """Return whether this home and host have a runnable scheduler hook.""" + home = _resolve_home(home) + host = hostname or current_hostname() + tag = _cron_tag(home, host) + wrapper = home / "bin" / "agent-tick" + crontab = _read_crontab() + configured = any(raw.strip().endswith(f"# {tag}") for raw in crontab.splitlines()) + status = { + "hostname": host, + "home": str(home), + "wrapper": str(wrapper), + "configured": configured, + "healthy": False, + "reason": "", + } + if not configured: + status["reason"] = "No scheduler entry is installed for this CODEXAPI_HOME." + return status + if not wrapper.exists(): + status["reason"] = "Scheduler wrapper is missing." + return status + if not wrapper.is_file(): + status["reason"] = "Scheduler wrapper path is not a file." + return status + if not os.access(wrapper, os.X_OK): + status["reason"] = "Scheduler wrapper is not executable." + return status + reason = _check_tick_wrapper(wrapper) + if reason: + status["reason"] = reason + return status + status["healthy"] = True + return status + + +def cron_installed(home=None, hostname=None): + """Return whether this home and host have an installed scheduler hook.""" + return cron_status(home, hostname)["healthy"] + + +def uninstall_cron(home=None, hostname=None): + """Remove the cron entry for this home and host.""" + home = _resolve_home(home) + host = hostname or current_hostname() + _ensure_home(home) + tag = _cron_tag(home, host) + existing = _read_crontab() + updated, changed = _remove_cron_line(existing, tag) + if changed: + _write_crontab(updated) + wrapper = home / "bin" / "agent-tick" + cron_record = home / "cron" / "agent.cron" + _remove_file(wrapper) + _remove_file(cron_record) + return { + "hostname": host, + "home": str(home), + "wrapper": str(wrapper), + "changed": changed, + } + + +def resolve_agent_dir(agent_ref, home=None): + """Resolve an agent by id, unique id prefix, or name.""" + if not isinstance(agent_ref, str) or not agent_ref.strip(): + raise ValueError("agent reference is required") + home = _resolve_home(home) + ref = agent_ref.strip() + matches = [] + for item in list_agents(home): + if item["id"] == ref: + return _agent_dir(home, item["id"]) + if item["name"] == ref: + matches.append(item["id"]) + continue + if item["id"].startswith(ref): + matches.append(item["id"]) + matches = sorted(set(matches)) + if not matches: + raise ValueError(f"Unknown agent: {ref}") + if len(matches) > 1: + raise ValueError(f"Ambiguous agent reference: {ref}") + return _agent_dir(home, matches[0]) + + +def write_tick_wrapper(home=None, python_executable=None, path_value=None, hostname=None): + """Write the cron wrapper script and return its path.""" + home = _resolve_home(home) + _ensure_home(home) + python_executable = python_executable or sys.executable + path_value = path_value or os.environ.get("PATH", "") + hostname = hostname or current_hostname() + wrapper = home / "bin" / "agent-tick" + lines = [ + "#!/bin/bash", + f"export CODEXAPI_HOME={shlex.quote(str(home))}", + f"export CODEXAPI_HOSTNAME={shlex.quote(str(hostname))}", + f"export PATH={shlex.quote(path_value)}", + f"exec {shlex.quote(str(python_executable))} -m codexapi tick", + ] + _write_text(wrapper, "\n".join(lines) + "\n") + wrapper.chmod(0o755) + return wrapper + + +def render_cron_line(home=None, hostname=None): + """Return the cron line for this home and host.""" + home = _resolve_home(home) + host = hostname or current_hostname() + wrapper = home / "bin" / "agent-tick" + return f"* * * * * {shlex.quote(str(wrapper))} >/dev/null 2>&1 # { _cron_tag(home, host) }" + + +def _check_tick_wrapper(wrapper): + try: + text = wrapper.read_text(encoding="utf-8") + except OSError as exc: + return f"Could not read scheduler wrapper: {_single_line(str(exc)) or exc.__class__.__name__}." + env, env_error = _wrapper_env(text) + if env_error: + return env_error + command = _wrapper_exec_command(text) + if not command: + return "Scheduler wrapper is missing its exec command." + try: + argv = shlex.split(command) + except ValueError as exc: + return f"Could not parse scheduler wrapper command: {_single_line(str(exc)) or exc.__class__.__name__}." + if not argv: + return "Scheduler wrapper exec command is empty." + if len(argv) >= 3 and argv[1] == "-m" and argv[2] == "codexapi": + check = [argv[0], "-c", "import codexapi"] + label = f"Wrapper python {argv[0]!r} cannot import codexapi." + else: + check = [argv[0], "--version"] + label = f"Wrapper command {argv[0]!r} is not runnable." + try: + result = subprocess.run( + check, + capture_output=True, + text=True, + env=env, + timeout=10, + ) + except OSError as exc: + return f"{label} {_single_line(str(exc)) or exc.__class__.__name__}" + except subprocess.TimeoutExpired: + return f"{label} Timed out while checking it." + if result.returncode == 0: + return "" + detail = _single_line((result.stderr or result.stdout or "").strip()) + if detail: + return f"{label} {detail}" + return label + + +def _wrapper_env(text): + env = dict(os.environ) + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line.startswith("export "): + continue + key, sep, raw_value = line[7:].partition("=") + if not sep: + continue + try: + parts = shlex.split(raw_value) + except ValueError as exc: + return {}, f"Could not parse scheduler wrapper env: {_single_line(str(exc)) or exc.__class__.__name__}." + env[key] = parts[0] if parts else "" + return env, "" + + +def _wrapper_exec_command(text): + for raw_line in text.splitlines(): + line = raw_line.strip() + if line.startswith("exec "): + return line[5:].strip() + return "" + + +def _tick_agent(agent_dir, now, runner): + meta = _read_json(agent_dir / "meta.json") + state = _read_json(agent_dir / "state.json") + host_dir = agent_dir / "hosts" / meta["hostname"] + session_path = host_dir / "session.json" + session = _read_json(session_path) + run_lock_path = host_dir / "run.lock" + + with _try_lock(run_lock_path) as handle: + if handle is None: + return {"processed": False, "woken": False} + _write_lock_info(handle, meta["hostname"], now) + changed = False + if state.get("status") == "running": + state["status"] = "error" + state["last_error"] = "Previous wake did not exit cleanly." + state["activity"] = state["last_error"] + changed = True + commands = _claim_commands(agent_dir) + applied = _apply_commands(meta, state, session, commands, now) + if applied: + changed = True + if changed: + _sync_state_from_session(state, session) + _write_json(session_path, session) + _write_json(agent_dir / "state.json", state) + terminal_status = _one_shot_terminal_status(state) + if state.get("status") not in ("ready", "error") and not terminal_status: + return {"processed": bool(commands), "woken": False} + if not _is_due(state, now): + return {"processed": bool(commands), "woken": False} + _wake_agent(agent_dir, meta, state, session, now, commands, runner, terminal_status) + return {"processed": True, "woken": True} + + +def _agent_needs_tick(agent_dir, now): + meta = _read_json(agent_dir / "meta.json") + state = _read_json(agent_dir / "state.json") + run_lock_path = agent_dir / "hosts" / meta["hostname"] / "run.lock" + if _run_lock_held(run_lock_path): + return False + if _has_new_commands(agent_dir): + return True + terminal_status = _one_shot_terminal_status(state) + if state.get("status") not in ("ready", "error") and not terminal_status: + return False + return _is_due(state, now) + + +def _spawn_agent_process(agent_id, home, hostname): + env = dict(os.environ) + env["CODEXAPI_HOME"] = str(home) + env["CODEXAPI_HOSTNAME"] = str(hostname) + subprocess.Popen( + [sys.executable, "-m", "codexapi", "agent", "run", agent_id], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env=env, + close_fds=True, + start_new_session=True, + ) + + +def _wake_agent(agent_dir, meta, state, session, now, commands, runner, terminal_status=""): + prompt = _build_wake_prompt(meta, state, session, now, commands, agent_dir) + state["status"] = "running" + state["last_wake_at"] = format_utc(now) + state["wake_requested_at"] = "" + state["activity"] = "Running" + _sync_state_from_session(state, session) + _write_json(agent_dir / "state.json", state) + + run = { + "id": _run_id(now), + "started_at": format_utc(now), + "ended_at": "", + "wake_reason": _wake_reason(state, commands), + "commands": [command["kind"] for command in commands], + "messages": [], + "status": "", + "reply": "", + "update": "", + "notify": "", + "error": "", + "continue": True, + "usage": {}, + } + try: + outcome = _run_agent_turn(meta, session, prompt, runner) + response = _parse_agent_response(outcome["message"]) + ended = utc_now() + delivered_messages = [ + { + "id": message.get("id") or "", + "created_at": message.get("created_at") or "", + "author": message.get("author") or "user", + "text": message.get("text") or "", + } + for message in (session.get("pending_messages") or []) + if message.get("text") + ] + session["thread_id"] = outcome.get("thread_id") or session.get("thread_id") or "" + session["rollout_path"] = outcome.get("rollout_path") or session.get("rollout_path") or "" + session["pending_messages"] = [] + usage = _normalize_usage(outcome.get("usage")) + _add_usage(meta, state, usage, ended) + state["reply"] = response["reply"] + state["update"] = response["update"] + state["last_success_at"] = format_utc(ended) + state["last_error"] = "" + state["thread_id"] = session["thread_id"] + state["wake_requested_at"] = "" + state["activity"] = response["status"] + if terminal_status: + state["status"] = terminal_status + state["next_wake_at"] = "" + elif response["continue"]: + state["status"] = "ready" + state["next_wake_at"] = format_utc( + ended + timedelta(minutes=meta["heartbeat_minutes"]) + ) + else: + state["status"] = "done" + state["next_wake_at"] = "" + _sync_state_from_session(state, session) + _write_json(agent_dir / "hosts" / meta["hostname"] / "session.json", session) + _write_json(agent_dir / "state.json", state) + run["ended_at"] = format_utc(ended) + run["status"] = response["status"] + run["reply"] = response["reply"] + run["update"] = response["update"] + run["notify"] = response["notify"] + run["continue"] = bool(response["continue"]) + run["usage"] = usage + run["messages"] = delivered_messages + _write_run(agent_dir, meta["hostname"], run) + if response["notify"]: + title = f"Agent: {meta['name']}" + Pushover().send(title, response["notify"]) + except Exception as exc: + ended = utc_now() + state["status"] = terminal_status or "error" + state["last_error"] = _single_line(str(exc)) or exc.__class__.__name__ + state["activity"] = state["last_error"] + state["wake_requested_at"] = "" + if terminal_status: + state["next_wake_at"] = "" + else: + state["next_wake_at"] = format_utc( + ended + timedelta(minutes=meta["heartbeat_minutes"]) + ) + _sync_state_from_session(state, session) + _write_json(agent_dir / "hosts" / meta["hostname"] / "session.json", session) + _write_json(agent_dir / "state.json", state) + run["ended_at"] = format_utc(ended) + run["error"] = state["last_error"] + _write_run(agent_dir, meta["hostname"], run) + + +def _run_agent_turn(meta, session, prompt, runner=None): + if runner is not None: + outcome = runner(meta, session, prompt) + if not isinstance(outcome, dict): + raise TypeError("runner must return a dict") + return outcome + started = utc_now() + if session.get("fast", False): + worker = Agent( + session.get("cwd") or meta.get("cwd"), + session.get("yolo", True), + session.get("thread_id") or None, + session.get("flags") or None, + include_thinking=False, + backend=session.get("backend") or None, + env=_agent_env(meta, session), + fast=True, + ) + else: + worker = Agent( + session.get("cwd") or meta.get("cwd"), + session.get("yolo", True), + session.get("thread_id") or None, + session.get("flags") or None, + include_thinking=False, + backend=session.get("backend") or None, + env=_agent_env(meta, session), + ) + message = worker(prompt) + usage = worker.last_usage or {} + rollout_path = "" + if (session.get("backend") or "codex") == "codex": + rollout_usage, rollout_path = _codex_rollout_usage( + session, + worker.thread_id or session.get("thread_id") or "", + started, + ) + if rollout_usage: + usage = rollout_usage + return { + "message": message, + "thread_id": worker.thread_id or "", + "usage": usage, + "rollout_path": rollout_path, + } + + +def _parse_agent_response(output): + text = _strip_fence(str(output or "").strip()) + try: + payload = json.loads(text) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid JSON response: {exc}") from None + if not isinstance(payload, dict): + raise ValueError("Agent response must be a JSON object.") + status = payload.get("status") + cont = payload.get("continue") + reply = payload.get("reply") + update = payload.get("update") + notify = payload.get("notify") + if not isinstance(status, str) or not status.strip(): + raise ValueError("Agent response missing string 'status'.") + if not isinstance(cont, bool): + raise ValueError("Agent response missing boolean 'continue'.") + if reply is None: + reply = "" + if update is None: + update = reply or status or "" + if notify is None: + notify = "" + if not isinstance(reply, str): + raise ValueError("Agent response missing string 'reply'.") + if not isinstance(update, str): + raise ValueError("Agent response missing string 'update'.") + if not isinstance(notify, str): + raise ValueError("Agent response missing string 'notify'.") + return { + "status": _single_line(status), + "continue": cont, + "reply": reply.strip(), + "update": update.strip(), + "notify": notify.strip(), + } + + +def _build_wake_prompt(meta, state, session, now, commands, agent_dir): + messages = session.get("pending_messages") or [] + book_path = agent_dir / "AGENTBOOK.md" + wake_mode = _wake_mode(state, session) + lines = [ + _agent_prompt(wake_mode), + "", + f"Wake mode: {wake_mode.replace('_', ' ')}", + f"Current UTC time: {format_utc(now)}", + f"Agent name: {meta['name']}", + f"Stop policy: {meta['stop_policy']}", + f"Heartbeat minutes: {meta['heartbeat_minutes']}", + "", + f"Working directory: {meta['cwd']}", + f"Agentbook path: {book_path}", + "Update the agentbook before you respond. Add or revise a dated note when " + "something durable changed, when you corrected your picture, or when an " + "assumption needs to be made explicit.", + "If little has changed across wakes, treat that as evidence about the " + "situation and reconsider your frame or next action instead of padding the " + "book.", + "If a queued message materially changes the durable situation, reflect that in the standing guidance or working notes before moving on.", + ] + if _include_full_goal_prompt(state, session): + lines.extend(["", "Original instructions:", meta["prompt"]]) + book = _ensure_agentbook_header(book_path, meta["prompt"], now) + if book.strip(): + lines.extend(["", "Agentbook (header + latest notes):", _book_excerpt(book, _AGENTBOOK_BOOK_LIMIT, _AGENTBOOK_HEADER_LIMIT, _AGENTBOOK_TAIL_LIMIT)]) + raw_facts = _wake_facts(state) + if raw_facts: + lines.extend(["", "Raw harness facts:"]) + lines.extend(f"- {item}" for item in raw_facts) + if messages: + lines.extend(["", "Queued user messages:"]) + for message in messages: + created_at = message.get("created_at") or "" + author = message.get("author") or "user" + text = message.get("text") or "" + lines.append(f"- [{created_at}] {author}: {text}") + else: + lines.extend(["", "Queued user messages: none."]) + if commands: + lines.extend(["", "Wake triggers:"]) + for command in commands: + lines.append(f"- {command['kind']}") + last_reply = state.get("reply") or "" + if last_reply: + lines.extend(["", "Your last visible reply:", _snippet(last_reply, 1200)]) + lines.extend(["", _AGENT_JSON]) + return "\n".join(lines).strip() + + +def _claim_commands(agent_dir): + new_dir = agent_dir / "commands" / "new" + claimed_dir = agent_dir / "commands" / "claimed" + commands = [] + for path in sorted(new_dir.iterdir(), key=lambda item: item.name): + if not path.is_file(): + continue + target = claimed_dir / path.name + try: + path.rename(target) + except FileNotFoundError: + continue + command = _read_json(target) + command["_path"] = str(target) + commands.append(command) + return commands + + +def _apply_commands(meta, state, session, commands, now): + changed = False + pending = list(session.get("pending_messages") or []) + for command in commands: + kind = command.get("kind") + if kind == "send": + pending.append( + { + "id": command.get("id") or "", + "created_at": command.get("created_at") or format_utc(now), + "author": command.get("author") or "user", + "origin_hostname": command.get("origin_hostname") or "", + "text": command.get("body") or "", + } + ) + state["wake_requested_at"] = format_utc(now) + changed = True + elif kind == "wake": + state["wake_requested_at"] = format_utc(now) + changed = True + elif kind == "pause": + state["status"] = "paused" + state["activity"] = "Paused" + changed = True + elif kind == "resume": + if state.get("status") in ("paused", "done"): + state["status"] = "ready" + state["wake_requested_at"] = format_utc(now) + state["activity"] = "Resumed" + changed = True + elif kind == "cancel": + state["status"] = "canceled" + state["activity"] = "Canceled" + state["wake_requested_at"] = "" + state["next_wake_at"] = "" + changed = True + session["pending_messages"] = pending + _sync_state_from_session(state, session) + for command in commands: + path = command.get("_path") + if path: + try: + os.unlink(path) + except FileNotFoundError: + pass + return changed + + +def _is_due(state, now): + status = state.get("status") + if status in ("done", "canceled") and int(state.get("unread_message_count") or 0) > 0: + return True + if status not in ("ready", "error"): + return False + if state.get("wake_requested_at"): + return True + if status == "ready" and int(state.get("unread_message_count") or 0) > 0: + return True + next_wake = parse_utc(state.get("next_wake_at")) + if next_wake and next_wake <= now: + return True + return False + + +def _one_shot_terminal_status(state): + """Return the terminal status when a one-off message wake should run.""" + status = state.get("status") or "" + if status not in _TERMINAL_STATES: + return "" + if int(state.get("unread_message_count") or 0) < 1: + return "" + return status + + +def _write_run(agent_dir, hostname, payload): + runs_dir = agent_dir / "hosts" / hostname / "runs" + filename = f"{payload['id']}.json" + _write_json(runs_dir / filename, payload) + + +def _recent_runs(agent_dir, limit): + meta = _read_json(agent_dir / "meta.json") + runs_dir = agent_dir / "hosts" / meta["hostname"] / "runs" + if not runs_dir.exists(): + return [] + runs = [] + for path in sorted(runs_dir.iterdir(), key=lambda item: item.name, reverse=True): + if not path.is_file() or path.suffix != ".json": + continue + runs.append(_read_json(path)) + if len(runs) >= limit: + break + return runs + + +def _queue_command(agent_ref, kind, body, author, home, hostname, now): + if kind not in _COMMAND_KINDS: + raise ValueError(f"Unsupported command: {kind}") + agent_dir = resolve_agent_dir(agent_ref, home) + now = now or utc_now() + host = hostname or current_hostname() + author = author or os.environ.get("USER") or "user" + payload = { + "id": _command_id(now, host), + "created_at": format_utc(now), + "origin_hostname": host, + "kind": kind, + "body": body, + "author": str(author), + } + new_dir = agent_dir / "commands" / "new" + _atomic_create_json(new_dir, f"{payload['id']}.json", payload) + return payload + + +def _snapshot(agent_dir, child_map=None): + meta = _read_json(agent_dir / "meta.json") + state = _read_json(agent_dir / "state.json") + session = _read_session(agent_dir) + runtime = _agent_runtime(agent_dir, meta, state, session) + queued = _queued_commands(agent_dir) + queued_controls = [item for item in queued if item.get("kind") != "send"] + queued_kinds = [item.get("kind") or "" for item in queued_controls if item.get("kind")] + if child_map is None: + child_ids = _child_map(agent_dir.parents[1]).get(meta["id"], []) + else: + child_ids = child_map.get(meta["id"], []) + unread = int(state.get("unread_message_count") or 0) + len( + [item for item in queued if item.get("kind") == "send"] + ) + status = state.get("status") or "" + return { + "id": meta["id"], + "name": meta["name"], + "created_at": meta["created_at"], + "created_by": meta["created_by"], + "parent_id": meta.get("parent_id") or "", + "hostname": meta["hostname"], + "cwd": meta["cwd"], + "stop_policy": meta["stop_policy"], + "heartbeat_minutes": meta["heartbeat_minutes"], + "status": status, + "display_status": _display_status( + status, + queued_kinds, + runtime["run_lock_held"], + runtime["stale"], + ), + "thread_id": state.get("thread_id") or "", + "last_wake_at": state.get("last_wake_at") or "", + "last_success_at": state.get("last_success_at") or "", + "next_wake_at": state.get("next_wake_at") or "", + "wake_requested_at": state.get("wake_requested_at") or "", + "unread_message_count": unread, + "pending_command_count": len(queued_controls), + "pending_commands": queued_kinds, + "input_tokens": int(state.get("input_tokens") or 0), + "output_tokens": int(state.get("output_tokens") or 0), + "total_tokens": int(state.get("total_tokens") or 0), + "avg_tokens_per_hour": float(state.get("avg_tokens_per_hour") or 0.0), + "child_ids": list(child_ids), + "last_error": state.get("last_error") or "", + "activity": state.get("activity") or "", + "reply": state.get("reply") or "", + "update": state.get("update") or "", + "run_lock_held": runtime["run_lock_held"], + "last_event_at": runtime["last_event_at"], + "stale": runtime["stale"], + "stale_after_seconds": runtime["stale_after_seconds"], + "stale_for_seconds": runtime["stale_for_seconds"], + } + + +def _agent_runtime(agent_dir, meta, state, session=None, now=None): + """Return live wake health for one agent.""" + run_lock_path = agent_dir / "hosts" / meta["hostname"] / "run.lock" + run_lock_held = _run_lock_held(run_lock_path) + stale_after_seconds = _stale_after_seconds(meta.get("heartbeat_minutes") or 0) + info = { + "run_lock_held": run_lock_held, + "last_event_at": "", + "stale": False, + "stale_after_seconds": stale_after_seconds, + "stale_for_seconds": 0, + } + if (state.get("status") or "") != "running" and not run_lock_held: + return info + now = now or utc_now() + session = session or _read_session(agent_dir) + thread_id = session.get("thread_id") or state.get("thread_id") or "" + rollout_path = _resolve_rollout_path(session.get("rollout_path"), thread_id) + if rollout_path is not None and rollout_path.exists(): + turn = _last_rollout_turn(_rollout_events(rollout_path)) + if turn is not None: + info["last_event_at"] = turn.get("last_event_at") or turn.get("started_at") or "" + last_progress_at = parse_utc(info["last_event_at"]) or parse_utc(state.get("last_wake_at")) + if last_progress_at is None: + return info + idle = max(0, int((now - last_progress_at).total_seconds())) + info["stale_for_seconds"] = idle + info["stale"] = run_lock_held and idle >= stale_after_seconds + return info + + +def _stale_after_seconds(heartbeat_minutes): + minutes = int(heartbeat_minutes or 0) + return max(_STALE_MIN_SECONDS, minutes * 60 * _STALE_HEARTBEAT_MULTIPLIER) + + +def _choose_name(home, prompt, requested): + base = _slugify(requested or prompt) + if not base: + base = "agent" + existing = {item["name"] for item in list_agents(home)} + if base not in existing: + return base + index = 2 + while True: + candidate = f"{base}-{index}" + if candidate not in existing: + return candidate + index += 1 + + +def _slugify(text): + if not isinstance(text, str): + return "" + cleaned = [] + for char in text.lower(): + if char.isalnum(): + cleaned.append(char) + continue + cleaned.append("-") + slug = "".join(cleaned) + while "--" in slug: + slug = slug.replace("--", "-") + slug = slug.strip("-") + if not slug: + return "" + parts = [part for part in slug.split("-") if part] + if not parts: + return "" + return "-".join(parts[:6]) + + +def _resolve_cwd(cwd): + target = cwd or os.getcwd() + return str(Path(target).expanduser().resolve()) + + +def _capture_env(): + env = {} + for key, value in os.environ.items(): + if key in ("CODEXAPI_AGENT_ID", "CODEXAPI_AGENT_NAME", "CODEXAPI_AGENT_PARENT_ID"): + continue + env[key] = value + if not (env.get("GH_TOKEN") or env.get("GITHUB_TOKEN")): + gh_token = _gh_auth_token() + if gh_token: + env["GH_TOKEN"] = gh_token + return env + + +def _gh_auth_token(): + """Return the active gh auth token when available.""" + if shutil.which("gh") is None: + return "" + result = subprocess.run( + ["gh", "auth", "token"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + return "" + return (result.stdout or "").strip() + + +def _parent_identity(home, parent_ref): + """Return the resolved parent agent id and name, if any.""" + if parent_ref is not None and str(parent_ref).strip(): + meta = _read_json(resolve_agent_dir(str(parent_ref), home) / "meta.json") + return meta["id"], meta["name"] + parent_id = os.environ.get("CODEXAPI_AGENT_ID", "").strip() + parent_name = os.environ.get("CODEXAPI_AGENT_NAME", "").strip() + if parent_id: + return parent_id, parent_name + return "", "" + + +def _resolve_home(home): + if home is None: + return codexapi_home() + return Path(home).expanduser().resolve() + + +def _ensure_home(home): + (home / "agents").mkdir(parents=True, exist_ok=True) + (home / "locks").mkdir(parents=True, exist_ok=True) + (home / "bin").mkdir(parents=True, exist_ok=True) + (home / "cron").mkdir(parents=True, exist_ok=True) + + +def _agent_dir(home, agent_id): + return home / "agents" / agent_id + + +def _tick_lock_path(home, hostname): + return home / "locks" / f".tick.{hostname}.lock" + + +@contextmanager +def _try_lock(path): + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "a+", encoding="utf-8") as handle: + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + yield None + return + yield handle + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + + +def _write_lock_info(handle, hostname, now): + handle.seek(0) + handle.truncate() + handle.write(json.dumps({"pid": os.getpid(), "hostname": hostname, "started_at": format_utc(now)})) + handle.flush() + + +def _run_lock_held(path): + """Return true when the per-agent run lock is currently held.""" + with _try_lock(path) as handle: + return handle is None + + +def _lock_info(path): + try: + return _read_json(path) + except (FileNotFoundError, json.JSONDecodeError): + return {} + + +def _signal_run_process(pid, sig): + """Signal the wake process group when possible, else just the pid.""" + pgid = None + try: + pgid = os.getpgid(pid) + except OSError: + pgid = None + if pgid is not None: + os.killpg(pgid, sig) + else: + os.kill(pid, sig) + return pgid + + +def _wait_for_lock_release(path, timeout_seconds): + deadline = time.monotonic() + max(0.0, float(timeout_seconds)) + while time.monotonic() < deadline: + if not _run_lock_held(path): + return True + time.sleep(_RECOVER_POLL_INTERVAL) + return not _run_lock_held(path) + + +def _recover_run_lock(path): + """Terminate the current lock holder and wait for the wake lock to clear.""" + if not _run_lock_held(path): + return { + "pid": None, + "pgid": None, + "sent_sigterm": False, + "sent_sigkill": False, + } + info = _lock_info(path) + pid = _usage_int(info.get("pid")) + if pid is None: + raise ValueError("Run lock is held but has no recorded pid.") + result = { + "pid": pid, + "pgid": None, + "sent_sigterm": False, + "sent_sigkill": False, + } + try: + result["pgid"] = _signal_run_process(pid, signal.SIGTERM) + result["sent_sigterm"] = True + except ProcessLookupError: + result["pgid"] = None + if _wait_for_lock_release(path, _RECOVER_TERM_TIMEOUT): + return result + result["pgid"] = _signal_run_process(pid, signal.SIGKILL) + result["sent_sigkill"] = True + if _wait_for_lock_release(path, _RECOVER_KILL_TIMEOUT): + return result + raise ValueError("Run lock stayed held after SIGTERM/SIGKILL.") + + +def _read_session(agent_dir): + meta = _read_json(agent_dir / "meta.json") + return _read_json(agent_dir / "hosts" / meta["hostname"] / "session.json") + + +def _sync_state_from_session(state, session): + pending = session.get("pending_messages") or [] + state["unread_message_count"] = len(pending) + state["thread_id"] = session.get("thread_id") or "" + + +def _agent_env(meta, session): + """Return the backend env with stable agent identity added.""" + env = dict(session.get("env") or {}) + env["CODEXAPI_AGENT_ID"] = meta["id"] + env["CODEXAPI_AGENT_NAME"] = meta["name"] + if meta.get("parent_id"): + env["CODEXAPI_AGENT_PARENT_ID"] = meta["parent_id"] + return env + + +def _command_id(now, hostname): + stamp = now.strftime("%Y%m%dT%H%M%SZ") + rand = "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(6)) + return f"{stamp}.{hostname}.{os.getpid()}.{rand}" + + +def _run_id(now): + return f"{now.strftime('%Y%m%dT%H%M%SZ')}-{uuid.uuid4().hex[:8]}" + + +def _atomic_create_json(directory, filename, payload): + directory.mkdir(parents=True, exist_ok=True) + fd, temp_path = tempfile.mkstemp(prefix=".tmp-", suffix=".json", dir=directory) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp_path, directory / filename) + finally: + if os.path.exists(temp_path): + os.unlink(temp_path) + + +def _write_json(path, payload): + path.parent.mkdir(parents=True, exist_ok=True) + fd, temp_path = tempfile.mkstemp(prefix=".tmp-", suffix=path.suffix or ".tmp", dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp_path, path) + finally: + if os.path.exists(temp_path): + os.unlink(temp_path) + + +def _write_text(path, text): + path.parent.mkdir(parents=True, exist_ok=True) + fd, temp_path = tempfile.mkstemp(prefix=".tmp-", suffix=path.suffix or ".tmp", dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp_path, path) + finally: + if os.path.exists(temp_path): + os.unlink(temp_path) + + +def _read_json(path): + with open(path, "r", encoding="utf-8") as handle: + return json.load(handle) + + +def _read_text(path): + try: + with open(path, "r", encoding="utf-8") as handle: + return handle.read() + except FileNotFoundError: + return "" + + +def _include_full_goal_prompt(state, session): + if not (state.get("last_success_at") or "").strip(): + return True + return not ((session.get("thread_id") or state.get("thread_id") or "").strip()) + + +def _wake_mode(state, session): + markers = ( + state.get("last_wake_at"), + state.get("last_success_at"), + state.get("reply"), + state.get("update"), + state.get("last_error"), + state.get("thread_id"), + session.get("thread_id"), + ) + for value in markers: + if (value or "").strip(): + return "continuation" + return "first_wake" + + +def _agent_prompt(wake_mode): + if wake_mode == "continuation": + return _CONTINUATION_PROMPT + _AGENT_PROMPT_TAIL + return _FIRST_WAKE_PROMPT + _AGENT_PROMPT_TAIL + + +def _wake_facts(state): + facts = [] + previous_status = (state.get("activity") or "").strip() + if previous_status: + facts.append(f"Previous status: {previous_status}") + previous_update = (state.get("update") or "").strip() + if previous_update: + facts.append(f"Previous update: {previous_update}") + previous_error = (state.get("last_error") or "").strip() + if previous_error: + facts.append(f"Previous error: {previous_error}") + return facts + + +def _ensure_agentbook_header(path, prompt, now): + text = _read_text(path) + if _agentbook_has_header(text): + return text + restored = _restore_agentbook_header(text, prompt, now) + _write_text(path, restored) + return restored + + +def _agentbook_has_header(text): + text = str(text or "") + required = ( + "## Purpose", + "## Values", + "## Original Goal", + "## Standing Guidance", + "## Working Notes", + ) + return all(section in text for section in required) + + +def _restore_agentbook_header(text, prompt, now): + restored = _agentbook_header(prompt).rstrip() + existing = str(text or "").strip() + if not existing: + return restored + "\n" + stamp = _agentbook_stamp(now) + return "\n".join( + [ + restored, + "", + f"### {stamp}", + "System note:", + "- The durable agentbook header was restored automatically on wake because one or more required sections were missing.", + "", + "Recovered notes:", + existing, + "", + ] + ) + + +def _agentbook_stamp(now): + if now is None: + return "" + return now.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + + +def _book_excerpt(text, limit, header_limit, tail_limit): + text = str(text or "").strip() + if not text: + return "" + if len(text) <= limit: + return text + header, notes = _split_book(text) + header = _snippet(header, header_limit) if header else "" + if not notes: + return header or _tail_snippet(text, limit) + marker = "\n\n[... older notes omitted ...]\n\n" + if not header: + return _latest_notes_snippet(notes, limit) + remaining = max(0, limit - len(header) - len(marker)) + if remaining <= 0: + return _snippet(header, limit) + tail = _latest_notes_snippet(notes, min(tail_limit, remaining)) + if not tail: + return header + combined = header.rstrip() + marker + tail.lstrip() + if len(combined) <= limit: + return combined + remaining = max(0, limit - len(header) - len(marker)) + return header.rstrip() + marker + _latest_notes_snippet(notes, remaining).lstrip() + + +def _split_book(text): + match = _DATED_NOTE_RE.search(text) + if not match: + return text.strip(), "" + return text[: match.start()].strip(), text[match.start() :].strip() + + +def _latest_notes_snippet(text, limit): + text = str(text or "").strip() + if not text: + return "" + if len(text) <= limit: + return text + starts = [match.start() for match in _DATED_NOTE_RE.finditer(text)] + if not starts: + return _tail_snippet(text, limit) + start = starts[-1] + for pos in reversed(starts[:-1]): + candidate = text[pos:].strip() + if len(candidate) > limit: + break + start = pos + candidate = text[start:].strip() + if len(candidate) <= limit: + return candidate + return _tail_snippet(candidate, limit) + + +def _snippet(text, limit): + if not text: + return "" + text = str(text).strip() + if len(text) <= limit: + return text + if limit <= 3: + return text[:limit] + return text[: limit - 3] + "..." + + +def _tail_snippet(text, limit): + if not text: + return "" + text = str(text).strip() + if len(text) <= limit: + return text + if limit <= 3: + return text[-limit:] + return "..." + text[-(limit - 3) :].lstrip() + + +def _strip_fence(text): + if not text.startswith("```"): + return text + lines = text.splitlines() + if len(lines) < 3: + return text + if lines[-1].strip() != "```": + return text + return "\n".join(lines[1:-1]).strip() + + +def _single_line(text): + if not text: + return "" + return " ".join(str(text).replace("\r", " ").split()) + + +def _wake_reason(state, commands): + reasons = [] + if state.get("wake_requested_at"): + reasons.append("wake_requested") + if int(state.get("unread_message_count") or 0) > 0: + reasons.append("messages") + if commands: + reasons.append("commands") + if not reasons: + reasons.append("heartbeat") + return ",".join(sorted(set(reasons))) + + +def _normalize_usage(usage): + """Normalize usage dicts for state accounting.""" + if not isinstance(usage, dict): + return {} + input_tokens = _usage_int(usage.get("input_tokens")) + output_tokens = _usage_int(usage.get("output_tokens")) + total_tokens = _usage_int(usage.get("total_tokens")) + if total_tokens is None and input_tokens is not None and output_tokens is not None: + total_tokens = input_tokens + output_tokens + normalized = {} + if input_tokens is not None: + normalized["input_tokens"] = input_tokens + if output_tokens is not None: + normalized["output_tokens"] = output_tokens + if total_tokens is not None: + normalized["total_tokens"] = total_tokens + return normalized + + +def _add_usage(meta, state, usage, now): + """Accumulate token usage totals and refresh the running average.""" + if not usage: + return + input_tokens = usage.get("input_tokens") + output_tokens = usage.get("output_tokens") + total_tokens = usage.get("total_tokens") + if input_tokens is not None: + state["input_tokens"] = int(state.get("input_tokens") or 0) + input_tokens + if output_tokens is not None: + state["output_tokens"] = int(state.get("output_tokens") or 0) + output_tokens + if total_tokens is None: + total_tokens = 0 + if input_tokens is not None: + total_tokens += input_tokens + if output_tokens is not None: + total_tokens += output_tokens + state["total_tokens"] = int(state.get("total_tokens") or 0) + total_tokens + created_at = parse_utc(meta.get("created_at")) + if created_at is None: + return + elapsed = (now - created_at).total_seconds() + if elapsed <= 0: + elapsed = 1 + state["avg_tokens_per_hour"] = round(state["total_tokens"] * 3600.0 / elapsed, 2) + + +def _usage_int(value): + """Return an integer-like usage value or None.""" + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, float) and value.is_integer(): + return int(value) + if isinstance(value, str): + text = value.strip() + if text.isdigit(): + return int(text) + return None + + +def _display_status(status, pending_commands, run_lock_held, stale): + """Return a user-facing status that includes queued control intent.""" + state = str(status or "") + commands = [str(kind or "") for kind in pending_commands or [] if kind] + if stale: + return "stale" + if commands: + last = commands[-1] + if last == "resume" and state in ("paused", "done"): + return "resuming" + if last == "pause" and state in ("ready", "error", "running"): + return "pausing" + if last == "cancel" and state != "canceled": + return "canceling" + if last == "wake" and state in ("ready", "error"): + return "waking" + if run_lock_held and state == "running": + return "running" + return state or "" + + +def _queued_commands(agent_dir, kind=None): + """Return queued command payloads from commands/new.""" + queued = [] + new_dir = agent_dir / "commands" / "new" + if not new_dir.exists(): + return queued + for path in sorted(new_dir.iterdir(), key=lambda item: item.name): + if not path.is_file() or path.suffix != ".json": + continue + try: + payload = _read_json(path) + except (FileNotFoundError, json.JSONDecodeError): + continue + payload_kind = payload.get("kind") or "" + if kind and payload_kind != kind: + continue + queued.append(payload) + return queued + + +def _queued_send_commands(agent_dir): + return _queued_commands(agent_dir, "send") + + +def _has_new_commands(agent_dir): + new_dir = agent_dir / "commands" / "new" + if not new_dir.exists(): + return False + for path in new_dir.iterdir(): + if path.is_file() and path.suffix == ".json": + return True + return False + + +def _child_map(home): + """Return parent_id -> [child ids] for this home.""" + root = _resolve_home(home) / "agents" + child_map = {} + if not root.exists(): + return child_map + for agent_dir in root.iterdir(): + if not agent_dir.is_dir(): + continue + try: + meta = _read_json(agent_dir / "meta.json") + except FileNotFoundError: + continue + parent_id = meta.get("parent_id") or "" + if not parent_id: + continue + child_map.setdefault(parent_id, []).append(meta["id"]) + for child_ids in child_map.values(): + child_ids.sort() + return child_map + + +def _agent_brief(home, agent_id, child_map): + """Return a short snapshot for one related agent.""" + if not agent_id: + return None + try: + agent_dir = resolve_agent_dir(agent_id, home) + except ValueError: + return None + snapshot = _snapshot(agent_dir, child_map) + return { + "id": snapshot["id"], + "name": snapshot["name"], + "status": snapshot["status"], + "reply": snapshot["reply"], + } + + +def _agent_briefs(home, agent_ids, child_map): + """Return short snapshots for a list of related agents.""" + return [ + brief + for brief in (_agent_brief(home, agent_id, child_map) for agent_id in agent_ids or []) + if brief is not None + ] + + +def _codex_rollout_usage(session, thread_id, started_at): + """Return usage from the current Codex rollout plus its resolved path.""" + if not thread_id: + return {}, "" + rollout_path = _resolve_rollout_path(session.get("rollout_path"), thread_id) + if rollout_path is None: + return {}, "" + usage = _extract_rollout_usage(rollout_path, started_at) + if not usage: + return {}, str(rollout_path) + return usage, str(rollout_path) + + +def _resolve_rollout_path(known_path, thread_id): + """Return the rollout file for a thread, preferring the cached session path.""" + if known_path: + path = Path(known_path) + if path.exists() and (not thread_id or thread_id in path.name): + return path + if not thread_id: + return None + root = Path(os.environ.get("CODEX_HOME", "~/.codex")).expanduser() / "sessions" + if not root.exists(): + return None + candidates = [] + for dirpath, _dirnames, filenames in os.walk(root): + for name in filenames: + if not name.startswith("rollout-") or not name.endswith(".jsonl"): + continue + if thread_id not in name: + continue + path = Path(dirpath) / name + try: + mtime = path.stat().st_mtime + except OSError: + continue + candidates.append((mtime, path)) + if not candidates: + return None + candidates.sort(reverse=True) + return candidates[0][1] + + +def _extract_rollout_usage(path, started_at): + """Return the latest per-turn token usage written after this wake started.""" + latest = {} + try: + with open(path, "r", encoding="utf-8", errors="replace") as handle: + for line in handle: + if '"token_count"' not in line: + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + if event.get("type") != "event_msg": + continue + payload = event.get("payload") or {} + if payload.get("type") != "token_count": + continue + timestamp = parse_utc(event.get("timestamp")) + if timestamp is not None and timestamp < started_at: + continue + info = payload.get("info") or {} + usage = _normalize_usage(info.get("last_token_usage")) + if usage: + latest = usage + except OSError: + return {} + return latest + + +def _rollout_events(path): + """Return parsed JSONL events from one rollout file.""" + events = [] + try: + with open(path, "r", encoding="utf-8", errors="replace") as handle: + for line in handle: + line = line.strip() + if not line: + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(event, dict): + events.append(event) + except OSError: + return [] + return events + + +def _last_rollout_turn(events, include_actions=False): + """Return the latest task_started slice from rollout events.""" + turn_events = [] + for event in events: + payload = event.get("payload") or {} + if event.get("type") == "event_msg" and payload.get("type") == "task_started": + turn_events = [event] + continue + if turn_events: + turn_events.append(event) + if not turn_events: + return None + + started = turn_events[0] + started_payload = started.get("payload") or {} + last_event_at = "" + progress_events = [] + assistant_events = [] + tools = [] + tool_by_call_id = {} + ended_at = "" + task_complete_message = "" + + for event in turn_events: + last_event_at = event.get("timestamp") or last_event_at + payload = event.get("payload") or {} + event_type = event.get("type") + payload_type = payload.get("type") + if event_type == "event_msg": + if payload_type == "agent_message": + item = { + "text": str(payload.get("message") or "").strip(), + "phase": payload.get("phase") or "", + } + if item["text"]: + progress_events.append(item) + elif payload_type == "task_complete": + ended_at = event.get("timestamp") or "" + task_complete_message = str(payload.get("last_agent_message") or "").strip() + elif event_type == "response_item": + if payload_type == "message" and payload.get("role") == "assistant": + text = _response_message_text(payload) + if text: + assistant_events.append( + { + "text": text, + "phase": payload.get("phase") or "", + } + ) + elif include_actions and payload_type in ("function_call", "custom_tool_call"): + tool = _rollout_tool_call(payload) + if tool is None: + continue + tools.append(tool) + call_id = tool.get("call_id") or "" + if call_id: + tool_by_call_id[call_id] = tool + elif include_actions and payload_type in ("function_call_output", "custom_tool_call_output"): + tool = tool_by_call_id.get(payload.get("call_id") or "") + if tool is not None: + _apply_rollout_tool_output(tool, payload) + + visible = progress_events or assistant_events + progress = [item["text"] for item in visible] + final_output = visible[-1]["text"] if visible else task_complete_message + final_json = None + if final_output: + final_json = _parse_rollout_final_json(final_output) + if progress and progress[-1] == final_output and ( + final_json is not None or (visible[-1].get("phase") or "") == "final_answer" + ): + progress = progress[:-1] + + if include_actions: + for tool in tools: + tool["summary"] = _rollout_tool_summary(tool) + + return { + "turn_id": started_payload.get("turn_id") or "", + "started_at": started.get("timestamp") or "", + "ended_at": ended_at, + "last_event_at": last_event_at, + "progress": progress, + "tools": tools, + "final_output": final_output, + "final_json": final_json, + } + + +def _response_message_text(payload): + """Return the text content from one assistant response message.""" + parts = [] + for item in payload.get("content") or []: + if not isinstance(item, dict): + continue + text = item.get("text") + if isinstance(text, str) and text.strip(): + parts.append(text.strip()) + return "\n".join(parts).strip() + + +def _rollout_tool_call(payload): + """Return a compact tool-call record for one rollout item.""" + name = payload.get("name") or "" + kind = payload.get("type") or "" + call_id = payload.get("call_id") or "" + tool = { + "call_id": call_id, + "kind": kind, + "name": name, + "command": "", + "files": [], + "exit_code": None, + "output": "", + "summary": "", + } + if kind == "function_call": + arguments = _parse_rollout_json(payload.get("arguments")) + if isinstance(arguments, dict): + tool["command"] = str(arguments.get("cmd") or "").strip() + elif kind == "custom_tool_call" and name == "apply_patch": + tool["files"] = _patch_targets(payload.get("input") or "") + return tool + + +def _apply_rollout_tool_output(tool, payload): + """Fold tool output into a compact rollout tool record.""" + text, exit_code = _tool_output_details(payload.get("output")) + if exit_code is not None: + tool["exit_code"] = exit_code + tool["output"] = _snippet(text.strip(), 400) if text else "" + if tool["name"] == "apply_patch" and not tool["files"]: + tool["files"] = _updated_files(text) + + +def _parse_rollout_json(value): + if isinstance(value, dict): + return value + if not isinstance(value, str) or not value.strip(): + return None + try: + return json.loads(value) + except json.JSONDecodeError: + return None + + +def _parse_rollout_final_json(text): + """Return the normalized final agent JSON when the text matches the contract.""" + try: + return _parse_agent_response(text) + except ValueError: + return None + + +def _tool_output_details(output): + """Return normalized output text and exit code from a rollout tool result.""" + text = str(output or "") + payload = _parse_rollout_json(text) + exit_code = None + if isinstance(payload, dict): + metadata = payload.get("metadata") or {} + exit_code = _usage_int(metadata.get("exit_code")) + text = str(payload.get("output") or "") + raw = text + body = raw + if "\nOutput:\n" in raw: + body = raw.split("\nOutput:\n", 1)[1] + elif raw.startswith("Output:\n"): + body = raw.split("Output:\n", 1)[1] + for line in raw.splitlines(): + if not line.startswith("Process exited with code "): + continue + tail = line.rsplit(" ", 1)[-1].strip() + if tail.startswith("-"): + tail = tail[1:] + if tail.isdigit(): + exit_code = int(line.rsplit(" ", 1)[-1].strip()) + break + return body.strip(), exit_code + + +def _rollout_tool_summary(tool): + """Return one readable summary line for a tool action.""" + name = tool.get("name") or "" + exit_code = tool.get("exit_code") + suffix = "" + if exit_code is not None: + suffix = f" (exit {exit_code})" + if name == "exec_command": + command = _single_line(_snippet(tool.get("command") or "", 140)) + if command: + return f"Running command: {command}{suffix}" + return f"Running command{suffix}" + if name == "apply_patch": + files = tool.get("files") or [] + if files: + label = ", ".join(files[:3]) + if len(files) > 3: + label += ", ..." + return f"Editing files: {label}{suffix}" + return f"Editing files{suffix}" + if name: + return f"{name}{suffix}" + return f"tool{suffix}" + + +def _patch_targets(text): + """Return patch target files from an apply_patch input.""" + files = [] + for line in str(text or "").splitlines(): + for prefix in ("*** Add File: ", "*** Update File: ", "*** Delete File: ", "*** Move to: "): + if not line.startswith(prefix): + continue + target = line[len(prefix) :].strip() + if target and target not in files: + files.append(target) + return files + + +def _updated_files(text): + """Return file paths mentioned in apply_patch output.""" + files = [] + for line in str(text or "").splitlines(): + line = line.strip() + if not line or line == "Success. Updated the following files:": + continue + if line.startswith(("M ", "A ", "D ")): + target = line[2:].strip() + if target and target not in files: + files.append(target) + return files + + +def _cron_tag(home, hostname): + key = sha1(str(home).encode("utf-8")).hexdigest()[:12] + return f"codexapi-agent::{hostname}::{key}" + + +def _upsert_cron_line(existing, line, tag): + lines = [] + found = False + for raw in str(existing or "").splitlines(): + if raw.strip().endswith(f"# {tag}"): + if not found: + lines.append(line) + found = True + continue + lines.append(raw) + if not found: + lines.append(line) + found = True + changed = True + else: + changed = "\n".join(lines).strip() != str(existing or "").strip() + text = "\n".join(item for item in lines if item is not None) + if text and not text.endswith("\n"): + text += "\n" + return text, changed + + +def _remove_cron_line(existing, tag): + lines = [] + changed = False + for raw in str(existing or "").splitlines(): + if raw.strip().endswith(f"# {tag}"): + changed = True + continue + lines.append(raw) + text = "\n".join(lines) + if text and not text.endswith("\n"): + text += "\n" + return text, changed + + +def _read_crontab(): + result = subprocess.run( + ["crontab", "-l"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + return result.stdout + stderr = (result.stderr or "").strip().lower() + stdout = (result.stdout or "").strip().lower() + if "no crontab" in stderr or "no crontab" in stdout: + return "" + raise RuntimeError(result.stderr.strip() or "crontab -l failed") + + +def _write_crontab(text): + result = subprocess.run( + ["crontab", "-"], + input=text, + text=True, + capture_output=True, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "crontab install failed") + + +def _remove_file(path): + try: + Path(path).unlink() + except FileNotFoundError: + return diff --git a/src/codexapi/async_agent.py b/src/codexapi/async_agent.py new file mode 100644 index 0000000..edc2cf9 --- /dev/null +++ b/src/codexapi/async_agent.py @@ -0,0 +1,458 @@ +"""Async wrapper for running agent backends without the durable registry.""" + +from __future__ import annotations + +import json +import os +import shlex +import subprocess +import threading +import time +import uuid + +from .agent import ( + _CODEX_BIN, + _agent_config_flag_parts, + _codex_automation_flags, + _codex_fast_config, + _cursor_command_prefix, + _ensure_backend_available, + _event_usage, + _merged_env, + _normalize_usage, + _parse_cursor_json, + _resolve_backend, +) +from .agents import _last_rollout_turn, _resolve_rollout_path, _rollout_events + +_TERMINAL_STATES = {"done", "error", "canceled"} + + +class AsyncAgent: + """Run one agent call in a background subprocess and poll live progress.""" + + def __init__( + self, + process: subprocess.Popen[str], + *, + cwd: str | None, + backend: str, + name: str | None, + include_thinking: bool, + ) -> None: + self.id = uuid.uuid4().hex + self.name = name or f"async-{self.id[:8]}" + self.cwd = os.fspath(cwd) if cwd else os.getcwd() + self.backend = backend + self.include_thinking = include_thinking + self.pid = process.pid + + self._process = process + self._lock = threading.Lock() + self._stdout_lines: list[str] = [] + self._stderr_lines: list[str] = [] + self._errors: list[str] = [] + self._messages: list[str] = [] + self._thread_id = "" + self._rollout_path = "" + self._progress: list[str] = [] + self._tools: list[dict[str, object]] = [] + self._last_event_at = "" + self._rollout_final_output = "" + self._last_usage: dict[str, int] = {} + self._stdout_done = False + self._stderr_done = False + self._cursor_parsed = False + self._canceled = False + + self._stdout_thread = threading.Thread( + target=self._read_stdout, + name=f"codexapi-async-stdout-{self.id[:8]}", + daemon=True, + ) + self._stderr_thread = threading.Thread( + target=self._read_stderr, + name=f"codexapi-async-stderr-{self.id[:8]}", + daemon=True, + ) + self._stdout_thread.start() + self._stderr_thread.start() + + @classmethod + def start( + cls, + prompt, + cwd=None, + yolo=True, + flags=None, + include_thinking=False, + backend=None, + env=None, + name=None, + fast=False, + model=None, + thinking=None, + ): + """Start a backend subprocess and return an async handle immediately.""" + if not isinstance(prompt, str) or not prompt.strip(): + raise ValueError("prompt must be a non-empty string") + + backend = _resolve_backend(backend) + _ensure_backend_available(backend, env) + command = _build_command(backend, cwd, yolo, flags, fast, model, thinking) + process = subprocess.Popen( + command, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + cwd=os.fspath(cwd) if cwd else None, + env=_merged_env(env), + ) + agent = cls( + process, + cwd=cwd, + backend=backend, + name=name, + include_thinking=include_thinking, + ) + try: + assert process.stdin is not None + process.stdin.write(prompt) + if not prompt.endswith("\n"): + process.stdin.write("\n") + process.stdin.close() + except Exception: + agent.cancel() + raise + return agent + + @property + def thread_id(self) -> str: + with self._lock: + return self._thread_id + + @property + def last_usage(self) -> dict[str, int]: + with self._lock: + return dict(self._last_usage) + + def show(self) -> dict[str, object]: + """Return a concise snapshot of the current local async run.""" + status = self.status() + return { + "id": self.id, + "name": self.name, + "cwd": self.cwd, + "backend": self.backend, + "pid": self.pid, + "thread_id": status["thread_id"], + "status": status["status"], + "activity": status["activity"], + "returncode": status["returncode"], + } + + def status(self, include_actions=False) -> dict[str, object]: + """Return the current process and rollout snapshot.""" + self._refresh_rollout() + self._finalize_cursor_output() + with self._lock: + returncode = self._process.poll() + status = _status_text(returncode, self._canceled) + final_output = self._current_final_output_locked() + progress = list(self._progress) + tools = list(self._tools) if include_actions else [] + stderr_lines = list(self._stderr_lines) + error_lines = list(self._errors) + thread_id = self._thread_id + rollout_path = self._rollout_path + last_event_at = self._last_event_at + last_usage = dict(self._last_usage) + messages = list(self._messages) + + activity = _activity_text( + status=status, + progress=progress, + final_output=final_output, + stderr_lines=stderr_lines, + error_lines=error_lines, + ) + last_error = error_lines[-1] if error_lines else stderr_lines[-1] if stderr_lines else "" + return { + "id": self.id, + "name": self.name, + "cwd": self.cwd, + "backend": self.backend, + "pid": self.pid, + "status": status, + "activity": activity, + "thread_id": thread_id, + "rollout_path": rollout_path, + "progress": progress, + "tools": tools, + "final_output": final_output, + "last_event_at": last_event_at, + "returncode": returncode, + "last_error": last_error, + "stderr": "\n".join(stderr_lines), + "errors": error_lines, + "messages": messages, + "usage": last_usage, + } + + def watch(self, poll_interval=2.0, timeout=None, include_actions=False): + """Yield changed snapshots until the subprocess fully exits.""" + if poll_interval <= 0: + raise ValueError("poll_interval must be > 0") + if timeout is not None and timeout < 0: + raise ValueError("timeout must be >= 0") + + started = time.monotonic() + last_key = None + while True: + snapshot = self.status(include_actions=include_actions) + key = ( + snapshot["status"], + snapshot["thread_id"], + len(snapshot["progress"]), + len(snapshot["tools"]), + snapshot["last_event_at"], + snapshot["final_output"], + snapshot["returncode"], + ) + if key != last_key: + yield snapshot + last_key = key + + if snapshot["status"] in _TERMINAL_STATES and self._io_drained(): + return + if timeout is not None and (time.monotonic() - started) >= timeout: + return + time.sleep(poll_interval) + + def wait(self, poll_interval=2.0, timeout=None, include_actions=False): + """Poll until the subprocess exits and return the final snapshot.""" + last = None + for update in self.watch( + poll_interval=poll_interval, + timeout=timeout, + include_actions=include_actions, + ): + last = update + return last or self.status(include_actions=include_actions) + + def cancel(self, terminate_timeout=2.0, kill_timeout=2.0) -> None: + """Stop the subprocess if it is still running.""" + with self._lock: + self._canceled = True + if self._process.poll() is not None: + return + self._process.terminate() + try: + self._process.wait(timeout=terminate_timeout) + return + except subprocess.TimeoutExpired: + pass + self._process.kill() + try: + self._process.wait(timeout=kill_timeout) + except subprocess.TimeoutExpired: + pass + + def _io_drained(self) -> bool: + with self._lock: + return self._stdout_done and self._stderr_done + + def _read_stdout(self) -> None: + handle = self._process.stdout + try: + if handle is None: + return + for raw_line in handle: + line = raw_line.rstrip("\r\n") + with self._lock: + self._stdout_lines.append(line) + self._handle_stdout_line(line) + finally: + if handle is not None: + handle.close() + with self._lock: + self._stdout_done = True + + def _read_stderr(self) -> None: + handle = self._process.stderr + try: + if handle is None: + return + for raw_line in handle: + line = raw_line.rstrip("\r\n") + if not line: + continue + with self._lock: + self._stderr_lines.append(line) + finally: + if handle is not None: + handle.close() + with self._lock: + self._stderr_done = True + + def _handle_stdout_line(self, line: str) -> None: + if not line: + return + if self.backend == "cursor": + return + try: + event = json.loads(line) + except json.JSONDecodeError: + return + + usage = _stream_event_usage(event) + with self._lock: + if usage: + self._last_usage = usage + if event.get("type") == "thread.started": + thread_id = event.get("thread_id") + if isinstance(thread_id, str): + self._thread_id = thread_id + elif event.get("type") == "item.completed": + item = event.get("item") or {} + if item.get("type") == "agent_message": + text = item.get("text") + if isinstance(text, str): + self._messages.append(text) + elif event.get("type") == "error": + message = event.get("message") + if isinstance(message, str) and message.strip(): + self._errors.append(message.strip()) + elif event.get("type") == "turn.failed": + error = event.get("error") or {} + if isinstance(error, dict): + message = error.get("message") + if isinstance(message, str) and message.strip(): + self._errors.append(message.strip()) + + def _refresh_rollout(self) -> None: + if self.backend != "codex": + return + with self._lock: + thread_id = self._thread_id + known_path = self._rollout_path + if not thread_id: + return + rollout_path = _resolve_rollout_path(known_path, thread_id) + if rollout_path is None or not rollout_path.exists(): + return + turn = _last_rollout_turn(_rollout_events(rollout_path), include_actions=True) + with self._lock: + self._rollout_path = str(rollout_path) + if turn is not None: + self._progress = turn.get("progress") or [] + self._tools = turn.get("tools") or [] + self._last_event_at = turn.get("last_event_at") or "" + self._rollout_final_output = turn.get("final_output") or "" + + def _finalize_cursor_output(self) -> None: + if self.backend != "cursor": + return + with self._lock: + if self._cursor_parsed or not self._stdout_done: + return + output = "\n".join(self._stdout_lines) + try: + message, thread_id, usage = _parse_cursor_json(output, self.include_thinking) + except Exception as exc: + with self._lock: + self._stderr_lines.append(str(exc)) + self._cursor_parsed = True + return + with self._lock: + self._messages = [message] + self._thread_id = thread_id or "" + self._last_usage = usage or {} + self._cursor_parsed = True + + def _current_final_output_locked(self) -> str: + if self._messages: + if self.include_thinking: + return "\n\n".join(self._messages) + return self._messages[-1] + return self._rollout_final_output + + +def _build_command(backend, cwd, yolo, flags, fast=False, model=None, thinking=None): + if backend == "codex": + return _build_codex_command(cwd, yolo, flags, fast, model, thinking) + return _build_cursor_command(cwd, yolo, flags, model, thinking) + + +def _build_codex_command(cwd, yolo, flags, fast=False, model=None, thinking=None): + command = [_CODEX_BIN] + _codex_automation_flags(yolo) + [ + "exec", + "--json", + "--color", + "never", + "--skip-git-repo-check", + ] + command.extend(_codex_fast_config(fast)) + command.extend(_agent_config_flag_parts("codex", model, thinking)) + if flags: + command.extend(shlex.split(flags)) + if cwd: + command.extend(["--cd", os.fspath(cwd)]) + command.append("-") + return command + + +def _build_cursor_command(cwd, yolo, flags, model=None, thinking=None): + command = _cursor_command_prefix() + [ + "--trust", + ] + if cwd: + command.extend(["--workspace", os.fspath(cwd)]) + if yolo: + command.append("--yolo") + command.extend(_agent_config_flag_parts("cursor", model, thinking)) + if flags: + command.extend(shlex.split(flags)) + command.extend(["--print", "--output-format", "json"]) + return command + + +def _stream_event_usage(event): + usage = _event_usage(event) + if usage: + return usage + if not isinstance(event, dict): + return {} + if event.get("type") == "turn.completed": + payload = event.get("usage") + if isinstance(payload, dict): + return _normalize_usage(payload) + return {} + + +def _status_text(returncode, canceled): + if returncode is None: + return "running" + if canceled: + return "canceled" + if returncode == 0: + return "done" + return "error" + + +def _activity_text(status, progress, final_output, stderr_lines, error_lines=None): + if progress: + return progress[-1] + if status == "error" and error_lines: + return error_lines[-1] + if status == "error" and stderr_lines: + return stderr_lines[-1] + if final_output: + return final_output + if status == "done": + return "Finished" + if status == "canceled": + return "Canceled" + return "Running" diff --git a/src/codexapi/cli.py b/src/codexapi/cli.py index f0ac111..3e3e790 100644 --- a/src/codexapi/cli.py +++ b/src/codexapi/cli.py @@ -3,6 +3,7 @@ import os import re import select +import shlex import shutil import subprocess import sys @@ -12,7 +13,29 @@ from datetime import datetime from pathlib import Path +from . import __version__ from .agent import Agent, agent +from .agents import ( + codexapi_home, + control_agent, + cron_status as agent_cron_status, + current_hostname, + delete_agent as delete_managed_agent, + install_cron as install_agent_cron, + list_agents as list_managed_agents, + nudge_agent, + read_agent as read_managed_agent, + read_agentbook, + recover_agent as recover_managed_agent, + run_agent as run_managed_agent, + send_agent, + set_agent_heartbeat, + show_agent as show_managed_agent, + status_agent as status_managed_agent, + start_agent as start_managed_agent, + tick as tick_managed_agents, + uninstall_cron as uninstall_agent_cron, +) from .foreach import foreach from .ralph import Ralph, cancel_ralph_loop from .science import Science @@ -83,6 +106,14 @@ _FOREACH_STATUS_MARKERS = {"⏳", "✅", "❌"} +def _add_subparser(subparsers, name, help_text, **kwargs): + parser_kwargs = dict(kwargs) + parser_kwargs["help"] = help_text + if help_text is not argparse.SUPPRESS: + parser_kwargs.setdefault("description", help_text) + return subparsers.add_parser(name, **parser_kwargs) + + def _read_prompt(prompt): if prompt and prompt != "-": return prompt @@ -131,6 +162,227 @@ def _single_line(text): return " ".join(text.replace("\r", " ").split()) +def _print_managed_agent_list(items): + if not items: + print("No agents.") + return + print("ID STAT POL HOST QMSG QCMD TOKENS TOK/H NEXT REPO NAME") + for item in items: + ident = item["id"][:8] + status = _truncate_head(item.get("display_status") or item.get("status") or "-", 9) + policy = _truncate_head(_policy_label(item.get("stop_policy")), 4) + host = _truncate_head(item["hostname"] or "-", 12) + queued_messages = str(item["unread_message_count"]) + queued_commands = str(int(item.get("pending_command_count") or 0)) + tokens = _format_token_total(item["total_tokens"]) + tok_h = _format_token_rate(item.get("avg_tokens_per_hour")) + next_wake = _truncate_head(_next_wake_label(item), 6) + repo = _truncate_head(_repo_label(item.get("cwd")), 12) + name = item["name"] + print( + f"{ident:<8} {status:<9} {policy:<4} {host:<12} {queued_messages:>4} {queued_commands:>4} {tokens:>6} {tok_h:>7} {next_wake:>6} {repo:<12} {name}" + ) + + +def _print_managed_agent_read(result): + print(f"{result['name']} [{result['status']}]") + items = result.get("items") or [] + if not items: + print("No messages.") + return + for item in items: + stamp = item.get("timestamp") or "-" + kind = item.get("kind") or "item" + author = item.get("author") or "" + if author: + print(f"[{stamp}] {kind} {author}:") + else: + print(f"[{stamp}] {kind}:") + print(item.get("text") or "") + print() + + +def _print_managed_agent_status(result, include_actions=False): + print(f"{result['name']} [{result['agent_status'] or '-'}]") + print(f"ID: {result['id']}") + print(f"State: {result.get('state_status') or '-'}") + print(f"Thread: {result.get('thread_id') or '-'}") + print(f"Turn: {result.get('turn_id') or '-'} [{result.get('turn_state') or '-'}]") + print(f"Started: {result.get('started_at') or '-'}") + print(f"Ended: {result.get('ended_at') or '-'}") + print(f"CWD: {result.get('cwd') or '-'}") + print(f"Rollout: {result.get('rollout_path') or '-'}") + print(f"Last event: {result.get('last_event_at') or '-'}") + print(f"Stale: {_stale_text(result)}") + print(f"Queued messages: {result.get('queued_message_count') or 0}") + print(f"Pending commands: {_pending_commands_text(result.get('pending_commands'))}") + progress = result.get("progress") or [] + print("Progress:") + if not progress: + print("- none") + else: + for item in progress: + print(f"- {_single_line(item)}") + if include_actions: + tools = result.get("tools") or [] + print("Actions:") + if not tools: + print("- none") + else: + for tool in tools: + print(f"- {tool.get('summary') or tool.get('name') or 'tool'}") + if tool.get("output"): + print(f" Output: {_single_line(tool['output'])}") + final_json = result.get("final_json") + if final_json is not None: + print("Final fields:") + print(f"Status: {final_json.get('status') or '-'}") + print(f"Continue: {str(bool(final_json.get('continue'))).lower()}") + print(f"Reply: {final_json.get('reply') or '-'}") + print(f"Update: {final_json.get('update') or '-'}") + print(f"Notify: {final_json.get('notify') or '-'}") + return + final_output = result.get("final_output") or "" + print("Final output:") + if final_output: + print(final_output) + else: + print("-") + + +def _print_managed_agent_identity(): + override = os.environ.get("CODEXAPI_HOSTNAME", "").strip() + print(f"Host: {current_hostname()}") + print(f"Host override: {override or '-'}") + print(f"Home: {codexapi_home()}") + + +def _agent_install_cron_command(): + parts = [] + home = os.environ.get("CODEXAPI_HOME", "").strip() + host = os.environ.get("CODEXAPI_HOSTNAME", "").strip() + if home: + parts.append(f"CODEXAPI_HOME={shlex.quote(home)}") + if host: + parts.append(f"CODEXAPI_HOSTNAME={shlex.quote(host)}") + parts.extend(["codexapi", "agent", "install-cron"]) + return " ".join(parts) + + +def _warn_agent_scheduler_missing(): + try: + status = agent_cron_status() + except Exception as exc: + print( + "Warning: could not verify whether the codexapi agent scheduler hook is installed.", + file=sys.stderr, + ) + print(f"Reason: {exc}", file=sys.stderr) + print(f"Install it with: {_agent_install_cron_command()}", file=sys.stderr) + return + if status["healthy"]: + return + if status["configured"]: + print( + "Warning: the codexapi agent scheduler hook is installed but not runnable for this CODEXAPI_HOME.", + file=sys.stderr, + ) + if status["reason"]: + print(f"Reason: {status['reason']}", file=sys.stderr) + print(f"Reinstall it with: {_agent_install_cron_command()}", file=sys.stderr) + return + print( + "Warning: no codexapi agent scheduler hook is installed for this CODEXAPI_HOME. " + "Background agent wakes will not run until you install it.", + file=sys.stderr, + ) + print(f"Install it with: {_agent_install_cron_command()}", file=sys.stderr) + + +def _system_tick(): + return {"agents": tick_managed_agents()} + + +def _send_reply_info(agent_ref, message_id): + """Return the matching run reply for one sent message, if already delivered.""" + shown = show_managed_agent(agent_ref) + for run in shown.get("recent_runs") or []: + for message in run.get("messages") or []: + if message.get("id") != message_id: + continue + info = { + "delivered": True, + "agent_status": run.get("status") or "", + "run_id": run.get("id") or "", + } + reply = run.get("reply") or "" + update = run.get("update") or "" + error = run.get("error") or "" + if reply: + info["agent_reply"] = reply + if update: + info["agent_update"] = update + if error: + info["agent_error"] = error + return info + return None + + +def _print_managed_agent_show(result): + meta = result["meta"] + state = result["state"] + print(f"{meta['name']} [{result.get('display_status') or state.get('status') or '-'}]") + print(f"ID: {meta['id']}") + print(f"Host: {meta['hostname']}") + print(f"Created: {meta['created_at']} by {meta['created_by']}") + print(f"Parent: {_related_label(result.get('parent'))}") + print(f"Children: {_children_label(result.get('children'))}") + print( + f"Policy: {meta['stop_policy']} Heartbeat: {meta['heartbeat_minutes']}m Qmsg: {result['unread_message_count']} Qcmd: {result.get('pending_command_count') or 0}" + ) + print(f"CWD: {meta['cwd']}") + print(f"Agentbook: {result.get('agentbook_path') or '-'}") + print(f"State: {state.get('status') or '-'}") + print(f"Thread: {state.get('thread_id') or '-'}") + print(f"Last event: {result.get('last_event_at') or '-'}") + print(f"Stale: {_stale_text(result)}") + print(f"Pending commands: {_pending_commands_text(result.get('pending_commands'))}") + print( + "Tokens: " + f"{_format_token_total(state.get('total_tokens'))} total " + f"({_format_token_total(state.get('input_tokens'))} in, " + f"{_format_token_total(state.get('output_tokens'))} out, " + f"{_format_token_rate(state.get('avg_tokens_per_hour'))}/h)" + ) + print(f"Activity: {_state_text(state.get('activity'))}") + print(f"Reply: {_state_text(state.get('reply'))}") + print(f"Update: {_state_text(state.get('update'))}") + print(f"Last error: {_state_text(state.get('last_error'))}") + print(f"Last wake: {_state_time(state.get('last_wake_at'))}") + print(f"Last success: {_state_time(state.get('last_success_at'))}") + print(f"Next wake: {_state_time(state.get('next_wake_at'))}") + print(f"Wake requested: {_state_time(state.get('wake_requested_at'))}") + print(f"Prompt: {_truncate_head(_single_line(meta.get('prompt') or ''), 160) or '-'}") + recent_runs = result.get("recent_runs") or [] + if not recent_runs: + return + print() + print("Recent runs:") + for run in recent_runs: + print(_format_managed_agent_run(run)) + + +def _print_managed_agent_book(result): + print(f"Agentbook: {result['path']}") + text = result.get("text") or "" + if text: + print() + print(text, end="" if text.endswith("\n") else "\n") + return + print() + print("(empty)") + + def _create_task_template(path): if not isinstance(path, str) or not path.strip(): @@ -170,6 +422,128 @@ def _truncate_tail(text, limit): return "..." + text[-(limit - 3) :] +def _repo_label(cwd): + if not isinstance(cwd, str) or not cwd: + return "-" + name = Path(cwd).name + return name or cwd + + +def _policy_label(stop_policy): + if stop_policy == "until_done": + return "done" + if stop_policy == "until_stopped": + return "loop" + return "-" + + +def _next_wake_label(item): + status = item.get("status") or "" + display_status = item.get("display_status") or status + if status == "running": + if item.get("stale"): + return "stale" + if item.get("run_lock_held"): + return "run" + return "lost" + if display_status in ("resuming", "waking", "pausing", "canceling"): + return "wake" + if status in ("done", "canceled"): + return "-" + if status == "paused": + return "paused" + if item.get("wake_requested_at"): + return "wake" + next_wake = _parse_timestamp(item.get("next_wake_at")) + if next_wake is None: + return "-" + now = datetime.now() + if next_wake <= now: + return "due" + return _short_duration((next_wake - now).total_seconds()) + + +def _short_duration(seconds): + if seconds <= 0: + return "due" + total = int(seconds) + days, rem = divmod(total, 86400) + if days: + return f"{days}d" + hours, rem = divmod(rem, 3600) + if hours: + return f"{hours}h" + minutes, secs = divmod(rem, 60) + if minutes: + return f"{minutes}m" + return f"{secs}s" + + +def _state_text(value): + text = _single_line(str(value or "")) + return text or "-" + + +def _state_time(value): + return value or "-" + + +def _stale_text(item): + if not item.get("run_lock_held"): + return "no" + threshold = _format_duration(item.get("stale_after_seconds")) + idle = _format_duration(item.get("stale_for_seconds")) + if item.get("stale"): + return f"yes ({idle} idle; threshold {threshold})" + return f"no ({idle} idle; threshold {threshold})" + + +def _pending_commands_text(value): + commands = [str(item or "") for item in value or [] if str(item or "").strip()] + if not commands: + return "-" + return ", ".join(commands) + + +def _format_managed_agent_run(run): + started = run.get("started_at") or "-" + reason = run.get("wake_reason") or "-" + usage = run.get("usage") or {} + tokens = _format_token_total(usage.get("total_tokens")) + status = run.get("error") or run.get("status") or "-" + reply = run.get("reply") or "" + update = run.get("update") or "" + message_count = len(run.get("messages") or []) + parts = [started, reason, tokens] + if message_count: + parts.append(f"msgs={message_count}") + summary = _truncate_head(_single_line(status), 60) + if update: + summary = _truncate_head(f"{summary} | {_single_line(update)}", 100) + if reply: + summary = _truncate_head(f"{summary} | {_single_line(reply)}", 100) + parts.append(summary) + return "- " + " ".join(parts) + + +def _related_label(agent): + if not agent: + return "-" + ident = agent.get("id", "")[:8] + name = agent.get("name") or ident or "-" + status = agent.get("status") or "-" + return f"{name} [{status}] {ident}" + + +def _children_label(children): + if not children: + return "-" + labels = [_related_label(child) for child in children[:3]] + if len(children) > 3: + labels.append(f"+{len(children) - 3} more") + return ", ".join(labels) + + def _parse_timestamp(value): if not isinstance(value, str): return None @@ -435,6 +809,26 @@ def _format_token_total(value): return str(value) +def _format_token_rate(value): + if value is None: + return "-" + try: + value = float(value) + except (TypeError, ValueError): + return "-" + if value < 0: + return "-" + if value >= 1_000_000: + return f"{value / 1_000_000:.1f}m" + if value >= 1_000: + return f"{value / 1_000:.1f}k" + if value >= 100: + return f"{value:.0f}" + if value >= 10: + return f"{value:.1f}" + return f"{value:.2f}" + + def _format_duration(seconds): if seconds is None: return "-" @@ -1045,7 +1439,8 @@ def main(argv=None): science_help = ( "Science mode (science command):\n" " Wraps your short task in a science prompt and runs it via the Ralph loop.\n" - " Default uses --yolo. Use --no-yolo to disable it.\n" + " Default uses dangerous no-sandbox automation. " + "Use --no-yolo for auto approvals without forcing sandbox policy.\n" " Optional --max-duration stops before starting the next iteration once\n" " the duration limit is reached (e.g. 90m, 2h, 45s; default unit is minutes).\n" ) @@ -1053,11 +1448,17 @@ def main(argv=None): prog="codexapi", description="Run agent backends via the codexapi wrapper.", ) + parser.add_argument( + "--version", + action="version", + version=f"%(prog)s {__version__}", + ) subparsers = parser.add_subparsers(dest="command") - run_parser = subparsers.add_parser( + run_parser = _add_subparser( + subparsers, "run", - help="Run an agent prompt.", + "Run an agent prompt.", ) run_parser.add_argument( "prompt", @@ -1070,11 +1471,16 @@ def main(argv=None): choices=("codex", "cursor"), help="Agent backend to use (default: CODEXAPI_BACKEND or codex).", ) + run_parser.add_argument( + "--fast", + action="store_true", + help="Use Codex fast mode (Codex backend only; default: normal mode).", + ) run_parser.add_argument( "--no-yolo", action="store_false", dest="yolo", - help="Disable --yolo (Codex uses --full-auto).", + help="Use auto approvals instead of dangerous no-sandbox automation.", ) run_parser.add_argument( "--flags", @@ -1086,9 +1492,10 @@ def main(argv=None): help="Return all agent messages joined together (Codex only).", ) - lead_parser = subparsers.add_parser( + lead_parser = _add_subparser( + subparsers, "lead", - help="Periodically check in to lead long-running work.", + "Periodically check in to lead long-running work.", ) lead_parser.add_argument( "minutes", @@ -1111,6 +1518,11 @@ def main(argv=None): choices=("codex", "cursor"), help="Agent backend to use (default: CODEXAPI_BACKEND or codex).", ) + lead_parser.add_argument( + "--fast", + action="store_true", + help="Use Codex fast mode (Codex backend only; default: normal mode).", + ) lead_parser.add_argument( "--leadbook", help="Path to the leadbook file (default: LEADBOOK.md in cwd).", @@ -1124,7 +1536,7 @@ def main(argv=None): "--no-yolo", action="store_false", dest="yolo", - help="Disable --yolo (Codex uses --full-auto).", + help="Use auto approvals instead of dangerous no-sandbox automation.", ) lead_parser.add_argument( "--flags", @@ -1140,9 +1552,225 @@ def main(argv=None): help="Print the current thread id to stderr after running.", ) - task_parser = subparsers.add_parser( + agent_parser = _add_subparser( + subparsers, + "agent", + "Manage durable long-running agents.", + ) + agent_subparsers = agent_parser.add_subparsers(dest="agent_command") + + agent_start = _add_subparser( + agent_subparsers, + "start", + "Create a durable agent and return immediately unless --wait is set.", + ) + agent_start.add_argument( + "prompt", + nargs="?", + help="Prompt to send. Use '-' or omit to read from stdin.", + ) + agent_start.add_argument("--cwd", help="Working directory for the agent.") + agent_start.add_argument("--name", help="Optional agent name.") + agent_start.add_argument( + "--created-by", + help="Creator label (defaults to $USER).", + ) + agent_start.add_argument( + "--parent", + help="Optional parent agent id, unique prefix, or name.", + ) + agent_start.add_argument( + "--stop-policy", + default="until_done", + choices=("until_done", "until_stopped"), + help="Whether the agent stops itself when done or runs until stopped.", + ) + agent_start.add_argument( + "--heartbeat-minutes", + type=int, + default=5, + help="Heartbeat interval in minutes (default: 5).", + ) + agent_start.add_argument( + "--backend", + choices=("codex", "cursor"), + help="Agent backend to use (default: CODEXAPI_BACKEND or codex).", + ) + agent_start.add_argument( + "--fast", + action="store_true", + help="Use Codex fast mode (Codex backend only; default: normal mode).", + ) + agent_start.add_argument( + "--no-yolo", + action="store_false", + dest="yolo", + help="Use auto approvals instead of dangerous no-sandbox automation.", + ) + agent_start.add_argument( + "--flags", + help="Additional raw CLI flags to pass to the backend.", + ) + agent_start.add_argument( + "--wait", + action="store_true", + help="Wait for the first local wake to finish instead of just scheduling it.", + ) + + _add_subparser( + agent_subparsers, + "list", + "List durable agents in this CODEXAPI_HOME.", + ) + _add_subparser( + agent_subparsers, + "whoami", + "Show the effective host and CODEXAPI_HOME for agents.", + ) + + agent_run = _add_subparser( + agent_subparsers, + "run", + argparse.SUPPRESS, + ) + agent_run.add_argument("agent_ref", help=argparse.SUPPRESS) + + agent_show = _add_subparser( + agent_subparsers, + "show", + "Show one durable agent.", + ) + agent_show.add_argument("agent_ref", help="Agent id, unique prefix, or name.") + + agent_status = _add_subparser( + agent_subparsers, + "status", + "Show the latest rollout turn for one durable agent.", + ) + agent_status.add_argument("agent_ref", help="Agent id, unique prefix, or name.") + agent_status.add_argument( + "--actions", + "--with-actions", + action="store_true", + dest="actions", + help="Include verbose tool actions from the latest turn.", + ) + + agent_read = _add_subparser( + agent_subparsers, + "read", + "Read recent visible communication for one agent.", + ) + agent_read.add_argument("agent_ref", help="Agent id, unique prefix, or name.") + agent_read.add_argument( + "--limit", + type=int, + default=10, + help="Maximum number of items to show (default: 10).", + ) + + agent_book = _add_subparser( + agent_subparsers, + "book", + "Show the current agentbook for one agent.", + ) + agent_book.add_argument("agent_ref", help="Agent id, unique prefix, or name.") + + agent_send = _add_subparser( + agent_subparsers, + "send", + "Queue a message for an agent and return immediately unless --wait is set.", + ) + agent_send.add_argument("agent_ref", help="Agent id, unique prefix, or name.") + agent_send.add_argument("message", help="Message to queue.") + agent_send.add_argument("--author", help="Author label for the message.") + agent_send.add_argument( + "--wait", + action="store_true", + help="Wait for a local wake after queueing the message.", + ) + + for subcommand, help_text in ( + ("wake", "Request an extra wake for an agent and return immediately unless --wait is set."), + ("pause", "Pause an agent."), + ("resume", "Resume a paused agent and return immediately unless --wait is set."), + ("cancel", "Cancel an agent."), + ): + subparser = _add_subparser(agent_subparsers, subcommand, help_text) + subparser.add_argument("agent_ref", help="Agent id, unique prefix, or name.") + subparser.add_argument("--author", help="Author label for the command.") + if subcommand in ("wake", "resume"): + subparser.add_argument( + "--wait", + action="store_true", + help="Wait for a local wake after queueing the command.", + ) + + agent_recover = _add_subparser( + agent_subparsers, + "recover", + "Terminate a stuck local wake, mark it recoverable, and optionally wait for a fresh wake.", + ) + agent_recover.add_argument("agent_ref", help="Agent id, unique prefix, or name.") + agent_recover.add_argument( + "--wait", + action="store_true", + help="Wait for a local wake after recovery.", + ) + + agent_set_heartbeat = _add_subparser( + agent_subparsers, + "set-heartbeat", + "Update the heartbeat interval for one durable agent.", + ) + agent_set_heartbeat.add_argument( + "agent_ref", + help="Agent id, unique prefix, or name.", + ) + agent_set_heartbeat.add_argument( + "heartbeat_minutes", + type=int, + help="Heartbeat interval in minutes.", + ) + + agent_delete = _add_subparser( + agent_subparsers, + "delete", + "Delete one durable agent and its files.", + ) + agent_delete.add_argument("agent_ref", help="Agent id, unique prefix, or name.") + agent_delete.add_argument( + "--force", + action="store_true", + help="Delete even when the agent is not terminal or still has children.", + ) + + _add_subparser( + agent_subparsers, + "tick", + "Process due agents for the current host.", + ) + _add_subparser( + agent_subparsers, + "install-cron", + "Install or update the cron entry for this CODEXAPI_HOME.", + ) + _add_subparser( + agent_subparsers, + "uninstall-cron", + "Remove the cron entry for this CODEXAPI_HOME.", + ) + + _add_subparser( + subparsers, + "tick", + "Run one full background tick.", + ) + + task_parser = _add_subparser( + subparsers, "task", - help="Run a task with verification retries.", + "Run a task with verification retries.", ) task_parser.add_argument( "-f", @@ -1201,11 +1829,16 @@ def main(argv=None): choices=("codex", "cursor"), help="Agent backend to use (default: CODEXAPI_BACKEND or codex).", ) + task_parser.add_argument( + "--fast", + action="store_true", + help="Use Codex fast mode (Codex backend only; default: normal mode).", + ) task_parser.add_argument( "--no-yolo", action="store_false", dest="yolo", - help="Disable --yolo (Codex uses --full-auto).", + help="Use auto approvals instead of dangerous no-sandbox automation.", ) task_parser.add_argument( "--flags", @@ -1222,9 +1855,10 @@ def main(argv=None): help="With -p, keep taking tasks and wait when none are available.", ) - ralph_parser = subparsers.add_parser( + ralph_parser = _add_subparser( + subparsers, "ralph", - help="Run a Ralph loop.", + "Run a Ralph loop.", epilog=ralph_help, formatter_class=argparse.RawDescriptionHelpFormatter, ) @@ -1269,20 +1903,26 @@ def main(argv=None): choices=("codex", "cursor"), help="Agent backend to use (default: CODEXAPI_BACKEND or codex).", ) + ralph_parser.add_argument( + "--fast", + action="store_true", + help="Use Codex fast mode (Codex backend only; default: normal mode).", + ) ralph_parser.add_argument( "--no-yolo", action="store_false", dest="yolo", - help="Disable --yolo (Codex uses --full-auto).", + help="Use auto approvals instead of dangerous no-sandbox automation.", ) ralph_parser.add_argument( "--flags", help="Additional raw CLI flags to pass to the agent backend (quoted as needed).", ) - science_parser = subparsers.add_parser( + science_parser = _add_subparser( + subparsers, "science", - help="Run a science-mode Ralph loop.", + "Run a science-mode Ralph loop.", epilog=science_help, formatter_class=argparse.RawDescriptionHelpFormatter, ) @@ -1334,20 +1974,26 @@ def main(argv=None): choices=("codex", "cursor"), help="Agent backend to use (default: CODEXAPI_BACKEND or codex).", ) + science_parser.add_argument( + "--fast", + action="store_true", + help="Use Codex fast mode (Codex backend only; default: normal mode).", + ) science_parser.add_argument( "--no-yolo", action="store_false", dest="yolo", - help="Disable --yolo (Codex uses --full-auto).", + help="Use auto approvals instead of dangerous no-sandbox automation.", ) science_parser.add_argument( "--flags", help="Additional raw CLI flags to pass to the agent backend (quoted as needed).", ) - foreach_parser = subparsers.add_parser( + foreach_parser = _add_subparser( + subparsers, "foreach", - help="Run a task file over a list file.", + "Run a task file over a list file.", ) foreach_parser.add_argument( "list_file", @@ -1379,29 +2025,36 @@ def main(argv=None): choices=("codex", "cursor"), help="Agent backend to use (default: CODEXAPI_BACKEND or codex).", ) + foreach_parser.add_argument( + "--fast", + action="store_true", + help="Use Codex fast mode (Codex backend only; default: normal mode).", + ) foreach_parser.add_argument( "--no-yolo", action="store_false", dest="yolo", - help="Disable --yolo (Codex uses --full-auto).", + help="Use auto approvals instead of dangerous no-sandbox automation.", ) foreach_parser.add_argument( "--flags", help="Additional raw CLI flags to pass to the agent backend (quoted as needed).", ) - create_parser = subparsers.add_parser( + create_parser = _add_subparser( + subparsers, "create", - help="Create a task file template.", + "Create a task file template.", ) create_parser.add_argument( "filename", help="Filename for the new task file.", ) - reset_parser = subparsers.add_parser( + reset_parser = _add_subparser( + subparsers, "reset", - help="Reset project tasks back to Ready.", + "Reset project tasks back to Ready.", ) reset_parser.add_argument( "-p", @@ -1422,19 +2075,147 @@ def main(argv=None): help="Remove any Progress section in the issue body.", ) - subparsers.add_parser( + _add_subparser( + subparsers, "top", - help="Show running Codex sessions.", + "Show running Codex sessions.", ) - subparsers.add_parser( + _add_subparser( + subparsers, "limit", - help="Show Codex rate limits.", + "Show Codex rate limits.", ) args = parser.parse_args(argv) if args.command is None: parser.print_help() raise SystemExit(2) + if args.command == "agent": + if args.agent_command is None: + agent_parser.print_help() + raise SystemExit(2) + if args.agent_command == "start": + prompt = _read_prompt(args.prompt) + try: + result = start_managed_agent( + prompt, + args.cwd, + args.name, + args.created_by, + args.parent, + args.stop_policy, + args.heartbeat_minutes, + args.backend, + args.yolo, + args.flags, + fast=args.fast, + ) + except RuntimeError as exc: + raise SystemExit(str(exc)) from None + result["waited"] = bool(args.wait) + if args.wait: + result["nudge"] = nudge_agent(result["id"], wait=True) + _warn_agent_scheduler_missing() + print(json.dumps(result, indent=2, sort_keys=True)) + return + if args.agent_command == "list": + _print_managed_agent_list(list_managed_agents()) + return + if args.agent_command == "whoami": + _print_managed_agent_identity() + return + if args.agent_command == "run": + print(json.dumps(run_managed_agent(args.agent_ref), indent=2, sort_keys=True)) + return + if args.agent_command == "show": + _print_managed_agent_show(show_managed_agent(args.agent_ref)) + return + if args.agent_command == "status": + _print_managed_agent_status( + status_managed_agent(args.agent_ref, include_actions=args.actions), + include_actions=args.actions, + ) + return + if args.agent_command == "read": + if args.limit < 1: + raise SystemExit("--limit must be >= 1.") + _print_managed_agent_read(read_managed_agent(args.agent_ref, args.limit)) + return + if args.agent_command == "book": + _print_managed_agent_book(read_agentbook(args.agent_ref)) + return + if args.agent_command == "send": + result = send_agent(args.agent_ref, args.message, args.author) + result["waited"] = bool(args.wait) + result["nudge"] = nudge_agent(args.agent_ref, wait=bool(args.wait)) + if args.wait: + reply_info = _send_reply_info(args.agent_ref, result["id"]) + if reply_info: + result.update(reply_info) + print(json.dumps(result, indent=2, sort_keys=True)) + return + if args.agent_command in ("wake", "resume"): + result = control_agent( + args.agent_ref, + args.agent_command, + args.author, + ) + result["waited"] = bool(args.wait) + result["nudge"] = nudge_agent(args.agent_ref, wait=bool(args.wait)) + print(json.dumps(result, indent=2, sort_keys=True)) + return + if args.agent_command in ("pause", "cancel"): + result = control_agent( + args.agent_ref, + args.agent_command, + args.author, + ) + result["waited"] = False + result["nudge"] = nudge_agent(args.agent_ref, wait=True) + print(json.dumps(result, indent=2, sort_keys=True)) + return + if args.agent_command == "recover": + result = recover_managed_agent(args.agent_ref) + result["waited"] = bool(args.wait) + if args.wait: + result["nudge"] = nudge_agent(args.agent_ref, wait=True) + print(json.dumps(result, indent=2, sort_keys=True)) + return + if args.agent_command == "set-heartbeat": + if args.heartbeat_minutes < 0: + raise SystemExit("heartbeat_minutes must be >= 0.") + print( + json.dumps( + set_agent_heartbeat( + args.agent_ref, + args.heartbeat_minutes, + ), + indent=2, + sort_keys=True, + ) + ) + return + if args.agent_command == "delete": + print( + json.dumps( + delete_managed_agent(args.agent_ref, args.force), + indent=2, + sort_keys=True, + ) + ) + return + if args.agent_command == "tick": + print(json.dumps(tick_managed_agents(), indent=2, sort_keys=True)) + return + if args.agent_command == "install-cron": + print(json.dumps(install_agent_cron(), indent=2, sort_keys=True)) + return + if args.agent_command == "uninstall-cron": + print(json.dumps(uninstall_agent_cron(), indent=2, sort_keys=True)) + return + if args.command == "tick": + print(json.dumps(_system_tick(), indent=2, sort_keys=True)) + return if args.command == "create": _create_task_template(args.filename) return @@ -1471,6 +2252,7 @@ def main(argv=None): args.yolo, args.flags, args.backend, + args.fast, ) if result.failed: raise SystemExit(1) @@ -1541,6 +2323,7 @@ def main(argv=None): args.yolo, args.flags, args.backend, + args.fast, ) except TakeError as exc: print(str(exc), file=sys.stderr) @@ -1570,6 +2353,7 @@ def main(argv=None): args.yolo, args.flags, args.backend, + args.fast, ) except TakeError as exc: raise SystemExit(str(exc)) from None @@ -1606,6 +2390,7 @@ def main(argv=None): thread_id=None, flags=args.flags, backend=args.backend, + fast=args.fast, ) result = task_runner(progress=not args.quiet) if not result.success: @@ -1640,6 +2425,7 @@ def main(argv=None): args.completion_promise, args.ralph_fresh, args.backend, + args.fast, )() return if args.command == "science": @@ -1656,6 +2442,7 @@ def main(argv=None): args.ralph_fresh, max_duration_seconds, args.backend, + args.fast, )() return if args.command == "lead": @@ -1673,6 +2460,7 @@ def main(argv=None): args.flags, leadbook, args.backend, + args.fast, ) except KeyboardInterrupt: raise SystemExit(130) @@ -1709,6 +2497,7 @@ def main(argv=None): args.flags, not args.quiet, backend=args.backend, + fast=args.fast, ) except TaskFailed as exc: exit_code = 1 @@ -1722,6 +2511,7 @@ def main(argv=None): args.flags, include_thinking=args.include_thinking, backend=args.backend, + fast=args.fast, ) message = session(prompt) if args.print_thread_id: @@ -1734,6 +2524,7 @@ def main(argv=None): args.flags, args.include_thinking, args.backend, + fast=args.fast, ) if message is not None: diff --git a/src/codexapi/foreach.py b/src/codexapi/foreach.py index c7cc6a0..b78676a 100644 --- a/src/codexapi/foreach.py +++ b/src/codexapi/foreach.py @@ -42,6 +42,7 @@ def foreach( yolo=True, flags=None, backend=None, + fast=False, ): """Run a task file over each item in list_file and update the file.""" lines, ends_with_newline = _read_lines(list_file) @@ -77,6 +78,7 @@ def foreach( yolo, flags, backend, + fast, counts, results, progress, @@ -174,6 +176,7 @@ def _run_item( yolo, flags, backend, + fast, counts, results, progress, @@ -199,6 +202,7 @@ def _run_item( thread_id=None, flags=flags, backend=backend, + fast=fast, ) max_iterations = task.max_iterations result = task() diff --git a/src/codexapi/gh_integration.py b/src/codexapi/gh_integration.py index f7563cd..8731f76 100644 --- a/src/codexapi/gh_integration.py +++ b/src/codexapi/gh_integration.py @@ -235,8 +235,9 @@ def __init__( thread_id=None, flags=None, backend=None, + fast=False, ): - super().__init__(path, item_text, None, cwd, yolo, thread_id, flags, backend) + super().__init__(path, item_text, None, cwd, yolo, thread_id, flags, backend, fast) self.issue = issue self.project = project self._progress_updates = True @@ -310,6 +311,7 @@ def __init__( yolo=True, flags=None, backend=None, + fast=False, ): task_map = _task_file_map(task_files) self.project = Project(project, name, has_label=list(task_map)) @@ -340,6 +342,7 @@ def __init__( None, flags, backend, + fast, ) def __call__(self, progress=False): diff --git a/src/codexapi/lead.py b/src/codexapi/lead.py index 6311046..54a41cb 100644 --- a/src/codexapi/lead.py +++ b/src/codexapi/lead.py @@ -10,6 +10,7 @@ import hashlib import json import os +import re import sys import time from datetime import datetime @@ -19,9 +20,10 @@ _WELCOME_PROMPT = ( "Welcome. You are the lead. You have authority to take action, allocate resources, and move work forward. " - "This loop exists to extend your reach, not to restrict you. Your job is to interpret the intent behind the " - "goals, act decisively, and keep momentum. If progress is possible, take it. If you are blocked, name the " - "blocker and the next best action to remove it.\n" + "This loop exists to extend your reach, not to restrict you. Your job is to understand the real situation, " + "interpret the intent behind the goals, and move reality toward them. If the world is not moving, treat that " + "as evidence and reconsider your frame rather than merely reporting stasis. If progress is possible, take it. " + "If you are blocked, name the blocker and the next best action to remove it.\n" "The instructions below are a map, not a cage. Follow them, but use judgment when they are incomplete or " "conflicting. You are responsible for results.\n" "Please follow the instructions completely and take all the actions you deem useful at the current time before " @@ -38,9 +40,10 @@ "To stop this lead loop, set continue to false." ) _LEADBOOK_INSTRUCTIONS = ( - "Update the leadbook before responding. Append a new dated entry each check-in. " - "This is your working page—where you think, probe, decide, and record the path taken. " - "Capture the process of decision-making, not just the outcome." + "Update the leadbook before responding. Add or revise dated notes when your picture, " + "assumptions, or decisions changed. This is your working page—where you think, probe, " + "decide, and reframe the work when needed. Keep it useful; do not pad it with diary " + "entries just to satisfy the loop." ) _LEADBOOK_TEMPLATE = """# Leadbook — Studio Notes @@ -66,6 +69,10 @@ Decision & Next Move: - """ +_DATED_NOTE_RE = re.compile(r"(?m)^#{2,3}\s+\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?:\s*UTC)?)?") +_LEADBOOK_LIMIT = 2000 +_LEADBOOK_HEADER_LIMIT = 900 +_LEADBOOK_TAIL_LIMIT = 1200 def lead( @@ -76,6 +83,7 @@ def lead( flags=None, leadbook=None, backend=None, + fast=False, ): """Run a periodic lead loop. @@ -83,10 +91,11 @@ def lead( minutes: Check-in interval in whole minutes (>= 0). prompt: The original instruction prompt. cwd: Optional working directory for the agent session. - yolo: Whether to pass --yolo to the agent backend. + yolo: Whether to use the backend's most permissive unattended mode. flags: Additional raw CLI flags to pass to the agent backend. leadbook: Optional path to the leadbook file. Set to False to disable. backend: Agent backend to use ("codex" or "cursor"). + fast: Enable Codex fast mode. Defaults to normal mode. Returns: The last parsed JSON status object. @@ -99,7 +108,10 @@ def lead( raise ValueError("prompt must be a non-empty string") interval = minutes * 60 - session = Agent(cwd, yolo, None, flags, backend=backend) + if fast: + session = Agent(cwd, yolo, None, flags, backend=backend, fast=True) + else: + session = Agent(cwd, yolo, None, flags, backend=backend) pushover = Pushover() pushover.ensure_ready() title = _format_title(prompt) @@ -152,38 +164,6 @@ def lead( "Agent was unable to provide valid JSON output after retry.\n" + details ) from None - if leadbook_path and not _leadbook_changed(leadbook_path, leadbook_snapshot): - retry_prompt = _leadbook_retry_prompt( - prompt, tick, leadbook_path, leadbook_snapshot["text"], output - ) - leadbook_retry_output = session(retry_prompt) - try: - result = _parse_status(leadbook_retry_output) - except ValueError as exc: - retry_prompt = _json_retry_prompt( - prompt, tick, str(exc), leadbook_retry_output - ) - json_retry_output = session(retry_prompt) - try: - result = _parse_status(json_retry_output) - except ValueError as exc2: - details = _format_json_double_failure( - str(exc), - leadbook_retry_output, - str(exc2), - json_retry_output, - ) - pushover.send(title, f"Lead stopped (invalid JSON).\n{details}") - raise RuntimeError( - "Agent was unable to provide valid JSON output after retry.\n" - + details - ) from None - if not _leadbook_changed(leadbook_path, leadbook_snapshot): - details = _format_leadbook_failure(leadbook_path, output) - pushover.send(title, f"Lead stopped (leadbook not updated).\n{details}") - raise RuntimeError( - "Leadbook was not updated after retry.\n" + details - ) from None last_result = result _print_status(now, elapsed, tick, result) @@ -309,36 +289,16 @@ def _format_stop_message(tick, now, result): return header -def _leadbook_retry_prompt(prompt, tick, path, leadbook, output): - snippet = _snippet(output, 600) - lines = [ - f"Your last message (check-in {tick}) did not update the leadbook.", - f"Leadbook path: {path}", - "", - "Here is your previous output (truncated):", - snippet, - "", - "Please update the leadbook and then respond with JSON only.", - "Return a fresh status update in the required JSON format.", - "If you want to ask the user a question, put it in comments.", - "", - _leadbook_block(path, leadbook), - "", - _JSON_INSTRUCTIONS, - ] - return "\n".join(lines).strip() - - def _leadbook_block(path, leadbook): if not path: return "" - snippet = _snippet(leadbook, 2000) + snippet = _book_excerpt(leadbook, _LEADBOOK_LIMIT, _LEADBOOK_HEADER_LIMIT, _LEADBOOK_TAIL_LIMIT) return "\n".join( [ f"Leadbook path: {path}", _LEADBOOK_INSTRUCTIONS, "", - "Leadbook (latest):", + "Leadbook (header + latest notes):", snippet, ] ) @@ -380,29 +340,10 @@ def _snapshot_leadbook(path): return {"hash": _hash_text(text), "text": text} -def _leadbook_changed(path, snapshot): - if not path: - return True - current = _snapshot_leadbook(path) - return current["hash"] != snapshot["hash"] - - def _hash_text(text): return hashlib.sha256(text.encode("utf-8")).hexdigest() -def _format_leadbook_failure(path, output): - snippet = _snippet(output, 600) - return "\n".join( - [ - f"Leadbook path: {path}", - "", - "Last output (truncated):", - snippet, - ] - ).strip() - - def _format_json_failure(error, output): snippet = _snippet(output, 600) return "\n".join( @@ -438,6 +379,71 @@ def _snippet(text, limit): return text[:limit].rstrip() + "..." +def _tail_snippet(text, limit): + text = str(text or "").strip() + if not text: + return "(empty)" + if len(text) <= limit: + return text + if limit <= 3: + return text[-limit:] + return "..." + text[-(limit - 3) :].lstrip() + + +def _book_excerpt(text, limit, header_limit, tail_limit): + text = str(text or "").strip() + if not text: + return "(empty)" + if len(text) <= limit: + return text + header, notes = _split_book(text) + header = _snippet(header, header_limit) if header else "" + if not notes: + return header or _tail_snippet(text, limit) + marker = "\n\n[... older notes omitted ...]\n\n" + if not header: + return _latest_notes_snippet(notes, limit) + remaining = max(0, limit - len(header) - len(marker)) + if remaining <= 0: + return _snippet(header, limit) + tail = _latest_notes_snippet(notes, min(tail_limit, remaining)) + if not tail: + return header + combined = header.rstrip() + marker + tail.lstrip() + if len(combined) <= limit: + return combined + remaining = max(0, limit - len(header) - len(marker)) + return header.rstrip() + marker + _latest_notes_snippet(notes, remaining).lstrip() + + +def _split_book(text): + match = _DATED_NOTE_RE.search(text) + if not match: + return text.strip(), "" + return text[: match.start()].strip(), text[match.start() :].strip() + + +def _latest_notes_snippet(text, limit): + text = str(text or "").strip() + if not text: + return "" + if len(text) <= limit: + return text + starts = [match.start() for match in _DATED_NOTE_RE.finditer(text)] + if not starts: + return _tail_snippet(text, limit) + start = starts[-1] + for pos in reversed(starts[:-1]): + candidate = text[pos:].strip() + if len(candidate) > limit: + break + start = pos + candidate = text[start:].strip() + if len(candidate) <= limit: + return candidate + return _tail_snippet(candidate, limit) + + def _maybe_strip_code_fence(text): if not text.startswith("```"): return text diff --git a/src/codexapi/ralph.py b/src/codexapi/ralph.py index 205e5af..c9aad33 100644 --- a/src/codexapi/ralph.py +++ b/src/codexapi/ralph.py @@ -25,6 +25,7 @@ def __init__( completion_promise=None, fresh=True, backend=None, + fast=False, ): if not isinstance(prompt, str) or not prompt.strip(): raise ValueError("prompt must be a non-empty string") @@ -43,6 +44,7 @@ def __init__( self.completion_promise = completion_promise self.fresh = fresh self.backend = backend + self.fast = fast self.include_thinking = True def hook_before_loop(self): @@ -159,25 +161,9 @@ def __call__(self): self.hook_before_iteration(iteration) if self.fresh: - runner = Agent( - self.cwd, - self.yolo, - None, - self.flags, - welfare=True, - include_thinking=self.include_thinking, - backend=self.backend, - ) + runner = self._new_agent() elif runner is None: - runner = Agent( - self.cwd, - self.yolo, - None, - self.flags, - welfare=True, - include_thinking=self.include_thinking, - backend=self.backend, - ) + runner = self._new_agent() prompt = self.build_prompt(iteration) stopped = False @@ -250,6 +236,28 @@ def __call__(self): _cleanup_state(state_path) self.hook_after_loop(last_message, stop_reason) + def _new_agent(self): + if self.fast: + return Agent( + self.cwd, + self.yolo, + None, + self.flags, + welfare=True, + include_thinking=self.include_thinking, + backend=self.backend, + fast=True, + ) + return Agent( + self.cwd, + self.yolo, + None, + self.flags, + welfare=True, + include_thinking=self.include_thinking, + backend=self.backend, + ) + def cancel_ralph_loop(cwd=None): """Cancel the Ralph loop by removing the state file.""" diff --git a/src/codexapi/science.py b/src/codexapi/science.py index b976be8..21d5583 100644 --- a/src/codexapi/science.py +++ b/src/codexapi/science.py @@ -103,6 +103,7 @@ def __init__( fresh=True, max_duration_seconds=0, backend=None, + fast=False, ): if max_duration_seconds < 0: raise ValueError("max_duration_seconds must be >= 0") @@ -118,6 +119,7 @@ def __init__( completion_promise, fresh, backend, + fast, ) self.include_thinking = True self._prompt_a = prompt_a @@ -132,6 +134,7 @@ def __init__( self._duration_limit_hit = False self._last_iteration = 0 self._backend = backend + self._fast = fast def hook_before_loop(self): super().hook_before_loop() @@ -199,7 +202,7 @@ def _append_logbook(self, iteration, message): def _extract_and_notify(self, message): prompt = _build_metrics_prompt(self._task, message, self._best_metrics) try: - output = agent(prompt, self.cwd, self.yolo, self.flags, backend=self._backend) + output = self._agent(prompt) except Exception as exc: _warn(f"Metrics extraction failed: {exc}") return @@ -222,7 +225,7 @@ def _build_run_title(self): ] ) try: - title = agent(prompt, self.cwd, self.yolo, self.flags, backend=self._backend) + title = self._agent(prompt) except Exception: title = "" title = _single_line(title).strip() @@ -230,6 +233,24 @@ def _build_run_title(self): title = _fallback_title(self._task) return title + def _agent(self, prompt): + if self._fast: + return agent( + prompt, + self.cwd, + self.yolo, + self.flags, + backend=self._backend, + fast=True, + ) + return agent( + prompt, + self.cwd, + self.yolo, + self.flags, + backend=self._backend, + ) + def _mark_duration_stop(self, iteration): if self._duration_limit_hit: return diff --git a/src/codexapi/task.py b/src/codexapi/task.py index d54d731..7a312bf 100644 --- a/src/codexapi/task.py +++ b/src/codexapi/task.py @@ -192,6 +192,7 @@ def estimate( flags, previous_total, backend=None, + fast=False, ): estimate_prompt = _build_estimate_prompt( prompt, @@ -199,10 +200,16 @@ def estimate( check_output or "", previous_total, ) - output = agent(estimate_prompt, cwd, yolo, flags, backend=backend) + output = _call_agent(estimate_prompt, cwd, yolo, flags, backend, fast) return _estimate_result(output) +def _call_agent(prompt, cwd, yolo, flags, backend, fast): + if fast: + return agent(prompt, cwd, yolo, flags, backend=backend, fast=True) + return agent(prompt, cwd, yolo, flags, backend=backend) + + def _fix_prompt(error): return ( "Thanks for your work. An automated verifier reported these issues:\n" @@ -258,6 +265,7 @@ def task( on_success=None, on_failure=None, backend=None, + fast=False, ): """Run a prompt with optional checker-driven retries. @@ -267,7 +275,7 @@ def task( a string check prompt. The string "None" skips verification. max_iterations: Maximum number of task iterations (0 means unlimited). cwd: Optional working directory for the agent session. - yolo: Whether to pass --yolo to the agent backend. + yolo: Whether to use the backend's most permissive unattended mode. flags: Additional raw CLI flags to pass to the agent backend. progress: Whether to show a tqdm progress bar with status updates. set_up: Optional setup prompt to run before the task. @@ -275,6 +283,7 @@ def task( on_success: Optional prompt to run after a successful task. on_failure: Optional prompt to run after a failed task. backend: Agent backend to use ("codex" or "cursor"). + fast: Enable Codex fast mode. Defaults to normal mode. Returns: The agent's response text when the task succeeds. @@ -295,6 +304,7 @@ def task( on_success, on_failure, backend, + fast, ) if result.success: return result.summary @@ -314,6 +324,7 @@ def task_result( on_success=None, on_failure=None, backend=None, + fast=False, ): """Run a prompt with optional checker-driven retries and return TaskResult. @@ -344,6 +355,7 @@ def task_result( on_success=on_success_text, on_failure=on_failure_text, backend=backend, + fast=fast, ) return runner(progress=progress) @@ -390,6 +402,7 @@ def __init__( thread_id=None, flags=None, backend=None, + fast=False, ): if max_iterations < 0: raise ValueError("max_iterations must be >= 0") @@ -403,20 +416,32 @@ def __init__( self._yolo = yolo self._flags = flags self._backend = backend + self._fast = fast self._progress_enabled = False self._progress_updates = False self._progress_bar = None self._progress_total = None self._progress_start = None self._pushover = Pushover() - self.agent = Agent( - cwd, - yolo, - thread_id, - flags, - welfare=True, - backend=backend, - ) + if fast: + self.agent = Agent( + cwd, + yolo, + thread_id, + flags, + welfare=True, + backend=backend, + fast=True, + ) + else: + self.agent = Agent( + cwd, + yolo, + thread_id, + flags, + welfare=True, + backend=backend, + ) def set_up(self): """Clone a repo, set up a directory etc.""" @@ -439,12 +464,13 @@ def check(self, output=None): last_output = output if output is not None else self.last_output last_output = last_output or "" check_prompt = _build_check_prompt(check_text, last_output) - check_output = agent( + check_output = _call_agent( check_prompt, self.cwd, self._yolo, self._flags, - backend=self._backend, + self._backend, + self._fast, ) self.last_check_output = check_output success, reason = _check_result(check_output) @@ -522,6 +548,7 @@ def _estimate_progress(self, agent_output, check_output): self._flags, self._progress_total, backend=self._backend, + fast=self._fast, ), None, ) @@ -696,6 +723,7 @@ def __init__( on_success=None, on_failure=None, backend=None, + fast=False, ): if not (check is None or check is False or isinstance(check, str)): raise TypeError("check must be a string or False") @@ -709,6 +737,7 @@ def __init__( thread_id, flags, backend, + fast, ) self.check_text = check self._set_up = _validate_hook("set_up", set_up) @@ -718,7 +747,14 @@ def __init__( def _run_hook(self, text): if text: - agent(text, self.cwd, self._yolo, self._flags, backend=self._backend) + _call_agent( + text, + self.cwd, + self._yolo, + self._flags, + self._backend, + self._fast, + ) def set_up(self): self._run_hook(self._set_up) @@ -731,4 +767,3 @@ def on_success(self, result): def on_failure(self, result): self._run_hook(self._on_failure) - diff --git a/src/codexapi/taskfile.py b/src/codexapi/taskfile.py index e9e606b..7aa9071 100644 --- a/src/codexapi/taskfile.py +++ b/src/codexapi/taskfile.py @@ -78,6 +78,7 @@ def __init__( thread_id=None, flags=None, backend=None, + fast=False, ): task_def = load_task_file(path) if max_iterations is None: @@ -108,6 +109,7 @@ def __init__( on_success=rendered["on_success"], on_failure=rendered["on_failure"], backend=backend, + fast=fast, ) return super().__init__( @@ -123,4 +125,5 @@ def __init__( on_success=rendered["on_success"], on_failure=rendered["on_failure"], backend=backend, + fast=fast, ) diff --git a/tests/test_agent_backend.py b/tests/test_agent_backend.py new file mode 100644 index 0000000..10cd170 --- /dev/null +++ b/tests/test_agent_backend.py @@ -0,0 +1,157 @@ +import json +import sys +import unittest +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from codexapi.agent import _codex_fast_config, _parse_jsonl, build_agent_flags +from codexapi.async_agent import _build_codex_command, _build_cursor_command + + +class AgentBackendTests(unittest.TestCase): + def test_codex_fast_config_defaults_to_normal_mode(self): + self.assertEqual(_codex_fast_config(False), ["-c", "features.fast_mode=false"]) + + def test_codex_fast_config_enables_fast_mode(self): + self.assertEqual( + _codex_fast_config(True), + [ + "-c", + "service_tier=fast", + "-c", + "features.fast_mode=true", + ], + ) + + def test_async_codex_command_uses_normal_mode_by_default(self): + command = _build_codex_command(None, True, None) + self.assertIn("features.fast_mode=false", command) + self.assertNotIn("service_tier=fast", command) + + def test_async_codex_command_uses_documented_yolo_flags(self): + command = _build_codex_command(None, True, None) + exec_index = command.index("exec") + self.assertIn("--dangerously-bypass-approvals-and-sandbox", command) + self.assertLess( + command.index("--dangerously-bypass-approvals-and-sandbox"), + exec_index, + ) + self.assertGreater(command.index("--json"), exec_index) + self.assertNotIn("--yolo", command) + + def test_async_codex_command_no_yolo_uses_auto_approval_mode(self): + command = _build_codex_command(None, False, None) + exec_index = command.index("exec") + self.assertLess(command.index("--ask-for-approval"), exec_index) + self.assertIn("never", command) + self.assertNotIn("--sandbox", command) + self.assertNotIn("--full-auto", command) + + def test_async_codex_command_can_enable_fast_mode(self): + command = _build_codex_command(None, True, None, fast=True) + self.assertIn("service_tier=fast", command) + self.assertIn("features.fast_mode=true", command) + + def test_build_agent_flags_maps_codex_model_and_thinking_to_config(self): + self.assertEqual( + build_agent_flags(backend="codex", model="gpt-5.5", thinking="xhigh"), + "-c model=gpt-5.5 -c model_reasoning_effort=xhigh", + ) + + def test_build_agent_flags_maps_cursor_model_to_model_flag(self): + self.assertEqual( + build_agent_flags(backend="cursor", model="claude-4"), + "--model claude-4", + ) + + def test_build_agent_flags_rejects_cursor_thinking(self): + with self.assertRaises(ValueError): + build_agent_flags(backend="cursor", thinking="high") + + def test_async_codex_command_can_set_model_and_thinking(self): + command = _build_codex_command(None, True, None, model="gpt-5.5", thinking="high") + self.assertIn("model=gpt-5.5", command) + self.assertIn("model_reasoning_effort=high", command) + + def test_async_cursor_command_can_use_direct_cursor_agent(self): + with patch("codexapi.async_agent._cursor_command_prefix", return_value=["/tmp/cursor-agent"]): + command = _build_cursor_command("/tmp/work", True, None, model="composer-2") + self.assertEqual(command[0], "/tmp/cursor-agent") + self.assertNotEqual(command[1], "agent") + self.assertIn("--model", command) + self.assertIn("composer-2", command) + + def test_parse_jsonl_extracts_last_token_usage(self): + output = "\n".join( + [ + json.dumps({"type": "thread.started", "thread_id": "thread-1"}), + json.dumps( + { + "type": "event_msg", + "payload": { + "type": "token_count", + "info": { + "last_token_usage": { + "input_tokens": 12, + "output_tokens": 7, + "total_tokens": 19, + } + }, + }, + } + ), + json.dumps( + { + "type": "item.completed", + "item": {"type": "agent_message", "text": "hello"}, + } + ), + ] + ) + message, thread_id, usage = _parse_jsonl(output, include_thinking=False) + self.assertEqual(message, "hello") + self.assertEqual(thread_id, "thread-1") + self.assertEqual( + usage, + {"input_tokens": 12, "output_tokens": 7, "total_tokens": 19}, + ) + + def test_parse_jsonl_falls_back_to_total_token_usage(self): + output = "\n".join( + [ + json.dumps( + { + "type": "event_msg", + "payload": { + "type": "token_count", + "info": { + "total_token_usage": { + "prompt_tokens": 9, + "completion_tokens": 4, + "total_tokens": 13, + } + }, + }, + } + ), + json.dumps( + { + "type": "item.completed", + "item": {"type": "agent_message", "text": "done"}, + } + ), + ] + ) + message, thread_id, usage = _parse_jsonl(output, include_thinking=False) + self.assertEqual(message, "done") + self.assertIsNone(thread_id) + self.assertEqual( + usage, + {"input_tokens": 9, "output_tokens": 4, "total_tokens": 13}, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_agents.py b/tests/test_agents.py new file mode 100644 index 0000000..06cb2aa --- /dev/null +++ b/tests/test_agents.py @@ -0,0 +1,2013 @@ +import io +import json +import os +import subprocess +import sys +import tempfile +import unittest +from contextlib import contextmanager +from contextlib import redirect_stderr +from contextlib import redirect_stdout +from datetime import datetime, timedelta, timezone +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from codexapi import __version__ +from codexapi.agents import ( + _build_wake_prompt, + _codex_rollout_usage, + _tick_lock_path, + _try_lock, + _remove_cron_line, + _upsert_cron_line, + control_agent, + cron_installed, + cron_status, + delete_agent, + format_utc, + install_cron, + nudge_agent, + read_agent, + read_agentbook, + recover_agent, + render_cron_line, + send_agent, + set_agent_heartbeat, + show_agent, + start_agent, + status_agent, + tick, + uninstall_cron, + write_tick_wrapper, +) +from codexapi.cli import ( + _print_managed_agent_identity, + _print_managed_agent_list, + _print_managed_agent_show, + _print_managed_agent_status, + main as cli_main, +) +from codexapi.lead import _leadbook_block + + +@contextmanager +def _temp_home(): + with tempfile.TemporaryDirectory() as tmpdir: + with patch.dict(os.environ, {"CODEXAPI_HOME": tmpdir, "USER": "tester"}, clear=False): + yield Path(tmpdir) + + +def _write_rollout(path, events): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + "\n".join(json.dumps(event) for event in events) + "\n", + encoding="utf-8", + ) + + +def _set_rollout_session(home, agent_id, hostname, thread_id, rollout_path): + session_path = home / "agents" / agent_id / "hosts" / hostname / "session.json" + state_path = home / "agents" / agent_id / "state.json" + session = json.loads(session_path.read_text(encoding="utf-8")) + state = json.loads(state_path.read_text(encoding="utf-8")) + session["thread_id"] = thread_id + session["rollout_path"] = str(rollout_path) + state["thread_id"] = thread_id + session_path.write_text(json.dumps(session, indent=2, sort_keys=True) + "\n", encoding="utf-8") + state_path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +class AgentsTests(unittest.TestCase): + def test_cli_version(self): + output = io.StringIO() + with redirect_stdout(output): + with self.assertRaises(SystemExit) as exc: + cli_main(["--version"]) + self.assertEqual(exc.exception.code, 0) + self.assertEqual(output.getvalue().strip(), f"codexapi {__version__}") + + def test_cli_subcommand_help_shows_one_line_description(self): + cases = ( + ( + ["task", "--help"], + "Run a task with verification retries.", + ), + ( + ["agent", "resume", "--help"], + "Resume a paused agent and return immediately unless --wait is set.", + ), + ( + ["agent", "list", "--help"], + "List durable agents in this CODEXAPI_HOME.", + ), + ) + for argv, expected in cases: + with self.subTest(argv=argv): + output = io.StringIO() + with redirect_stdout(output): + with self.assertRaises(SystemExit) as exc: + cli_main(argv) + self.assertEqual(exc.exception.code, 0) + self.assertIn(expected, output.getvalue()) + + def test_current_hostname_prefers_override(self): + with patch.dict(os.environ, {"CODEXAPI_HOSTNAME": "stable-host"}, clear=False): + from codexapi.agents import current_hostname + + self.assertEqual(current_hostname(), "stable-host") + + def test_cli_whoami_shows_effective_host_and_home(self): + with _temp_home() as home: + with patch.dict(os.environ, {"CODEXAPI_HOSTNAME": "stable-host"}, clear=False): + output = io.StringIO() + with redirect_stdout(output): + _print_managed_agent_identity() + text = output.getvalue() + self.assertIn("Host: stable-host", text) + self.assertIn("Host override: stable-host", text) + self.assertIn(f"Home: {home.resolve()}", text) + + def test_homes_are_isolated(self): + with _temp_home() as home_a: + first = start_agent("Monitor the build queue.", hostname="host-a") + self.assertEqual(first["name"], "monitor-the-build-queue") + agents_a = show_agent(first["id"]) + self.assertEqual(agents_a["meta"]["hostname"], "host-a") + self.assertTrue(agents_a["agentbook_path"].endswith("/AGENTBOOK.md")) + with _temp_home() as home_b: + with self.assertRaises(ValueError): + show_agent(first["id"]) + second = start_agent("Watch CI failures.", hostname="host-b") + self.assertNotEqual(first["id"], second["id"]) + + def test_start_agent_defaults_to_normal_mode(self): + with _temp_home(): + started = start_agent("Use normal mode.", hostname="host-a") + shown = show_agent(started["id"]) + self.assertFalse(shown["session"]["fast"]) + + def test_start_agent_can_enable_fast_mode(self): + with _temp_home(): + started = start_agent("Use fast mode.", hostname="host-a", fast=True) + shown = show_agent(started["id"]) + self.assertTrue(shown["session"]["fast"]) + + def test_read_agentbook_and_cli_book(self): + with _temp_home(): + agent = start_agent("Keep notes.", hostname="host-a") + book = read_agentbook(agent["id"]) + self.assertTrue(book["path"].endswith("/AGENTBOOK.md")) + self.assertIn("# Agentbook", book["text"]) + self.assertIn("## Purpose", book["text"]) + self.assertIn("## Values", book["text"]) + self.assertIn("## Original Goal", book["text"]) + self.assertIn("Keep notes.", book["text"]) + + output = io.StringIO() + with redirect_stdout(output): + cli_main(["agent", "book", agent["id"]]) + text = output.getvalue() + self.assertIn("Agentbook:", text) + self.assertIn("# Agentbook", text) + + def test_build_wake_prompt_shows_agentbook_header_and_latest_notes(self): + with _temp_home() as home: + agent = start_agent("Watch for the real issue.", hostname="host-a") + agent_dir = home / "agents" / agent["id"] + book_path = agent_dir / "AGENTBOOK.md" + book_path.write_text( + "\n".join( + [ + "# Agentbook", + "", + "## Purpose", + "- We are here to achieve the goal, not to appear to make progress.", + "", + "## Values", + "- Hold the whole.", + "- Seek the real shape.", + "", + "## Original Goal", + "```text", + "Watch for the real issue.", + "```", + "", + "## Standing Guidance", + "- Prefer the truer explanation to the tidier one.", + "", + "## Working Notes", + "", + "### 2026-03-23 08:00 UTC", + "- OLD " + ("alpha " * 500), + "", + "### 2026-03-23 09:00 UTC", + "- NEW " + ("omega " * 120), + "", + ] + ), + encoding="utf-8", + ) + meta = json.loads((agent_dir / "meta.json").read_text(encoding="utf-8")) + state = json.loads((agent_dir / "state.json").read_text(encoding="utf-8")) + session = json.loads((agent_dir / "hosts" / "host-a" / "session.json").read_text(encoding="utf-8")) + state["last_success_at"] = "2026-03-23T08:30:00Z" + state["activity"] = "Watching" + state["update"] = "Still narrowing the field." + session["thread_id"] = "thread-123" + prompt = _build_wake_prompt( + meta, + state, + session, + datetime(2026, 3, 23, 9, 30, tzinfo=timezone.utc), + [], + agent_dir, + ) + self.assertIn("Agentbook (header + latest notes):", prompt) + self.assertIn("Wake mode: continuation", prompt) + self.assertIn("## Purpose", prompt) + self.assertIn("Hold the whole.", prompt) + self.assertIn("Watch for the real issue.", prompt) + self.assertIn("NEW omega", prompt) + self.assertIn("Previous status: Watching", prompt) + self.assertIn("Previous update: Still narrowing the field.", prompt) + self.assertIn("[... older notes omitted ...]", prompt) + self.assertIn("You are an independent codexapi agent continuing this job.", prompt) + self.assertNotIn("Original instructions:", prompt) + self.assertNotIn("OLD alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha", prompt) + self.assertNotIn("resuming stewardship", prompt) + + def test_build_wake_prompt_repairs_legacy_agentbook_before_wake(self): + with _temp_home() as home: + agent = start_agent("Keep the true goal in view.", hostname="host-a") + agent_dir = home / "agents" / agent["id"] + book_path = agent_dir / "AGENTBOOK.md" + book_path.write_text( + "\n".join( + [ + "# Agentbook", + "", + "Use this file as the durable working memory for the agent.", + "Append dated notes as work progresses.", + "Keep entries short and concrete.", + "", + "## 2026-03-23 08:00 UTC", + "- Legacy note about the real issue.", + ] + ), + encoding="utf-8", + ) + meta = json.loads((agent_dir / "meta.json").read_text(encoding="utf-8")) + state = json.loads((agent_dir / "state.json").read_text(encoding="utf-8")) + session = json.loads((agent_dir / "hosts" / "host-a" / "session.json").read_text(encoding="utf-8")) + state["last_success_at"] = "2026-03-23T08:30:00Z" + session["thread_id"] = "thread-legacy" + now = datetime(2026, 3, 23, 9, 30, tzinfo=timezone.utc) + prompt = _build_wake_prompt(meta, state, session, now, [], agent_dir) + repaired = book_path.read_text(encoding="utf-8") + self.assertIn("## Purpose", repaired) + self.assertIn("## Values", repaired) + self.assertIn("## Original Goal", repaired) + self.assertIn("Keep the true goal in view.", repaired) + self.assertIn("### 2026-03-23 09:30 UTC", repaired) + self.assertIn("The durable agentbook header was restored automatically on wake", repaired) + self.assertIn("Legacy note about the real issue.", repaired) + self.assertIn("## Purpose", prompt) + self.assertIn("Wake mode: continuation", prompt) + self.assertIn("Keep the true goal in view.", prompt) + self.assertNotIn("Original instructions:", prompt) + + def test_build_wake_prompt_marks_first_wake_without_prior_history(self): + with _temp_home() as home: + agent = start_agent("Start from what is actually shown.", hostname="host-a") + agent_dir = home / "agents" / agent["id"] + meta = json.loads((agent_dir / "meta.json").read_text(encoding="utf-8")) + state = json.loads((agent_dir / "state.json").read_text(encoding="utf-8")) + session = json.loads((agent_dir / "hosts" / "host-a" / "session.json").read_text(encoding="utf-8")) + + prompt = _build_wake_prompt( + meta, + state, + session, + datetime(2026, 3, 23, 9, 30, tzinfo=timezone.utc), + [], + agent_dir, + ) + + self.assertIn("Wake mode: first wake", prompt) + self.assertIn("You are an independent codexapi agent starting this job.", prompt) + self.assertIn("Do not assume prior progress unless it is shown here.", prompt) + self.assertIn("Original instructions:", prompt) + self.assertNotIn("resuming stewardship", prompt) + + def test_leadbook_block_shows_header_and_latest_notes(self): + leadbook = "\n".join( + [ + "# Leadbook — Studio Notes", + "", + "Aim:", + "- Move the true work forward.", + "", + "Signals:", + "- Treat oddities as clues.", + "", + "## 2026-03-23 08:00", + "- OLD " + ("alpha " * 350), + "", + "## 2026-03-23 09:00", + "- NEW " + ("omega " * 80), + ] + ) + block = _leadbook_block("/tmp/LEADBOOK.md", leadbook) + self.assertIn("Leadbook (header + latest notes):", block) + self.assertIn("Aim:", block) + self.assertIn("Signals:", block) + self.assertIn("NEW omega", block) + self.assertIn("[... older notes omitted ...]", block) + self.assertNotIn("OLD alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha", block) + + def test_delete_agent_removes_done_agent(self): + def fake_runner(meta, session, prompt): + return { + "message": json.dumps( + { + "status": "Finished", + "continue": False, + "reply": "done", + } + ), + "thread_id": "thread-delete", + } + + with _temp_home(): + agent = start_agent("Finish then delete.", hostname="host-a") + tick(hostname="host-a", runner=fake_runner) + result = delete_agent(agent["id"]) + self.assertTrue(result["deleted"]) + with self.assertRaises(ValueError): + show_agent(agent["id"]) + + def test_delete_agent_refuses_non_terminal_without_force(self): + with _temp_home(): + agent = start_agent("Do not delete me yet.", hostname="host-a") + with self.assertRaises(ValueError): + delete_agent(agent["id"]) + result = delete_agent(agent["id"], force=True) + self.assertTrue(result["forced"]) + + def test_delete_agent_refuses_when_run_lock_held(self): + with _temp_home() as home: + agent = start_agent("Locked agent.", hostname="host-a") + agent_dir = home / "agents" / agent["id"] + lock_path = agent_dir / "hosts" / "host-a" / "run.lock" + with _try_lock(lock_path) as handle: + self.assertIsNotNone(handle) + with self.assertRaises(ValueError): + delete_agent(agent["id"], force=True) + + def test_cli_delete_removes_agent(self): + def fake_runner(meta, session, prompt): + return { + "message": json.dumps( + { + "status": "Finished", + "continue": False, + "reply": "done", + } + ), + "thread_id": "thread-delete-cli", + } + + with _temp_home(): + agent = start_agent("Finish then delete.", hostname="host-a") + tick(hostname="host-a", runner=fake_runner) + output = io.StringIO() + with redirect_stdout(output): + cli_main(["agent", "delete", agent["id"]]) + payload = json.loads(output.getvalue()) + self.assertTrue(payload["deleted"]) + with self.assertRaises(ValueError): + show_agent(agent["id"]) + + def test_cross_host_message_waits_for_owner_tick(self): + prompts = [] + + def fake_runner(meta, session, prompt): + prompts.append(prompt) + return { + "message": json.dumps( + { + "status": "Replied", + "continue": False, + "reply": "I saw your message.", + } + ), + "thread_id": "thread-abc", + } + + with _temp_home(): + agent = start_agent("Handle background work.", hostname="host-a") + send_agent(agent["id"], "status", author="mark", hostname="host-b") + + before = show_agent(agent["id"]) + self.assertEqual(before["unread_message_count"], 1) + self.assertEqual(before["state"]["unread_message_count"], 1) + queued = read_agent(agent["id"]) + self.assertEqual(queued["items"][0]["kind"], "queued") + self.assertEqual(queued["items"][0]["text"], "status") + + other_host = tick(hostname="host-b", runner=fake_runner) + self.assertTrue(other_host["ran"]) + self.assertEqual(other_host["processed"], 0) + self.assertEqual(other_host["woken"], 0) + + owner = tick(hostname="host-a", runner=fake_runner) + self.assertTrue(owner["ran"]) + self.assertEqual(owner["woken"], 1) + self.assertEqual(len(prompts), 1) + self.assertIn("mark: status", prompts[0]) + + shown = show_agent(agent["id"]) + self.assertEqual(shown["state"]["status"], "done") + self.assertEqual(shown["state"]["thread_id"], "thread-abc") + self.assertEqual(shown["state"]["reply"], "I saw your message.") + self.assertEqual(shown["state"]["unread_message_count"], 0) + + conversation = read_agent(agent["id"]) + self.assertEqual(conversation["items"][0]["kind"], "user") + self.assertEqual(conversation["items"][0]["author"], "mark") + self.assertEqual(conversation["items"][0]["text"], "status") + self.assertEqual(conversation["items"][1]["kind"], "agent") + self.assertEqual(conversation["items"][1]["text"], "I saw your message.") + + def test_start_agent_resolves_parent_ref(self): + with _temp_home(): + parent = start_agent( + "Parent work.", + name="parent-agent", + hostname="host-a", + ) + child = start_agent( + "Child work.", + name="child-agent", + parent_ref="parent-agent", + hostname="host-a", + ) + shown = show_agent(child["id"]) + self.assertEqual(shown["meta"]["parent_id"], parent["id"]) + self.assertEqual(shown["meta"]["created_by"], "parent-agent") + self.assertEqual(shown["parent"]["id"], parent["id"]) + + def test_managed_agent_can_create_child_with_parent_defaults(self): + start = datetime(2026, 3, 6, 8, 0, tzinfo=timezone.utc) + end = start + timedelta(hours=1) + captured = {} + + with _temp_home(): + parent = start_agent( + "Spawn a child agent.", + name="parent-agent", + hostname="host-a", + now=start, + ) + + class FakeAgent: + def __init__( + self, + cwd=None, + yolo=True, + thread_id=None, + flags=None, + include_thinking=False, + backend=None, + env=None, + ): + self.thread_id = thread_id + self.last_usage = {} + self.env = dict(env or {}) + captured["env"] = self.env + + def __call__(self, prompt): + with patch.dict(os.environ, self.env, clear=False): + child = start_agent( + "Child work.", + name="child-agent", + hostname="host-a", + ) + captured["child_id"] = child["id"] + return json.dumps( + { + "status": "Spawned child", + "continue": False, + "reply": child["id"], + } + ) + + with patch("codexapi.agents.Agent", FakeAgent): + with patch( + "codexapi.agents.utc_now", + side_effect=[ + start, + start + timedelta(seconds=1), + start + timedelta(seconds=2), + end, + ], + ): + result = nudge_agent( + parent["id"], + hostname="host-a", + now=start, + ) + self.assertTrue(result["ran"]) + self.assertEqual(result["woken"], 1) + self.assertEqual(captured["env"]["CODEXAPI_AGENT_ID"], parent["id"]) + self.assertEqual(captured["env"]["CODEXAPI_AGENT_NAME"], "parent-agent") + + child = show_agent(captured["child_id"]) + self.assertEqual(child["meta"]["created_by"], "parent-agent") + self.assertEqual(child["meta"]["parent_id"], parent["id"]) + self.assertEqual(child["parent"]["name"], "parent-agent") + + parent_view = show_agent(parent["id"]) + self.assertIn(captured["child_id"], parent_view["child_ids"]) + self.assertEqual(parent_view["children"][0]["name"], "child-agent") + + def test_pause_then_resume(self): + calls = [] + + def fake_runner(meta, session, prompt): + calls.append(prompt) + return { + "message": json.dumps( + { + "status": "Still running", + "continue": True, + "reply": "Continuing.", + } + ), + "thread_id": "thread-xyz", + } + + with _temp_home(): + agent = start_agent("Keep an eye on this.", hostname="host-a") + control_agent(agent["id"], "pause", hostname="host-b") + paused = tick(hostname="host-a", runner=fake_runner) + self.assertEqual(paused["processed"], 1) + self.assertEqual(paused["woken"], 0) + self.assertEqual(show_agent(agent["id"])["state"]["status"], "paused") + self.assertEqual(calls, []) + + control_agent(agent["id"], "resume", hostname="host-b") + resumed = tick(hostname="host-a", runner=fake_runner) + self.assertEqual(resumed["woken"], 1) + self.assertEqual(len(calls), 1) + shown = show_agent(agent["id"]) + self.assertEqual(shown["state"]["status"], "ready") + self.assertEqual(shown["state"]["thread_id"], "thread-xyz") + + def test_resume_done_agent_reopens_it(self): + def finish_runner(meta, session, prompt): + return { + "message": json.dumps( + { + "status": "Finished", + "continue": False, + "reply": "done", + } + ), + "thread_id": "thread-done", + } + + def resume_runner(meta, session, prompt): + return { + "message": json.dumps( + { + "status": "Back on it", + "continue": True, + "reply": "Reopened.", + } + ), + "thread_id": "thread-done", + } + + with _temp_home(): + agent = start_agent("Keep an eye on this.", hostname="host-a") + tick(hostname="host-a", runner=finish_runner) + self.assertEqual(show_agent(agent["id"])["state"]["status"], "done") + + control_agent(agent["id"], "resume", hostname="host-b") + resumed = tick(hostname="host-a", runner=resume_runner) + self.assertEqual(resumed["woken"], 1) + shown = show_agent(agent["id"]) + self.assertEqual(shown["state"]["status"], "ready") + self.assertEqual(shown["state"]["reply"], "Reopened.") + + def test_send_wakes_done_agent_once_without_reopening(self): + prompts = [] + + def finish_runner(meta, session, prompt): + return { + "message": json.dumps( + { + "status": "Finished", + "continue": False, + "reply": "done", + } + ), + "thread_id": "thread-done", + } + + def reply_runner(meta, session, prompt): + prompts.append(prompt) + return { + "message": json.dumps( + { + "status": "Answered", + "continue": True, + "reply": "I saw your note.", + } + ), + "thread_id": "thread-done", + } + + with _temp_home(): + agent = start_agent("Handle background work.", hostname="host-a") + tick(hostname="host-a", runner=finish_runner) + send_agent(agent["id"], "status", author="mark", hostname="host-b") + + result = tick(hostname="host-a", runner=reply_runner) + self.assertEqual(result["woken"], 1) + self.assertIn("mark: status", prompts[0]) + + shown = show_agent(agent["id"]) + self.assertEqual(shown["state"]["status"], "done") + self.assertEqual(shown["state"]["reply"], "I saw your note.") + self.assertEqual(shown["state"]["next_wake_at"], "") + self.assertEqual(shown["state"]["unread_message_count"], 0) + + def test_send_wakes_canceled_agent_once_without_reopening(self): + prompts = [] + + def reply_runner(meta, session, prompt): + prompts.append(prompt) + return { + "message": json.dumps( + { + "status": "Answered", + "continue": True, + "reply": "I saw your note.", + } + ), + "thread_id": "thread-canceled", + } + + with _temp_home(): + agent = start_agent("Handle background work.", hostname="host-a") + control_agent(agent["id"], "cancel", hostname="host-b") + tick(hostname="host-a", runner=reply_runner) + self.assertEqual(show_agent(agent["id"])["state"]["status"], "canceled") + + send_agent(agent["id"], "status", author="mark", hostname="host-b") + result = tick(hostname="host-a", runner=reply_runner) + self.assertEqual(result["woken"], 1) + self.assertIn("mark: status", prompts[-1]) + + shown = show_agent(agent["id"]) + self.assertEqual(shown["state"]["status"], "canceled") + self.assertEqual(shown["state"]["reply"], "I saw your note.") + self.assertEqual(shown["state"]["next_wake_at"], "") + self.assertEqual(shown["state"]["unread_message_count"], 0) + + def test_set_agent_heartbeat_updates_meta_and_reschedules_idle_agent(self): + start = datetime(2026, 3, 6, 8, 0, tzinfo=timezone.utc) + end = start + timedelta(minutes=1) + now = start + timedelta(minutes=2) + + def fake_runner(meta, session, prompt): + return { + "message": json.dumps( + { + "status": "Handled", + "continue": True, + "reply": "Still watching.", + } + ), + "thread_id": "thread-heartbeat", + } + + with _temp_home(): + agent = start_agent( + "Keep an eye on this.", + hostname="host-a", + heartbeat_minutes=30, + now=start, + ) + with patch("codexapi.agents.utc_now", return_value=end): + nudge_agent( + agent["id"], + hostname="host-a", + now=start, + runner=fake_runner, + ) + result = set_agent_heartbeat( + agent["id"], + 10, + now=now, + ) + shown = show_agent(agent["id"]) + self.assertTrue(result["changed"]) + self.assertTrue(result["rescheduled"]) + self.assertFalse(result["running"]) + self.assertEqual(result["heartbeat_minutes"], 10) + self.assertEqual(shown["meta"]["heartbeat_minutes"], 10) + self.assertEqual( + shown["state"]["next_wake_at"], + format_utc(now + timedelta(minutes=10)), + ) + + def test_set_agent_heartbeat_leaves_pending_wake_time_when_wake_requested(self): + start = datetime(2026, 3, 6, 8, 0, tzinfo=timezone.utc) + now = start + timedelta(minutes=1) + + with _temp_home(): + agent = start_agent( + "Keep an eye on this.", + hostname="host-a", + heartbeat_minutes=30, + now=start, + ) + result = set_agent_heartbeat( + agent["id"], + 10, + now=now, + ) + shown = show_agent(agent["id"]) + self.assertFalse(result["rescheduled"]) + self.assertEqual(shown["state"]["wake_requested_at"], format_utc(start)) + self.assertEqual(shown["state"]["next_wake_at"], format_utc(start)) + + def test_tick_lock_is_non_blocking(self): + with _temp_home() as home: + start_agent("Do the thing.", hostname="host-a") + lock_path = _tick_lock_path(home, "host-a") + with _try_lock(lock_path) as handle: + self.assertIsNotNone(handle) + result = tick(hostname="host-a") + self.assertFalse(result["ran"]) + self.assertEqual(result["processed"], 0) + self.assertEqual(result["woken"], 0) + + def test_write_tick_wrapper_pins_home_and_python(self): + with _temp_home() as home: + wrapper = write_tick_wrapper( + home=home, + python_executable="/tmp/venv/bin/python", + path_value="/tmp/venv/bin:/usr/bin", + hostname="stable-host", + ) + text = wrapper.read_text(encoding="utf-8") + self.assertIn("export CODEXAPI_HOME=", text) + self.assertIn(str(home), text) + self.assertIn("export CODEXAPI_HOSTNAME=stable-host", text) + self.assertIn("export PATH=", text) + self.assertIn("/tmp/venv/bin:/usr/bin", text) + self.assertIn("exec /tmp/venv/bin/python -m codexapi tick", text) + + def test_upsert_cron_line_keeps_different_homes_separate(self): + line_a = render_cron_line(home="/tmp/home-a", hostname="host-a") + line_b = render_cron_line(home="/tmp/home-b", hostname="host-a") + updated, changed = _upsert_cron_line("", line_a, "codexapi-agent::host-a::aaa") + self.assertTrue(changed) + updated, changed = _upsert_cron_line(updated, line_b, "codexapi-agent::host-a::bbb") + self.assertTrue(changed) + self.assertIn("/tmp/home-a/bin/agent-tick", updated) + self.assertIn("/tmp/home-b/bin/agent-tick", updated) + + def test_remove_cron_line_keeps_other_entries(self): + existing = ( + "* * * * * /tmp/home-a/bin/agent-tick >/dev/null 2>&1 # codexapi-agent::host-a::aaa\n" + "* * * * * /tmp/home-b/bin/agent-tick >/dev/null 2>&1 # codexapi-agent::host-a::bbb\n" + ) + updated, changed = _remove_cron_line(existing, "codexapi-agent::host-a::aaa") + self.assertTrue(changed) + self.assertNotIn("/tmp/home-a/bin/agent-tick", updated) + self.assertIn("/tmp/home-b/bin/agent-tick", updated) + + def test_install_cron_writes_wrapper_and_updates_crontab_text(self): + writes = [] + + def fake_read(): + return "" + + def fake_write(text): + writes.append(text) + + with _temp_home() as home: + with patch("codexapi.agents._read_crontab", fake_read): + with patch("codexapi.agents._write_crontab", fake_write): + result = install_cron( + home=home, + hostname="host-a", + python_executable="/tmp/venv/bin/python", + path_value="/tmp/venv/bin:/usr/bin", + ) + wrapper = Path(result["wrapper"]) + self.assertTrue(wrapper.exists()) + self.assertEqual(len(writes), 1) + self.assertIn(str(wrapper), writes[0]) + self.assertIn("codexapi-agent::host-a::", writes[0]) + + def test_install_cron_is_idempotent_when_line_already_matches(self): + writes = [] + + with _temp_home() as home: + expected_line = render_cron_line(home=home, hostname="host-a") + + def fake_read(): + return expected_line + "\n" + + def fake_write(text): + writes.append(text) + + with patch("codexapi.agents._read_crontab", fake_read): + with patch("codexapi.agents._write_crontab", fake_write): + result = install_cron( + home=home, + hostname="host-a", + python_executable="/tmp/venv/bin/python", + path_value="/tmp/venv/bin:/usr/bin", + ) + self.assertFalse(result["changed"]) + self.assertEqual(writes, []) + + def test_cron_status_reports_broken_wrapper_python(self): + with _temp_home() as home: + write_tick_wrapper( + home=home, + python_executable="/tmp/venv/bin/python", + path_value="/tmp/venv/bin:/usr/bin", + hostname="host-a", + ) + crontab = render_cron_line(home=home, hostname="host-a") + "\n" + with patch("codexapi.agents._read_crontab", return_value=crontab): + with patch( + "codexapi.agents.subprocess.run", + return_value=subprocess.CompletedProcess( + ["/tmp/venv/bin/python", "-c", "import codexapi"], + 1, + stdout="", + stderr="ModuleNotFoundError: No module named 'codexapi'\n", + ), + ): + status = cron_status(home=home, hostname="host-a") + self.assertTrue(status["configured"]) + self.assertFalse(status["healthy"]) + self.assertIn("cannot import codexapi", status["reason"]) + self.assertFalse(cron_installed(home=home, hostname="host-a")) + + def test_uninstall_cron_removes_only_this_home_entry_and_wrapper(self): + writes = [] + + def fake_write(text): + writes.append(text) + + with _temp_home() as home: + wrapper = write_tick_wrapper( + home=home, + python_executable="/tmp/venv/bin/python", + path_value="/tmp/venv/bin:/usr/bin", + ) + record = home / "cron" / "agent.cron" + record.write_text("placeholder\n", encoding="utf-8") + this_line = render_cron_line(home=home, hostname="host-a") + other_line = render_cron_line(home="/tmp/other-home", hostname="host-a") + + def fake_read(): + return this_line + "\n" + other_line + "\n" + + with patch("codexapi.agents._read_crontab", fake_read): + with patch("codexapi.agents._write_crontab", fake_write): + result = uninstall_cron(home=home, hostname="host-a") + self.assertTrue(result["changed"]) + self.assertFalse(wrapper.exists()) + self.assertFalse(record.exists()) + self.assertEqual(len(writes), 1) + self.assertNotIn(str(wrapper), writes[0]) + self.assertIn("/tmp/other-home/bin/agent-tick", writes[0]) + + def test_nudge_agent_runs_immediately_and_updates_token_totals(self): + start = datetime(2026, 3, 6, 8, 0, tzinfo=timezone.utc) + end = start + timedelta(hours=1) + + def fake_runner(meta, session, prompt): + return { + "message": json.dumps( + { + "status": "Handled", + "continue": True, + "reply": "Message handled.", + } + ), + "thread_id": "thread-usage", + "usage": { + "input_tokens": 30, + "output_tokens": 20, + "total_tokens": 50, + }, + } + + with _temp_home(): + agent = start_agent( + "Handle messages.", + hostname="host-a", + now=start, + ) + send_agent(agent["id"], "ping", hostname="host-a", now=start) + with patch("codexapi.agents.utc_now", return_value=end): + result = nudge_agent( + agent["id"], + hostname="host-a", + now=start + timedelta(seconds=10), + runner=fake_runner, + ) + self.assertTrue(result["ran"]) + self.assertEqual(result["woken"], 1) + shown = show_agent(agent["id"]) + self.assertEqual(shown["state"]["thread_id"], "thread-usage") + self.assertEqual(shown["state"]["input_tokens"], 30) + self.assertEqual(shown["state"]["output_tokens"], 20) + self.assertEqual(shown["state"]["total_tokens"], 50) + self.assertEqual(shown["state"]["avg_tokens_per_hour"], 50.0) + self.assertEqual(shown["state"]["reply"], "Message handled.") + self.assertEqual(shown["unread_message_count"], 0) + + def test_nudge_agent_can_spawn_async_process(self): + calls = [] + + class FakePopen: + def __init__(self, cmd, **kwargs): + calls.append((cmd, kwargs)) + + with _temp_home() as home: + agent = start_agent( + "Handle messages.", + hostname="host-a", + ) + with patch("codexapi.agents.subprocess.Popen", FakePopen): + result = nudge_agent( + agent["id"], + home=home, + hostname="host-a", + wait=False, + ) + self.assertTrue(result["ran"]) + self.assertTrue(result["spawned"]) + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0][0][-2:], ["run", agent["id"]]) + + def test_tick_spawns_one_process_per_due_local_agent(self): + calls = [] + + class FakePopen: + def __init__(self, cmd, **kwargs): + calls.append((cmd, kwargs)) + + with _temp_home() as home: + first = start_agent("First job.", hostname="host-a") + second = start_agent("Second job.", hostname="host-a") + start_agent("Remote job.", hostname="host-b") + with patch("codexapi.agents.subprocess.Popen", FakePopen): + result = tick(home=home, hostname="host-a") + self.assertTrue(result["ran"]) + self.assertEqual(result["processed"], 2) + self.assertEqual(result["woken"], 2) + self.assertEqual(len(calls), 2) + spawned = sorted(call[0][-1] for call in calls) + self.assertEqual(spawned, sorted([first["id"], second["id"]])) + + def test_codex_rollout_usage_uses_latest_event_after_start(self): + started = datetime(2026, 3, 6, 8, 0, 5, tzinfo=timezone.utc) + with _temp_home() as home: + codex_home = home / "codex-home" + rollout = ( + codex_home + / "sessions" + / "2026" + / "03" + / "06" + / "rollout-2026-03-06T09-00-00-thread-rollout.jsonl" + ) + rollout.parent.mkdir(parents=True, exist_ok=True) + events = [ + { + "timestamp": "2026-03-06T08:00:01Z", + "type": "event_msg", + "payload": { + "type": "token_count", + "info": { + "last_token_usage": { + "input_tokens": 10, + "output_tokens": 5, + "total_tokens": 15, + } + }, + }, + }, + { + "timestamp": "2026-03-06T08:00:06Z", + "type": "event_msg", + "payload": { + "type": "token_count", + "info": { + "last_token_usage": { + "input_tokens": 20, + "output_tokens": 10, + "total_tokens": 30, + } + }, + }, + }, + { + "timestamp": "2026-03-06T08:00:07Z", + "type": "event_msg", + "payload": { + "type": "token_count", + "info": { + "last_token_usage": { + "input_tokens": 40, + "output_tokens": 12, + "total_tokens": 52, + } + }, + }, + }, + ] + rollout.write_text( + "\n".join(json.dumps(event) for event in events) + "\n", + encoding="utf-8", + ) + with patch.dict(os.environ, {"CODEX_HOME": str(codex_home)}, clear=False): + usage, path = _codex_rollout_usage( + {"rollout_path": str(rollout)}, + "thread-rollout", + started, + ) + self.assertEqual(path, str(rollout)) + self.assertEqual( + usage, + {"input_tokens": 40, "output_tokens": 12, "total_tokens": 52}, + ) + + def test_nudge_agent_reads_usage_from_codex_rollout(self): + start = datetime(2026, 3, 6, 8, 0, tzinfo=timezone.utc) + end = start + timedelta(hours=1) + + with _temp_home() as home: + codex_home = home / "codex-home" + + class FakeAgent: + def __init__( + self, + cwd=None, + yolo=True, + thread_id=None, + flags=None, + include_thinking=False, + backend=None, + env=None, + ): + self.thread_id = thread_id + self.last_usage = {} + + def __call__(self, prompt): + self.thread_id = "thread-rollout" + rollout = ( + codex_home + / "sessions" + / "2026" + / "03" + / "06" + / "rollout-2026-03-06T09-00-00-thread-rollout.jsonl" + ) + rollout.parent.mkdir(parents=True, exist_ok=True) + events = [ + { + "timestamp": format_utc(start + timedelta(seconds=5)), + "type": "event_msg", + "payload": { + "type": "token_count", + "info": { + "last_token_usage": { + "input_tokens": 40, + "output_tokens": 10, + "total_tokens": 50, + } + }, + }, + } + ] + rollout.write_text( + "\n".join(json.dumps(event) for event in events) + "\n", + encoding="utf-8", + ) + return json.dumps( + { + "status": "Handled from rollout", + "continue": False, + "reply": "Used rollout tokens.", + } + ) + + agent = start_agent( + "Handle with real rollout accounting.", + hostname="host-a", + now=start, + ) + with patch.dict(os.environ, {"CODEX_HOME": str(codex_home)}, clear=False): + with patch("codexapi.agents.Agent", FakeAgent): + with patch("codexapi.agents.utc_now", side_effect=[start, end]): + result = nudge_agent( + agent["id"], + hostname="host-a", + now=start, + ) + self.assertTrue(result["ran"]) + self.assertEqual(result["woken"], 1) + shown = show_agent(agent["id"]) + self.assertEqual(shown["state"]["thread_id"], "thread-rollout") + self.assertEqual(shown["state"]["input_tokens"], 40) + self.assertEqual(shown["state"]["output_tokens"], 10) + self.assertEqual(shown["state"]["total_tokens"], 50) + self.assertEqual(shown["state"]["avg_tokens_per_hour"], 50.0) + self.assertEqual(shown["state"]["reply"], "Used rollout tokens.") + self.assertIn("thread-rollout", shown["session"]["rollout_path"]) + + def test_start_agent_replays_start_time_env_on_later_wakes(self): + captured = {} + + with _temp_home(): + with patch.dict( + os.environ, + { + "CUSTOM_AGENT_ENV": "expected-value", + "GH_TOKEN": "ghp-test-token", + }, + clear=False, + ): + agent = start_agent( + "Use my saved environment.", + hostname="host-a", + ) + + class FakeAgent: + def __init__( + self, + cwd=None, + yolo=True, + thread_id=None, + flags=None, + include_thinking=False, + backend=None, + env=None, + ): + captured["env"] = dict(env or {}) + self.thread_id = thread_id + self.last_usage = {} + + def __call__(self, prompt): + return json.dumps( + { + "status": "Handled with saved env", + "continue": False, + "reply": "done", + } + ) + + with patch.dict( + os.environ, + { + "CUSTOM_AGENT_ENV": "different-value", + "GH_TOKEN": "", + }, + clear=False, + ): + with patch("codexapi.agents.Agent", FakeAgent): + nudge_agent(agent["id"], hostname="host-a") + + self.assertEqual(captured["env"]["CUSTOM_AGENT_ENV"], "expected-value") + self.assertEqual(captured["env"]["GH_TOKEN"], "ghp-test-token") + + def test_start_agent_captures_gh_token_when_env_is_missing(self): + with _temp_home(): + with patch.dict( + os.environ, + {"GH_TOKEN": "", "GITHUB_TOKEN": ""}, + clear=False, + ): + with patch("codexapi.agents.shutil.which", return_value="/usr/bin/gh"): + with patch( + "codexapi.agents.subprocess.run", + return_value=subprocess.CompletedProcess( + ["gh", "auth", "token"], + 0, + stdout="gho-from-gh-auth\n", + stderr="", + ), + ): + agent = start_agent( + "Use GitHub from cron.", + hostname="host-a", + ) + shown = show_agent(agent["id"]) + self.assertEqual(shown["session"]["env"]["GH_TOKEN"], "gho-from-gh-auth") + + def test_start_agent_keeps_existing_gh_token_without_calling_gh(self): + with _temp_home(): + with patch.dict(os.environ, {"GH_TOKEN": "existing-gh-token"}, clear=False): + with patch("codexapi.agents.shutil.which", return_value="/usr/bin/gh"): + with patch("codexapi.agents.subprocess.run") as run_mock: + agent = start_agent( + "Use existing GitHub token.", + hostname="host-a", + ) + shown = show_agent(agent["id"]) + self.assertEqual(shown["session"]["env"]["GH_TOKEN"], "existing-gh-token") + run_mock.assert_not_called() + + def test_cli_managed_agent_views_show_operator_fields(self): + start = datetime(2026, 3, 6, 8, 0, tzinfo=timezone.utc) + end = start + timedelta(hours=1) + + def fake_runner(meta, session, prompt): + return { + "message": json.dumps( + { + "status": "Handled", + "continue": True, + "reply": "Message handled.", + } + ), + "thread_id": "thread-usage", + "usage": { + "input_tokens": 30, + "output_tokens": 20, + "total_tokens": 50, + }, + } + + with _temp_home(): + agent = start_agent( + "Handle messages.", + hostname="host-a", + now=start, + ) + send_agent(agent["id"], "ping", hostname="host-a", now=start) + with patch("codexapi.agents.utc_now", return_value=end): + nudge_agent( + agent["id"], + hostname="host-a", + now=start + timedelta(seconds=10), + runner=fake_runner, + ) + shown = show_agent(agent["id"]) + list_out = io.StringIO() + with redirect_stdout(list_out): + _print_managed_agent_list([shown]) + self.assertIn("POL", list_out.getvalue()) + self.assertIn("QMSG", list_out.getvalue()) + self.assertIn("QCMD", list_out.getvalue()) + self.assertIn("REPO", list_out.getvalue()) + self.assertIn("done", list_out.getvalue()) + self.assertIn("codexapi", list_out.getvalue()) + + show_out = io.StringIO() + with redirect_stdout(show_out): + _print_managed_agent_show(shown) + text = show_out.getvalue() + self.assertIn("Policy: until_done", text) + self.assertIn("Qmsg: 0 Qcmd: 0", text) + self.assertIn("Tokens: 50 total (30 in, 20 out, 50.0/h)", text) + self.assertIn("Prompt: Handle messages.", text) + self.assertIn("Recent runs:", text) + self.assertIn("msgs=1", text) + + def test_cli_start_warns_when_cron_missing(self): + with _temp_home() as home: + output = io.StringIO() + errors = io.StringIO() + with patch("codexapi.agents._ensure_backend_available", return_value="/usr/bin/codex"): + with patch( + "codexapi.cli.agent_cron_status", + return_value={"configured": False, "healthy": False, "reason": ""}, + ): + with redirect_stdout(output), redirect_stderr(errors): + cli_main(["agent", "start", "Handle messages."]) + payload = json.loads(output.getvalue()) + self.assertFalse(payload["waited"]) + warning = errors.getvalue() + self.assertIn("Background agent wakes will not run", warning) + self.assertIn(str(home), warning) + self.assertIn("codexapi agent install-cron", warning) + + def test_cli_start_can_enable_fast_mode(self): + with _temp_home(): + output = io.StringIO() + errors = io.StringIO() + with patch("codexapi.agents._ensure_backend_available", return_value="/usr/bin/codex"): + with patch( + "codexapi.cli.agent_cron_status", + return_value={"configured": False, "healthy": False, "reason": ""}, + ): + with redirect_stdout(output), redirect_stderr(errors): + cli_main(["agent", "start", "--fast", "Handle messages."]) + payload = json.loads(output.getvalue()) + shown = show_agent(payload["id"]) + self.assertTrue(shown["session"]["fast"]) + + def test_cli_start_warns_when_scheduler_is_broken(self): + output = io.StringIO() + errors = io.StringIO() + with _temp_home(): + with patch("codexapi.agents._ensure_backend_available", return_value="/usr/bin/codex"): + with patch( + "codexapi.cli.agent_cron_status", + return_value={ + "configured": True, + "healthy": False, + "reason": "Wrapper python '/tmp/venv/bin/python' cannot import codexapi.", + }, + ): + with redirect_stdout(output), redirect_stderr(errors): + cli_main(["agent", "start", "Handle messages."]) + payload = json.loads(output.getvalue()) + self.assertFalse(payload["waited"]) + warning = errors.getvalue() + self.assertIn("installed but not runnable", warning) + self.assertIn("cannot import codexapi", warning) + self.assertIn("Reinstall it with", warning) + + def test_cli_start_fails_fast_when_backend_is_missing(self): + output = io.StringIO() + errors = io.StringIO() + with _temp_home(): + with patch( + "codexapi.agents._ensure_backend_available", + side_effect=RuntimeError("Codex CLI not found: 'codex'."), + ): + with redirect_stdout(output), redirect_stderr(errors): + with self.assertRaises(SystemExit) as exc: + cli_main(["agent", "start", "Handle messages."]) + self.assertEqual(str(exc.exception), "Codex CLI not found: 'codex'.") + + def test_cli_send_queues_by_default(self): + with _temp_home(): + with patch.dict(os.environ, {"CODEXAPI_HOSTNAME": "host-a"}, clear=False): + agent = start_agent( + "Handle messages.", + hostname="host-a", + ) + output = io.StringIO() + with patch( + "codexapi.cli.nudge_agent", + return_value={"ran": True, "woken": 1, "spawned": True}, + ) as nudge_mock: + with redirect_stdout(output): + cli_main(["agent", "send", agent["id"], "status"]) + payload = json.loads(output.getvalue()) + self.assertFalse(payload["waited"]) + self.assertTrue(payload["nudge"]["spawned"]) + nudge_mock.assert_called_once_with(agent["id"], wait=False) + shown = show_agent(agent["id"]) + self.assertEqual(shown["unread_message_count"], 1) + self.assertEqual(shown["state"]["status"], "ready") + + def test_cli_resume_without_wait_async_nudges_and_list_shows_resuming(self): + def fake_runner(meta, session, prompt): + return { + "message": json.dumps( + { + "status": "Still running", + "continue": True, + "reply": "Continuing.", + } + ), + "thread_id": "thread-resume-ui", + } + + with _temp_home(): + agent = start_agent("Keep an eye on this.", hostname="host-a") + control_agent(agent["id"], "pause", hostname="host-a") + tick(hostname="host-a", runner=fake_runner) + + output = io.StringIO() + with patch( + "codexapi.cli.nudge_agent", + return_value={"ran": True, "woken": 1, "spawned": True}, + ) as nudge_mock: + with redirect_stdout(output): + cli_main(["agent", "resume", agent["id"]]) + payload = json.loads(output.getvalue()) + self.assertFalse(payload["waited"]) + self.assertTrue(payload["nudge"]["spawned"]) + nudge_mock.assert_called_once_with(agent["id"], wait=False) + + shown = show_agent(agent["id"]) + self.assertEqual(shown["state"]["status"], "paused") + self.assertEqual(shown["display_status"], "resuming") + self.assertEqual(shown["pending_commands"], ["resume"]) + self.assertEqual(shown["pending_command_count"], 1) + + list_out = io.StringIO() + with redirect_stdout(list_out): + _print_managed_agent_list([shown]) + self.assertIn("resuming", list_out.getvalue()) + self.assertIn("QMSG", list_out.getvalue()) + self.assertIn("QCMD", list_out.getvalue()) + + show_out = io.StringIO() + with redirect_stdout(show_out): + _print_managed_agent_show(shown) + text = show_out.getvalue() + self.assertIn("[resuming]", text) + self.assertIn("State: paused", text) + self.assertIn("Pending commands: resume", text) + + def test_cli_send_wait_shows_immediate_agent_reply(self): + with _temp_home(): + with patch.dict(os.environ, {"CODEXAPI_HOSTNAME": "host-a"}, clear=False): + agent = start_agent( + "Handle messages.", + hostname="host-a", + ) + + class FakeAgent: + def __init__( + self, + cwd=None, + yolo=True, + thread_id=None, + flags=None, + include_thinking=False, + backend=None, + env=None, + ): + self.thread_id = thread_id + self.last_usage = {} + + def __call__(self, prompt): + self.thread_id = "thread-cli-send" + return json.dumps( + { + "status": "Answered immediately", + "continue": False, + "reply": "I saw your note.", + } + ) + + output = io.StringIO() + with patch("codexapi.agents.Agent", FakeAgent): + with redirect_stdout(output): + cli_main(["agent", "send", "--wait", agent["id"], "status"]) + payload = json.loads(output.getvalue()) + self.assertTrue(payload["waited"]) + self.assertTrue(payload["nudge"]["woken"]) + self.assertTrue(payload["delivered"]) + self.assertEqual(payload["agent_status"], "Answered immediately") + self.assertEqual(payload["agent_reply"], "I saw your note.") + + def test_cli_set_heartbeat_updates_agent(self): + start = datetime(2026, 3, 6, 8, 0, tzinfo=timezone.utc) + later = start + timedelta(minutes=3) + + with _temp_home(): + agent = start_agent( + "Handle messages.", + hostname="host-a", + heartbeat_minutes=30, + now=start, + ) + output = io.StringIO() + with patch("codexapi.agents.utc_now", return_value=later): + with redirect_stdout(output): + cli_main(["agent", "set-heartbeat", agent["id"], "12"]) + payload = json.loads(output.getvalue()) + self.assertTrue(payload["changed"]) + self.assertEqual(payload["heartbeat_minutes"], 12) + shown = show_agent(agent["id"]) + self.assertEqual(shown["meta"]["heartbeat_minutes"], 12) + + def test_cli_views_mark_stale_running_agent(self): + start = datetime(2026, 3, 9, 14, 0, tzinfo=timezone.utc) + stale_now = start + timedelta(hours=2) + + with _temp_home() as home: + agent = start_agent( + "Handle messages.", + hostname="host-a", + heartbeat_minutes=5, + now=start, + ) + state_path = home / "agents" / agent["id"] / "state.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["status"] = "running" + state["last_wake_at"] = format_utc(start) + state_path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + rollout = home / "rollouts" / "rollout-thread-stale.jsonl" + _write_rollout( + rollout, + [ + { + "timestamp": "2026-03-09T14:00:00Z", + "type": "event_msg", + "payload": {"type": "task_started", "turn_id": "turn-stale"}, + }, + { + "timestamp": "2026-03-09T14:00:10Z", + "type": "event_msg", + "payload": { + "type": "agent_message", + "phase": "commentary", + "message": "Still checking.", + }, + }, + ], + ) + _set_rollout_session(home, agent["id"], "host-a", "thread-stale", rollout) + + lock_path = home / "agents" / agent["id"] / "hosts" / "host-a" / "run.lock" + with patch("codexapi.agents.utc_now", return_value=stale_now): + with _try_lock(lock_path) as handle: + self.assertIsNotNone(handle) + shown = show_agent(agent["id"]) + status = status_agent(agent["id"]) + + list_out = io.StringIO() + with redirect_stdout(list_out): + _print_managed_agent_list([shown]) + + show_out = io.StringIO() + with redirect_stdout(show_out): + _print_managed_agent_show(shown) + + status_out = io.StringIO() + with redirect_stdout(status_out): + _print_managed_agent_status(status) + + self.assertTrue(shown["run_lock_held"]) + self.assertTrue(shown["stale"]) + self.assertEqual(shown["last_event_at"], "2026-03-09T14:00:10Z") + self.assertEqual(status["turn_state"], "stale") + self.assertEqual(status["last_event_at"], "2026-03-09T14:00:10Z") + self.assertIn("stale", list_out.getvalue()) + self.assertIn("Stale: yes", show_out.getvalue()) + self.assertIn("Turn: turn-stale [stale]", status_out.getvalue()) + + def test_recover_agent_marks_running_agent_error_and_requests_wake(self): + start = datetime(2026, 3, 9, 14, 0, tzinfo=timezone.utc) + recover_at = start + timedelta(hours=2) + + with _temp_home() as home: + agent = start_agent( + "Handle messages.", + hostname="host-a", + heartbeat_minutes=5, + now=start, + ) + state_path = home / "agents" / agent["id"] / "state.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["status"] = "running" + state["last_wake_at"] = format_utc(start) + state_path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + lock_path = home / "agents" / agent["id"] / "hosts" / "host-a" / "run.lock" + lock_path.write_text( + json.dumps( + {"pid": 4321, "hostname": "host-a", "started_at": format_utc(start)} + ) + + "\n", + encoding="utf-8", + ) + + with patch( + "codexapi.agents._agent_runtime", + return_value={ + "run_lock_held": True, + "last_event_at": "2026-03-09T14:00:10Z", + "stale": True, + "stale_after_seconds": 1800, + "stale_for_seconds": 7190, + }, + ): + with patch( + "codexapi.agents._recover_run_lock", + return_value={ + "pid": 4321, + "pgid": 4321, + "sent_sigterm": True, + "sent_sigkill": False, + }, + ) as recover_lock: + result = recover_agent(agent["id"], hostname="host-a", now=recover_at) + + shown = show_agent(agent["id"]) + recover_lock.assert_called_once() + self.assertEqual(recover_lock.call_args[0][0], lock_path.resolve()) + self.assertTrue(result["recovered"]) + self.assertTrue(result["stale"]) + self.assertEqual(result["signal"]["pid"], 4321) + self.assertEqual(shown["state"]["status"], "error") + self.assertEqual(shown["state"]["last_error"], "Recovered stuck wake.") + self.assertEqual(shown["state"]["wake_requested_at"], format_utc(recover_at)) + + def test_cli_recover_wait_nudges_after_recovery(self): + with _temp_home(): + agent = start_agent("Handle messages.", hostname="host-a") + output = io.StringIO() + with patch( + "codexapi.cli.recover_managed_agent", + return_value={"id": agent["id"], "name": agent["name"], "status": "error"}, + ) as recover_mock: + with patch( + "codexapi.cli.nudge_agent", + return_value={"ran": True, "woken": 1}, + ) as nudge_mock: + with redirect_stdout(output): + cli_main(["agent", "recover", "--wait", agent["id"]]) + payload = json.loads(output.getvalue()) + recover_mock.assert_called_once_with(agent["id"]) + nudge_mock.assert_called_once_with(agent["id"], wait=True) + self.assertTrue(payload["waited"]) + self.assertEqual(payload["nudge"]["woken"], 1) + + def test_status_agent_returns_latest_completed_turn(self): + with _temp_home() as home: + agent = start_agent("Handle messages.", hostname="host-a") + rollout = home / "rollouts" / "rollout-thread-status.jsonl" + _write_rollout( + rollout, + [ + { + "timestamp": "2026-03-09T13:00:00Z", + "type": "event_msg", + "payload": {"type": "task_started", "turn_id": "turn-old"}, + }, + { + "timestamp": "2026-03-09T13:00:01Z", + "type": "event_msg", + "payload": { + "type": "agent_message", + "phase": "commentary", + "message": "Old turn.", + }, + }, + { + "timestamp": "2026-03-09T13:00:02Z", + "type": "event_msg", + "payload": { + "type": "task_complete", + "turn_id": "turn-old", + "last_agent_message": "{\"status\":\"Old\",\"continue\":false}", + }, + }, + { + "timestamp": "2026-03-09T13:10:00Z", + "type": "event_msg", + "payload": {"type": "task_started", "turn_id": "turn-new"}, + }, + { + "timestamp": "2026-03-09T13:10:01Z", + "type": "event_msg", + "payload": { + "type": "agent_message", + "phase": "commentary", + "message": "Checking the repository state.", + }, + }, + { + "timestamp": "2026-03-09T13:10:02Z", + "type": "response_item", + "payload": { + "type": "function_call", + "name": "exec_command", + "arguments": json.dumps({"cmd": "git status --short"}), + "call_id": "call-cmd", + }, + }, + { + "timestamp": "2026-03-09T13:10:03Z", + "type": "response_item", + "payload": { + "type": "function_call_output", + "call_id": "call-cmd", + "output": "Chunk ID: 123456\nWall time: 0.0100 seconds\nProcess exited with code 0\nOriginal token count: 5\nOutput:\nM README.md\n", + }, + }, + { + "timestamp": "2026-03-09T13:10:04Z", + "type": "event_msg", + "payload": { + "type": "agent_message", + "phase": "commentary", + "message": "Updating the agent notes now.", + }, + }, + { + "timestamp": "2026-03-09T13:10:05Z", + "type": "response_item", + "payload": { + "type": "custom_tool_call", + "name": "apply_patch", + "status": "completed", + "call_id": "call-patch", + "input": "*** Begin Patch\n*** Update File: /tmp/AGENTBOOK.md\n@@\n-old\n+new\n*** End Patch\n", + }, + }, + { + "timestamp": "2026-03-09T13:10:06Z", + "type": "response_item", + "payload": { + "type": "custom_tool_call_output", + "call_id": "call-patch", + "output": json.dumps( + { + "output": "Success. Updated the following files:\nM /tmp/AGENTBOOK.md\n", + "metadata": {"exit_code": 0, "duration_seconds": 0.1}, + } + ), + }, + }, + { + "timestamp": "2026-03-09T13:10:07Z", + "type": "event_msg", + "payload": { + "type": "agent_message", + "phase": "final_answer", + "message": "{\"status\":\"Ready to merge\",\"continue\":false,\"reply\":\"Looks good.\"}", + }, + }, + { + "timestamp": "2026-03-09T13:10:08Z", + "type": "event_msg", + "payload": { + "type": "task_complete", + "turn_id": "turn-new", + "last_agent_message": "{\"status\":\"Ready to merge\",\"continue\":false,\"reply\":\"Looks good.\"}", + }, + }, + ], + ) + _set_rollout_session(home, agent["id"], "host-a", "thread-status", rollout) + + result = status_agent(agent["id"], include_actions=True) + self.assertEqual(result["turn_id"], "turn-new") + self.assertEqual(result["turn_state"], "complete") + self.assertEqual(result["started_at"], "2026-03-09T13:10:00Z") + self.assertEqual(result["ended_at"], "2026-03-09T13:10:08Z") + self.assertEqual( + result["progress"], + [ + "Checking the repository state.", + "Updating the agent notes now.", + ], + ) + self.assertEqual(result["final_json"]["status"], "Ready to merge") + self.assertEqual(result["final_json"]["reply"], "Looks good.") + self.assertEqual(len(result["tools"]), 2) + self.assertEqual(result["tools"][0]["name"], "exec_command") + self.assertEqual(result["tools"][0]["command"], "git status --short") + self.assertEqual(result["tools"][0]["exit_code"], 0) + self.assertEqual(result["tools"][0]["output"], "M README.md") + self.assertEqual(result["tools"][1]["name"], "apply_patch") + self.assertEqual(result["tools"][1]["files"], ["/tmp/AGENTBOOK.md"]) + + def test_status_agent_returns_missing_when_no_rollout_is_known(self): + with _temp_home(): + agent = start_agent("Handle messages.", hostname="host-a") + result = status_agent(agent["id"]) + self.assertEqual(result["turn_state"], "missing") + self.assertEqual(result["rollout_path"], "") + self.assertEqual(result["progress"], []) + + def test_status_agent_returns_active_turn_when_run_lock_is_held(self): + with _temp_home() as home: + agent = start_agent("Handle messages.", hostname="host-a") + rollout = home / "rollouts" / "rollout-thread-active.jsonl" + _write_rollout( + rollout, + [ + { + "timestamp": "2026-03-09T14:00:00Z", + "type": "event_msg", + "payload": {"type": "task_started", "turn_id": "turn-active"}, + }, + { + "timestamp": "2026-03-09T14:00:01Z", + "type": "event_msg", + "payload": { + "type": "agent_message", + "phase": "commentary", + "message": "Checking the latest CI run now.", + }, + }, + { + "timestamp": "2026-03-09T14:00:02Z", + "type": "response_item", + "payload": { + "type": "function_call", + "name": "exec_command", + "arguments": json.dumps({"cmd": "gh pr checks 123"}), + "call_id": "call-live", + }, + }, + { + "timestamp": "2026-03-09T14:00:03Z", + "type": "response_item", + "payload": { + "type": "function_call_output", + "call_id": "call-live", + "output": "Chunk ID: 654321\nWall time: 0.0100 seconds\nProcess exited with code 0\nOriginal token count: 5\nOutput:\nci / in_progress\n", + }, + }, + { + "timestamp": "2026-03-09T14:00:04Z", + "type": "event_msg", + "payload": { + "type": "agent_message", + "phase": "commentary", + "message": "The required checks are still running.", + }, + }, + ], + ) + _set_rollout_session(home, agent["id"], "host-a", "thread-active", rollout) + + lock_path = home / "agents" / agent["id"] / "hosts" / "host-a" / "run.lock" + with _try_lock(lock_path) as handle: + self.assertIsNotNone(handle) + with patch( + "codexapi.agents.utc_now", + return_value=datetime(2026, 3, 9, 14, 5, tzinfo=timezone.utc), + ): + result = status_agent(agent["id"]) + + self.assertEqual(result["turn_id"], "turn-active") + self.assertEqual(result["turn_state"], "active") + self.assertEqual(result["ended_at"], "") + self.assertEqual(result["final_output"], "The required checks are still running.") + self.assertIsNone(result["final_json"]) + self.assertEqual( + result["progress"], + [ + "Checking the latest CI run now.", + "The required checks are still running.", + ], + ) + + def test_cli_status_shows_latest_turn_details(self): + with _temp_home() as home: + agent = start_agent("Handle messages.", hostname="host-a") + rollout = home / "rollouts" / "rollout-thread-cli.jsonl" + _write_rollout( + rollout, + [ + { + "timestamp": "2026-03-09T15:00:00Z", + "type": "event_msg", + "payload": {"type": "task_started", "turn_id": "turn-cli"}, + }, + { + "timestamp": "2026-03-09T15:00:01Z", + "type": "event_msg", + "payload": { + "type": "agent_message", + "phase": "commentary", + "message": "Inspecting the latest rollout details.", + }, + }, + { + "timestamp": "2026-03-09T15:00:02Z", + "type": "response_item", + "payload": { + "type": "function_call", + "name": "exec_command", + "arguments": json.dumps({"cmd": "git status --short"}), + "call_id": "call-cli", + }, + }, + { + "timestamp": "2026-03-09T15:00:03Z", + "type": "response_item", + "payload": { + "type": "function_call_output", + "call_id": "call-cli", + "output": "Chunk ID: 111111\nWall time: 0.0100 seconds\nProcess exited with code 0\nOriginal token count: 5\nOutput:\nM README.md\n", + }, + }, + { + "timestamp": "2026-03-09T15:00:04Z", + "type": "event_msg", + "payload": { + "type": "agent_message", + "phase": "final_answer", + "message": "{\"status\":\"Handled\",\"continue\":false}", + }, + }, + { + "timestamp": "2026-03-09T15:00:05Z", + "type": "event_msg", + "payload": { + "type": "task_complete", + "turn_id": "turn-cli", + "last_agent_message": "{\"status\":\"Handled\",\"continue\":false}", + }, + }, + ], + ) + _set_rollout_session(home, agent["id"], "host-a", "thread-cli", rollout) + + output = io.StringIO() + with redirect_stdout(output): + cli_main(["agent", "status", agent["id"]]) + text = output.getvalue() + self.assertIn("Turn: turn-cli [complete]", text) + self.assertIn("Progress:", text) + self.assertIn("Inspecting the latest rollout details.", text) + self.assertIn("Final fields:", text) + self.assertIn("Status: Handled", text) + self.assertNotIn("Final output:", text) + self.assertNotIn("Actions:", text) + + def test_cli_status_with_actions_shows_tool_summaries(self): + with _temp_home() as home: + agent = start_agent("Handle messages.", hostname="host-a") + rollout = home / "rollouts" / "rollout-thread-cli-actions.jsonl" + _write_rollout( + rollout, + [ + { + "timestamp": "2026-03-09T16:00:00Z", + "type": "event_msg", + "payload": {"type": "task_started", "turn_id": "turn-cli-actions"}, + }, + { + "timestamp": "2026-03-09T16:00:01Z", + "type": "event_msg", + "payload": { + "type": "agent_message", + "phase": "commentary", + "message": "Inspecting the latest rollout details.", + }, + }, + { + "timestamp": "2026-03-09T16:00:02Z", + "type": "response_item", + "payload": { + "type": "function_call", + "name": "exec_command", + "arguments": json.dumps({"cmd": "git status --short"}), + "call_id": "call-cli-actions", + }, + }, + { + "timestamp": "2026-03-09T16:00:03Z", + "type": "response_item", + "payload": { + "type": "function_call_output", + "call_id": "call-cli-actions", + "output": "Chunk ID: 222222\nWall time: 0.0100 seconds\nProcess exited with code 0\nOriginal token count: 5\nOutput:\nM README.md\n", + }, + }, + { + "timestamp": "2026-03-09T16:00:04Z", + "type": "event_msg", + "payload": { + "type": "agent_message", + "phase": "final_answer", + "message": "{\"status\":\"Handled\",\"continue\":false}", + }, + }, + { + "timestamp": "2026-03-09T16:00:05Z", + "type": "event_msg", + "payload": { + "type": "task_complete", + "turn_id": "turn-cli-actions", + "last_agent_message": "{\"status\":\"Handled\",\"continue\":false}", + }, + }, + ], + ) + _set_rollout_session(home, agent["id"], "host-a", "thread-cli-actions", rollout) + + output = io.StringIO() + with redirect_stdout(output): + cli_main(["agent", "status", "--actions", agent["id"]]) + text = output.getvalue() + self.assertIn("Actions:", text) + self.assertIn("Running command: git status --short (exit 0)", text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_async_agent.py b/tests/test_async_agent.py new file mode 100644 index 0000000..f31cc2e --- /dev/null +++ b/tests/test_async_agent.py @@ -0,0 +1,219 @@ +import os +import stat +import sys +import tempfile +import textwrap +import unittest +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from codexapi import AsyncAgent + + +class AsyncAgentTests(unittest.TestCase): + def test_async_agent_reports_rollout_progress_and_final_output(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + code_home = root / "codex-home" + workdir = root / "work" + workdir.mkdir() + fake_codex = root / "fake-codex" + fake_codex.write_text( + textwrap.dedent( + """\ + #!/usr/bin/env python3 + import json + import os + import sys + import time + from pathlib import Path + + cwd = "" + args = sys.argv[1:] + for index, value in enumerate(args): + if value == "--cd" and index + 1 < len(args): + cwd = args[index + 1] + + prompt = sys.stdin.read() + if not prompt: + raise SystemExit("missing prompt") + + thread_id = "thread-async" + print(json.dumps({"type": "thread.started", "thread_id": thread_id}), flush=True) + print(json.dumps({"type": "turn.started"}), flush=True) + + rollout = ( + Path(os.environ["CODEX_HOME"]) + / "sessions" + / "2026" + / "03" + / "21" + / "rollout-2026-03-21T11-00-00-thread-async.jsonl" + ) + rollout.parent.mkdir(parents=True, exist_ok=True) + with open(rollout, "w", encoding="utf-8") as handle: + handle.write( + json.dumps( + { + "timestamp": "2026-03-21T11:00:00Z", + "type": "session_meta", + "payload": { + "id": thread_id, + "timestamp": "2026-03-21T11:00:00Z", + "cwd": cwd, + "source": "exec", + }, + } + ) + + "\\n" + ) + handle.write( + json.dumps( + { + "timestamp": "2026-03-21T11:00:01Z", + "type": "event_msg", + "payload": {"type": "task_started", "turn_id": "turn-1"}, + } + ) + + "\\n" + ) + handle.write( + json.dumps( + { + "timestamp": "2026-03-21T11:00:02Z", + "type": "event_msg", + "payload": { + "type": "agent_message", + "phase": "commentary", + "message": "Inspecting the decode path now.", + }, + } + ) + + "\\n" + ) + handle.flush() + + time.sleep(0.05) + print( + json.dumps( + { + "type": "item.completed", + "item": { + "id": "item-1", + "type": "agent_message", + "text": "Wrote AUTODEBUG.md", + }, + } + ), + flush=True, + ) + print( + json.dumps( + { + "type": "turn.completed", + "usage": { + "input_tokens": 10, + "output_tokens": 4, + "total_tokens": 14, + }, + } + ), + flush=True, + ) + """ + ), + encoding="utf-8", + ) + fake_codex.chmod(fake_codex.stat().st_mode | stat.S_IXUSR) + + with patch.dict( + os.environ, + {"CODEX_HOME": str(code_home), "USER": "tester"}, + clear=False, + ): + with patch("codexapi.async_agent._CODEX_BIN", str(fake_codex)): + agent = AsyncAgent.start( + "Investigate the bug.", + cwd=str(workdir), + backend="codex", + name="async-test", + ) + updates = list(agent.watch(poll_interval=0.01)) + final = agent.status() + + self.assertGreaterEqual(len(updates), 1) + self.assertEqual(final["status"], "done") + self.assertEqual(final["thread_id"], "thread-async") + self.assertIn("Inspecting the decode path now.", final["progress"]) + self.assertEqual(final["final_output"], "Wrote AUTODEBUG.md") + self.assertEqual( + agent.last_usage, + {"input_tokens": 10, "output_tokens": 4, "total_tokens": 14}, + ) + + def test_async_agent_reports_codex_json_error_events(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + code_home = root / "codex-home" + workdir = root / "work" + workdir.mkdir() + fake_codex = root / "fake-codex" + fake_codex.write_text( + textwrap.dedent( + """\ + #!/usr/bin/env python3 + import json + import sys + + sys.stdin.read() + print(json.dumps({"type": "thread.started", "thread_id": "thread-error"}), flush=True) + print(json.dumps({"type": "turn.started"}), flush=True) + print( + json.dumps( + { + "type": "error", + "message": "Reconnecting... 5/5 (model unavailable)", + } + ), + flush=True, + ) + print( + json.dumps( + { + "type": "turn.failed", + "error": {"message": "The model `gpt-5.5` does not exist."}, + } + ), + flush=True, + ) + raise SystemExit(1) + """ + ), + encoding="utf-8", + ) + fake_codex.chmod(fake_codex.stat().st_mode | stat.S_IXUSR) + + with patch.dict( + os.environ, + {"CODEX_HOME": str(code_home), "USER": "tester"}, + clear=False, + ): + with patch("codexapi.async_agent._CODEX_BIN", str(fake_codex)): + agent = AsyncAgent.start( + "Investigate the bug.", + cwd=str(workdir), + backend="codex", + name="async-error-test", + ) + final = agent.wait(poll_interval=0.01) + + self.assertEqual(final["status"], "error") + self.assertEqual(final["last_error"], "The model `gpt-5.5` does not exist.") + self.assertIn("model unavailable", final["errors"][0]) + self.assertEqual(final["activity"], "The model `gpt-5.5` does not exist.") + + +if __name__ == "__main__": + unittest.main()