Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .codex/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,22 @@ coordination and audit. See `AGENTS.md` §"Commit
attribution — harness-specific trailers" for the
full convention.

Headless Codex host-loop commits add machine-readable
origin trailers so future deployments can distinguish
background work from foreground Vera / Codex chat work:

```
Codex-Origin: codex-launchd-loop
Codex-Surface: codex-background-service
Codex-Loop-Run-Id: <run id from ZETA_CODEX_LOOP_RUN_ID>
```

Foreground Codex chat commits do not use these extra
trailers. The difference is intentional: the shared
`Co-Authored-By` trailer identifies the harness, while
the `Codex-*` trailers identify the headless launchd
surface that created the work.

## Visible Speaker Prefix

While multiple agent chat surfaces are active, Codex / Vera
Expand Down
75 changes: 66 additions & 9 deletions .codex/bin/codex-loop-tick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ const codexTimeoutMs = Number(process.env.ZETA_CODEX_LOOP_CODEX_TIMEOUT_SECONDS
const codexBypassApprovals = process.env.ZETA_CODEX_LOOP_BYPASS_APPROVALS !== "0";
const dryRun = process.env.ZETA_CODEX_LOOP_DRY_RUN === "1";
const codexStateFile = join(stateDir, "last-codex-run.json");
const loopOrigin = process.env.ZETA_CODEX_LOOP_ORIGIN ?? "codex-launchd-loop";
const loopSurface = process.env.ZETA_CODEX_LOOP_SURFACE ?? "codex-background-service";
const loopSession = process.env.ZETA_CODEX_LOOP_SESSION ?? "codex/launchd-loop";

function nowIso(): string {
return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
Expand All @@ -31,12 +34,18 @@ function ensureRuntimeDirs(): void {
mkdirSync(logDir, { recursive: true });
}

function run(command: string, args: string[], timeoutMs: number): { status: number; stdout: string; stderr: string } {
function run(
command: string,
args: string[],
timeoutMs: number,
extraEnv: Record<string, string> = {},
): { status: number; stdout: string; stderr: string } {
const result = spawnSync(command, args, {
cwd: worktree,
encoding: "utf8",
env: {
...process.env,
...extraEnv,
PATH: `/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:${join(home, ".local/bin")}`,
},
timeout: timeoutMs,
Expand Down Expand Up @@ -145,11 +154,43 @@ export function codexExecArgs(config: { worktree: string; prompt: string; bypass
return args;
}

export function buildCodexPrompt(config: { home?: string } = {}): string {
export function codexLoopEnv(config: {
runId: string;
origin?: string;
surface?: string;
session?: string;
}): Record<string, string> {
return {
ZETA_AGENT_ORIGIN: config.origin ?? loopOrigin,
ZETA_AGENT_SURFACE: config.surface ?? loopSurface,
ZETA_CODEX_LOOP_RUN_ID: config.runId,
ZETA_CODEX_LOOP_SESSION: config.session ?? loopSession,
};
}

export function buildCodexPrompt(config: {
home?: string;
runId?: string;
origin?: string;
surface?: string;
session?: string;
} = {}): string {
const broadcastDir = join(config.home ?? home, ".local/share/zeta-broadcasts");
const promptRunId = config.runId ?? process.env.ZETA_CODEX_LOOP_RUN_ID ?? "unknown";
const promptOrigin = config.origin ?? loopOrigin;
const promptSurface = config.surface ?? loopSurface;
const promptSession = config.session ?? loopSession;

return [
"Act as the Codex background service for Zeta. This is an active self-owned work loop, not a monitor.",
[
"Provenance posture: this run is headless/background Codex, not foreground Codex chat.",
`Record this surface as ${promptSurface}, origin as ${promptOrigin}, session as ${promptSession}, and run id as ${promptRunId}.`,
"For any branch, claim file, PR body, PR comment, broadcast, or cleanup record created by this run, include the surface/origin/run-id fields when the format has room.",
`For PR bodies created by this run, include a provenance footer with \`Headless-Origin: ${promptOrigin}\`, \`Headless-Surface: ${promptSurface}\`, and \`Codex-Loop-Run-Id: ${promptRunId}\`.`,
`For commits created by this run, include the required \`Co-Authored-By: Codex <noreply@openai.com>\` trailer plus \`Codex-Origin: ${promptOrigin}\`, \`Codex-Surface: ${promptSurface}\`, and \`Codex-Loop-Run-Id: ${promptRunId}\`.`,
"For new loop-owned claims, prefer slugs and branches beginning with `codex-loop-` so headless work is distinguishable from foreground Vera work.",
].join(" "),
[
"Cold-start by reading the repo rules before deciding:",
"`AGENTS.md`, `.codex/AGENTS.md`, `docs/ALIGNMENT.md`, `docs/AUTONOMOUS-LOOP.md`, `docs/AGENT-CLAIM-PROTOCOL.md`, and `docs/AGENT-ISSUE-WORKFLOW.md`.",
Expand Down Expand Up @@ -265,8 +306,11 @@ export function main(): number {
heartbeatTmp,
`${JSON.stringify(
{
session: "codex/launchd-loop",
session: loopSession,
harness: "codex",
surface: loopSurface,
origin: loopOrigin,
run_id: runId,
claim: "host-codex-loop",
branch,
worktree,
Expand All @@ -286,7 +330,7 @@ export function main(): number {

appendFileSync(
join(logDir, "heartbeat.log"),
`${nowIso()} run_id=${runId} branch=${branch} fetch=${fetchStatus} claims=${claims.length} open_prs=${openPrCount} dirty=${dirty.length} mode=heartbeat\n`,
`${nowIso()} run_id=${runId} origin=${loopOrigin} surface=${loopSurface} branch=${branch} fetch=${fetchStatus} claims=${claims.length} open_prs=${openPrCount} dirty=${dirty.length} mode=heartbeat\n`,
);

if (dryRun) {
Expand Down Expand Up @@ -314,16 +358,29 @@ export function main(): number {
return 0;
}

const prompt = buildCodexPrompt();

const codexStartedAt = nowIso();
writeCodexState({ run_id: runId, started_at: codexStartedAt, status: "running" });
const codexEnv = codexLoopEnv({ runId, origin: loopOrigin, surface: loopSurface, session: loopSession });
const prompt = buildCodexPrompt({ runId, origin: loopOrigin, surface: loopSurface, session: loopSession });
writeCodexState({
run_id: runId,
started_at: codexStartedAt,
status: "running",
origin: loopOrigin,
surface: loopSurface,
});
log(`codex forward gate start run_id=${runId} timeout=${Math.round(codexTimeoutMs / 1000)}s`);
const codex = run("codex", codexExecArgs({ worktree, prompt, bypassApprovals: codexBypassApprovals }), codexTimeoutMs);
const codex = run("codex", codexExecArgs({ worktree, prompt, bypassApprovals: codexBypassApprovals }), codexTimeoutMs, codexEnv);
appendFileSync(join(logDir, "ticks.log"), codex.stdout);
appendFileSync(join(logDir, "ticks.err"), codex.stderr);
log(`codex forward gate end run_id=${runId} status=${codex.status}`);
writeCodexState({ run_id: runId, started_at: codexStartedAt, finished_at: nowIso(), status: codex.status });
writeCodexState({
run_id: runId,
started_at: codexStartedAt,
finished_at: nowIso(),
status: codex.status,
origin: loopOrigin,
surface: loopSurface,
});
return codex.status;
}

Expand Down
62 changes: 51 additions & 11 deletions docs/CODEX-HARNESS-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,23 @@ Codex does not have Claude Code's native `CronCreate` /
`CronList` scheduled-task tools. On this machine, the Codex
autonomous loop is therefore a macOS `launchd` job.

| Field | Value |
|---|---|
| LaunchAgent label | `com.zeta.codex-loop` |
| Plist | `~/Library/LaunchAgents/com.zeta.codex-loop.plist` |
| Runner | `.codex/bin/codex-loop-tick.ts` |
| Control clone | `~/.local/share/zeta-codex-loop/Zeta` |
| Heartbeat cadence | 60 seconds (`StartInterval = 60`) |
| Codex gate cadence | 15 minutes when `ZETA_CODEX_LOOP_RUN_CODEX=1` |
| Logs | `~/Library/Logs/zeta-codex-loop/` |
| State / lock | `~/Library/Application Support/ZetaCodexLoop/` |
| Field | Value |
| ------------------ | -------------------------------------------------- |
| LaunchAgent label | `com.zeta.codex-loop` |
| Plist | `~/Library/LaunchAgents/com.zeta.codex-loop.plist` |
| Runner | `.codex/bin/codex-loop-tick.ts` |
| Control clone | `~/.local/share/zeta-codex-loop/Zeta` |
| Heartbeat cadence | 60 seconds (`StartInterval = 60`) |
| Codex gate cadence | 15 minutes when `ZETA_CODEX_LOOP_RUN_CODEX=1` |
| Logs | `~/Library/Logs/zeta-codex-loop/` |
| State / lock | `~/Library/Application Support/ZetaCodexLoop/` |

The runner writes a local heartbeat named
`codex-launchd-loop.json` under the clone's
`agent-heartbeats` directory, fetches remote refs, records
active claim count / open PR count / dirty state, then exits.
active claim count / open PR count / dirty state, and stamps
the heartbeat with `origin`, `surface`, and `run_id`, then
exits.
Codex has no native in-harness cron callback in this session.
The LaunchAgent is the loop substrate. It starts a bounded,
read-only Codex gate report only when
Expand All @@ -33,6 +35,44 @@ default gate interval is 900 seconds. The gate output lands in
`ticks.log` / `ticks.err`; it does not appear inside the
currently open chat transcript.

Headless provenance is part of the service contract. The
runner exports the following environment variables into the
spawned `codex exec` process:

| Variable | Default |
| ------------------------- | -------------------------- |
| `ZETA_AGENT_ORIGIN` | `codex-launchd-loop` |
| `ZETA_AGENT_SURFACE` | `codex-background-service` |
| `ZETA_CODEX_LOOP_SESSION` | `codex/launchd-loop` |
| `ZETA_CODEX_LOOP_RUN_ID` | current runner `run_id` |

The loop prompt instructs headless runs to carry those values
into claim files, PR bodies/comments, broadcasts, cleanup
records, and commit trailers when those surfaces are created
by the background service. Background PR bodies use a
searchable footer:

```text
Headless-Origin: codex-launchd-loop
Headless-Surface: codex-background-service
Codex-Loop-Run-Id: <run id>
```

Background commits keep the normal Codex harness trailer and
add:

```text
Co-Authored-By: Codex <noreply@openai.com>
Codex-Origin: codex-launchd-loop
Codex-Surface: codex-background-service
Codex-Loop-Run-Id: <run id>
```

Foreground Vera / Codex chat commits use the shared
`Co-Authored-By` trailer without the `Codex-*` launchd
trailers. This lets headless deployments count background PR
work without relying on a GUI transcript.

The runner invokes noninteractive Codex with
`--dangerously-bypass-approvals-and-sandbox` by default because
launchd has no human approval surface. Set
Expand Down
38 changes: 37 additions & 1 deletion tools/codex-loop-tick.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";

import { buildCodexPrompt, codexExecArgs } from "../.codex/bin/codex-loop-tick";
import { buildCodexPrompt, codexExecArgs, codexLoopEnv } from "../.codex/bin/codex-loop-tick";

describe("codex-loop-tick service contract", () => {
test("launches Codex with the current noninteractive bypass flag", () => {
Expand Down Expand Up @@ -29,6 +29,42 @@ describe("codex-loop-tick service contract", () => {
test("imports runner helpers without executing the loop", () => {
expect(typeof buildCodexPrompt).toBe("function");
expect(typeof codexExecArgs).toBe("function");
expect(typeof codexLoopEnv).toBe("function");
});

test("marks headless loop runs distinctly from foreground Codex chat", () => {
expect(
codexLoopEnv({
runId: "20260513T224509Z",
origin: "codex-launchd-loop",
surface: "codex-background-service",
session: "codex/launchd-loop",
}),
).toEqual({
ZETA_AGENT_ORIGIN: "codex-launchd-loop",
ZETA_AGENT_SURFACE: "codex-background-service",
ZETA_CODEX_LOOP_RUN_ID: "20260513T224509Z",
ZETA_CODEX_LOOP_SESSION: "codex/launchd-loop",
});

const prompt = buildCodexPrompt({
home: "/tmp/zeta-home",
runId: "20260513T224509Z",
origin: "codex-launchd-loop",
surface: "codex-background-service",
session: "codex/launchd-loop",
});

expect(prompt).toContain("headless/background Codex, not foreground Codex chat");
expect(prompt).toContain("surface as codex-background-service");
expect(prompt).toContain("origin as codex-launchd-loop");
expect(prompt).toContain("run id as 20260513T224509Z");
expect(prompt).toContain("`Headless-Origin: codex-launchd-loop`");
expect(prompt).toContain("`Headless-Surface: codex-background-service`");
expect(prompt).toContain("`Codex-Origin: codex-launchd-loop`");
expect(prompt).toContain("`Codex-Surface: codex-background-service`");
expect(prompt).toContain("`Codex-Loop-Run-Id: 20260513T224509Z`");
expect(prompt).toContain("branches beginning with `codex-loop-`");
});

test("foreground reliability prompt owns Codex PRs through merge before new work", () => {
Expand Down
Loading