Skip to content

Parity approach: Codex and OpenCode spawn-tagging + incremental ingest without native hooks #63

@willwashburn

Description

@willwashburn

Context

Claude Code ingest is the most observable of burn's three supported harnesses because Claude exposes a hook configuration surface (#7, landed). Codex and OpenCode do not expose equivalent hooks. Today:

  • burn claude pre-generates a session UUID, stamps metadata before spawn, passes --session-id to Claude, and installs hooks via --settings so every PreToolUse / PostToolUse / Stop / SubagentStop / SessionEnd event fires burn ingest --runtime claude against the live transcript (packages/ledger/src/hook-settings.ts, packages/cli/src/commands/claude.ts).
  • burn codex snapshots the rollouts directory before spawn, scans for new files after exit, and runs parseCodexSession over whatever appeared. Session IDs are derived from the filename pattern rollout-<ts>-<uuid>.jsonl (packages/cli/src/ingest.ts:deriveCodexSessionId), and stamp() runs after appendTurns — too late to carry pre-spawn context onto the first turn if a downstream consumer queries mid-run.
  • burn opencode uses an mtime heuristic (mtimeMs + 1 < spawnStartTs) to decide which session files belong to this spawn, then ingests at exit. It does already read session.parentID so isSidechain is populated for OpenCode subagents (packages/reader/src/opencode.ts). Codex has no parent-tracking path today.
  • Content sidecars are Claude-only (README status line, unchanged in code). Codex/OpenCode readers flow through the content.store modes but do not yet populate content records.

This leaves three practical gaps for Codex/OpenCode:

  1. Stamp-before-spawn is impossible because the session ID is only discoverable after the harness writes its first file.
  2. No incremental ingest — long-running Codex/OpenCode sessions are invisible in the ledger until the wrapper exits.
  3. No uniform subagent attribution — Claude uses isSidechain, OpenCode uses parentID, Codex has nothing, and none of these carry orchestrator-level context (workflow, step, parent agent) unless the spawner explicitly attaches it.

Goal

Define parity by outcomes, not mechanism. A spawned Codex or OpenCode instance should be:

  • Tagged before its first turn lands in the ledger with whatever the spawner knows (workflowId, agentId, parentAgentId, persona, tier, harness).
  • Ingested incrementally while the session is live, not only at wrapper exit.
  • Attributable to its parent in a spawn tree, using the same field shape across all three harnesses so #8 / #42 queries don't need source-specific branches.

Claude's native hooks remain the richest signal source; Codex/OpenCode need burn-owned substitutes that land the same outcomes.

Strategies

A. Pending-stamp manifest (pre-spawn tagging without a known session ID)

A small on-disk manifest under $RELAYBURN_HOME/pending-stamps/ keyed by (pid, spawnStartTs, cwd, harness). Each record carries the enrichment bag the spawner wants applied.

interface PendingStamp {
  v: 1;
  harness: 'codex' | 'opencode';
  spawnerPid: number;
  spawnStartTs: string;
  cwd: string;
  enrichment: Record<string, string>;
  // optional narrowing hints
  sessionDirHint?: string;
}

On ingest, burn ingest (or the wrapper's post-spawn code) resolves the manifest against newly-discovered sessions using the same mtime window already used for causality, calls stamp({ sessionId }, enrichment), and deletes the manifest entry. Stale entries TTL out after 24h.

Wins: removes the Codex/OpenCode post-hoc stamping race; lets orchestrators stamp before the session file exists.
Does not solve: incremental ingest.
Cost: small — one new file format, one resolver, write-points in codex.ts / opencode.ts.

B. burn watch — fs-watcher for incremental ingest

A single long-lived process that fs.watches ~/.codex/rollouts/ and ~/.local/share/opencode/storage/session/ and runs the existing Codex/OpenCode readers incrementally on change. The cursor + dedup machinery in packages/ledger/src/cursors.ts already makes repeated ingests idempotent, so the watcher can fire on every append.

Invocation options:

  • burn watch — foreground, for interactive sessions.
  • burn watch --daemon — background, managed via launchd/systemd-user unit (scoped out of this issue).
  • Auto-started by burn codex / burn opencode for the duration of the child process, then torn down on exit.

Wins: closes the incremental-ingest gap for both harnesses; also covers Claude passive ingest on machines where hooks weren't installed.
Cost: bigger — new command, new lifecycle, test surface for fs-watch flakiness.

C. Harness-native intercept where it exists (OpenCode only)

OpenCode has a server mode with an event stream. Subscribing to that would give real per-event parity (tool-call grain, not file grain). Codex has no equivalent surface I'm aware of — the rollout JSONL is the only observation point, and (B) is the ceiling there.

Worth pursuing only if a concrete attribution question (per-tool-call cost, mid-turn retry detection) needs finer grain than (B) provides. Deferred.

D. Spawner-owned tagging contract (the real parity mechanism)

Standardize env vars the spawner sets, that each burn <harness> wrapper reads and stamps:

RELAYBURN_WORKFLOW_ID
RELAYBURN_STEP_ID
RELAYBURN_AGENT_ID
RELAYBURN_PARENT_AGENT_ID
RELAYBURN_PERSONA
RELAYBURN_TIER

When burn codex / burn opencode spawns a child harness, it sets these on the child's env so the child's own wrapper picks them up transitively. This replicates Claude's isSidechain attribution at the orchestrator layer, independent of whether the harness itself reports parent/child relationships. Native signals (isSidechain, parentID) become cross-checks rather than sources of truth.

This is the strategy that actually makes parity achievable, because it's our abstraction — it works identically for all three harnesses.

Recommended sequencing

  1. (D) + (A) first. Small, self-contained changes to packages/cli/src/commands/codex.ts and opencode.ts. Pre-assign or reserve stamps, standardize env-var reading, write the manifest, resolve on ingest. This closes the tagging gap completely without any daemon work.
  2. (B) second. Ship burn watch once (A) + (D) are in. This closes the incremental-ingest gap and generalizes to all three harnesses.
  3. (C) deferred. Only pursue if a concrete backlog item needs finer-than-file grain for OpenCode.

Source-specific notes

Codex

  • Session-ID discovery today: filename regex. Add a fallback that reads the first JSONL line's session metadata so renamed/relocated rollouts still work.
  • No parent field in Codex rollouts — subagent attribution must come from (D). Native spawn_agent / wait correlation (see Execution graph for passive readers: session relationships and tool-result event chronology #42) can populate SessionRelationshipRecord but won't carry orchestrator tags.
  • Content sidecar: parser has the extraction path; populating it is a separate follow-up to the sidecar work, not blocking on this issue.

OpenCode

  • Session-ID discovery is solid (filename == sessionId). Keep.
  • parentID detection already populates isSidechain; keep as a cross-check against the (D) env-var chain.
  • mtime causality heuristic is racy under slow spawns — (A)'s manifest should supersede it as the primary correlation signal, with mtime as a fallback.

Claude (cross-check)

  • (D) applies here too: burn claude should read the same env vars and fold them into the stamp bag alongside the current harness / burnSpawn tags, so workflow/agent attribution survives across Claude↔Codex↔OpenCode spawn chains.

Acceptance

  • burn codex and burn opencode accept --tag k=v and honor RELAYBURN_* env vars, writing a pending-stamp manifest before spawn.
  • A Codex or OpenCode session spawned with workflowId=foo has that stamp applied to its first-ingested turn — not only after wrapper exit.
  • A Codex/OpenCode spawn chain carries parentAgentId through transitive env inheritance, and burn summary --agent <id> / --workflow <id> queries return the chain uniformly across all three harnesses.
  • Manifest resolver is idempotent and has a TTL so abandoned spawns don't accumulate.
  • (Phase 2) burn watch exists and ingests Codex/OpenCode sessions incrementally while live.
  • isSidechain continues to populate from native signals where available (Claude, OpenCode); Codex sidechain attribution is sourced from the (D) env-var chain and surfaced identically in queries.

Depends on

Unblocks

  • Subagent tree as first-class primitive (replace flat isSidechain flag) #8 subagent tree for Codex (currently unsupported) and tighter OpenCode attribution.
  • Workflow/agent-scoped burn compare across multi-harness spawn chains.
  • Content sidecar parity for Codex/OpenCode (separate follow-up, but stamp-before-first-turn is a prerequisite for joining content to orchestrator tags reliably).

Non-goals

  • Replicating Claude's per-tool-call hook payload grain for Codex. The rollout JSONL doesn't expose it and there's no cheap way to get there.
  • Modifying Codex or OpenCode upstream. Everything here is burn-owned.
  • Making native isSidechain / parentID the authoritative parent signal. (D) supersedes them.

Priority

High. This is the blocker for using burn as an attribution layer in multi-harness orchestrators, which is the core composability promise in the README.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions