## What a user gets Upgrading OpenRig no longer stops a working Codex seat at "Hooks need review" for OpenRig's own unchanged plugin. Codex 0.164 fingerprints the whole plugin, and this release changed openrig-core's scripts and skills while its six hook declarations stayed the same, so every upgraded Codex seat stopped. OpenRig now re-approves its own plugin on upgrade, without asking, when three things hold: - the earlier approval exactly matches the previously shipped plugin; - nothing is disabled; - the installed files exactly match what shipped. ## The change - **The renewal:** a new `codex-owned-plugin-trust.ts` plans it, and only when all three conditions hold. - "Previous" is OpenRig's recorded approval, else the pinned published 0.1.17. - Disables and choices in any config layer or profile keep native review. - It writes only `trusted_content_hash` under the six OpenRig-owned hook headers, plus one small receipt (mode 0600) per Codex home. - **Checked before writing:** the proposed config is parsed and must equal the original plus exactly the planned fields. Otherwise nothing is written, no receipt is recorded and native review stays. This covers quoted keys and table text inside multiline strings. The final re-read before the write is kept. - **Install versus approval:** - Install eligibility keeps the previous check: the shipped hook files and the Codex manifest. So a home with extra non-hook files still gets the upgraded plugin. - The first-install declaration grant and the renewal both require the exact shipped tree, so an unmatched tree is never approved automatically. - **Main only:** renewal stays in the selected Codex home (#1937). **Unchanged:** explicit disables, refusals, custom config layers, other people's hooks, and Codex's own baseline. Codex records no "continue without trusting" choice, so a deliberate skip of this exact owned update is overridden. That was the accepted choice. ## Behaviour by case | Case | What happens | |---|---| | Clean shipped tree | A fresh install gets the six exact declarations plus content approval. An owned upgrade renews. | | An extra non-hook file in the plugin folder, fresh home | The plugin installs. Nothing is approved automatically, so Codex's own review applies. | | An extra non-hook file, approved upgrade | The new plugin installs. The existing approval stays as it was, with no renewal. | | A changed shipped hook file or Codex manifest | The Codex plugin isn't installed or upgraded. | ## Proof - Three review rounds, each read by an independent Claude and Codex pair: - a config edit that could make a valid file unloadable; - the install check, which had widened to skip upgrades for any extra file; - the first-install grant on an unmatched tree. - The focused suites pass, 179 of 179 on main, with each round's failing control red first. Daemon typecheck is clean, and required CI passes at this head. - Native upgrade validation on real Codex homes is the release quality lead's, outside this PR. ## Cost +353/-19: - source +189/-14 (the adapter +48/-14, the new module +141); - tests +158/-1; - docs +6/-4. No new setting. It adds whole-tree reads on Codex install checks and one receipt per Codex home.
13 KiB
The Refocus Channel
How orientation content reaches a RUNNING seat. Editing a file is not
delivery: a running seat read its configuration at session start, and nothing
in its context re-reads disk on its own. The refocus channel closes that gap —
it is the mechanism that makes shipped chain files (see
chain-file-convention.md) live doctrine rather than boot-time decoration.
The mechanism
openrig-core ships hooks/scripts/refocus.cjs, registered on both runtimes:
| event | Claude Code | Codex | why |
|---|---|---|---|
| UserPromptSubmit | ✓ | ✓ | deliver due or on-demand context at a model-visible boundary |
| Stop | ✓ | — | catch a long Claude turn crossing the growth threshold |
| PostCompact | ✓ | ✓ | retain exact compaction due-state; deliver on the next actionable prompt |
How the hooks reach a seat. A Claude seat whose resolved plugin selection includes the shipped openrig-core plugin
(for example plugins: [shared:openrig-core], or the same plugin under another name) starts with CLAUDE_CODE_PLUGIN_DIRS
set to the vendored plugin ($OPENRIG_HOME/plugins/openrig-core), so Claude loads hooks/claude.json with no setup step, on a
fresh launch, a restore, a handover successor and a launch with a permission mode. A seat that didn't select the plugin is
launched as before and doesn't load these hooks. The folder a spec's plugin selection projects into the seat's workspace is
not what Claude loads. A list the seat declares itself in CLAUDE_CODE_PLUGIN_DIRS keeps working, with the vendored plugin
added after it.
Claude Code reads this variable from v2.1.280. OpenRig doesn't check the Claude version at launch: an older Claude ignores the
variable, starts normally, and doesn't load these hooks. The behaviour was checked on Claude Code 2.1.296 and not on
earlier versions.
A Claude started from inside a seat (its shell or Bash tool) inherits the seat's environment and so loads the plugin as well.
A seat that loads the plugin is also given OPENRIG_SEAT_PROJECT_DIR, its launch project, and the plugin's seat effects (the
activity and session-identity posts, the compaction hooks, refocus, and the first-launch marker) run only when that Claude's
CLAUDE_PROJECT_DIR names the same folder (compared by real path, so a trailing slash or symlink doesn't matter). A nested
Claude in another project, or in a subfolder of the seat's project, therefore changes nothing for the seat, and a cd inside
the seat's own session keeps working because CLAUDE_PROJECT_DIR is fixed when Claude starts. A nested Claude in the seat's
own project folder is the same project and still acts as the seat, as before this change. Without either variable (a seat
launched before this change, a manual run) the hooks do what they always did. The plugin's activity relay stays quiet next to
the seat's own settings.local.json copy only when that entry is exactly OpenRig's and the script it names is the same
script; an entry from an older install doesn't match, so both report until the seat's next launch refreshes it.
OpenRig automatically installs its Codex plugin for seats that select it. On its first installation, the six exact shipped
hook definitions and bundle content are trusted before launch, after checking the entire installed plugin against this daemon's shipped copy.
On upgrade, OpenRig renews content approval only for a previously approved OpenRig-owned bundle with unchanged approved hook definitions.
It keeps explicit disables, refusals, unknown config layers and other plugins' state. An installation without a proven prior approval stays under native review.
Codex's "continue without trusting" leaves no record: a skip of an otherwise eligible OpenRig-owned update may therefore be superseded on retry.
OpenRig records its approved bundle in CODEX_HOME/.openrig-core-hook-trust.json and leaves Codex's immutable .hook-trust baseline untouched.
Codex 0.164 checks bundle contents when hooks run; a later edit requires native approval again. Older Codex versions enforce their own trust rules.
The Codex
column above then applies only to a Codex seat whose plugin selection includes openrig-core: every Codex hook command
exits 0 before running anything unless OpenRig gave that seat OPENRIG_SEAT_PROJECT_DIR and an explicit
OPENRIG_NODE.
Claude's additional firing signal is transcript growth — not turns (one turn
can burn 200k tokens across fifty tool calls) and not wall-clock (the failure is
sustained work, not elapsed time). Its default threshold is ~2.6MB of JSONL
growth (≈300k tokens); tune with OPENRIG_REFOCUS_BYTES. Codex never uses that
threshold: its exact PostCompact lifecycle hook is the trigger, avoiding a
redundant double-fire around Codex's own compaction cadence. Set
OPENRIG_REFOCUS_NOW=1 for an on-demand refocus and
OPENRIG_REFOCUS_ENABLED=0 to disable the feature. Fresh SessionStart is
always a no-op: the default onboarding pack owns fresh orientation. Both
runtimes retain PostCompact due-state for the next prompt. After a managed Claude
compaction, the acknowledgement-only boundary and other prompts (for up to 10 minutes)
are skipped without consuming that state, so the hook delivers on the restore request
(after 10 minutes, on the next prompt);
that request asks the seat to read the refocusing skill and consume the delivered
traces rather than rerun them, and the read-depth audit accounts for them. Otherwise the hook
is a no-op (it writes a stderr advisory when the transcript shrinks or the
session has no identity), and it degrades to silence on unrelated hook errors.
Time budget. By default each lookup (the trace, the content REF, rig queue whoami, rig config) gets 2 seconds,
as before, and a slow one ends as a reported gap (TRACE GAP, REFOCUS CONTENT REF FAILED, or a role marked
unknown). Set OPENRIG_REFOCUS_BUDGET_MS in a seat's environment to give a slow trace more time, up to 52000 (52 s).
Each lookup's cap then grows in proportion (at 45000 the trace may take 20 seconds; at 52000, about 23), and every lookup
is also cut to what is left of that budget, so the attempt delivers what it has and counts as failed. After a failed
attempt, the issue report gets its own 1.5 seconds once the lookups are done. Both registrations allow refocus.cjs 60
seconds, where they used to stop it at 5; the other OpenRig hooks keep theirs. So a default fire whose lookups together
pass 5 seconds now completes instead of being stopped with nothing delivered, and a hook that hangs outside its lookups
can hold a prompt for up to those 60 seconds.
A configured REF resolution failure degrades loudly in the delivered payload while still completing the hook — a refocus must never break a seat's turn.
Overlapping events (unresolved). Two hook runs for the same seat and occupant are not serialized. If they overlap,
a PostCompact recorded while a prompt is delivering can be lost, and one refocus can be delivered twice. This is
reproduced between hook processes; whether a harness overlaps these events for one session is not established, so it is
an open gap, not one shown to be safe.
Failed trace or configured-content resolution keeps refocus due. After three
consecutive failed attempts for the same seat and occupant, the hook emits one
idempotent issue-stream item tagged issue,refocus, visible through rig stream list --tag refocus. A successful delivery resets that failure episode. An
unconfirmed stream write keeps the same item ID for the next failed attempt; it
does not send a wake, change seat status, or prove the agent read the content.
Because the hook runs at the seat's own turn boundaries, delivery to a RUNNING seat needs no relaunch, no operator action, and no message traffic: the next prompt the seat processes carries the content. The latency bound is "the seat's next turn," which is also the earliest moment new orientation could have been acted on anyway.
The content is configurable — and never project-specific in source
Resolution order:
OPENRIG_REFOCUS_CONTENT_REF— a path-like context-library ref resolved throughrig context get, so refocus receives the same assembled bytes as on-demand pull. This wins when REF and FILE are both set.OPENRIG_REFOCUS_CONTENT_FILE— an operator-authored file, set in the environment the seat's harness runs in (rig and agent specs have noenvkey).$OPENRIG_HOME/refocus/REFOCUS.md— the instance's standing content.- The generic default at
skills/refocusing/references/refocus.md: three project-neutral orientation questions, the ladder itself, and a pointer to the separately shipped onboarding assets.
The hook names a resolved REF in the delivered payload. If REF resolution
fails, the payload starts with REFOCUS CONTENT REF FAILED, the exact ref, and
the resolver's reason, then continues with generic orientation. A broken ref
therefore stays visible without blocking the seat's session boundary. With REF
unset, the existing FILE and generic paths are unchanged.
Mission-, project-, or box-specific refocus text belongs in a context-library entry or one of those FILES on the instance that needs it. It must never be committed into product source — the shipped default carries no path, seat name, mission, or practice that is not generally applicable.
Every delivered refocus pairs its content with the public refocusing skill's
path-only trace. Configure OPENRIG_REFOCUS_TREES=topology|work|both and
OPENRIG_REFOCUS_DEPTH=light|full; optional OPENRIG_REFOCUS_TOPOLOGY_NODE
and OPENRIG_REFOCUS_WORK_NODE select starts that cannot be derived. The
script resolves topology.root and workspace.root through live config and
reports broken links as gaps rather than following pointers.
This automatic hook path is additive to one-shot manual injection through
rig send --context; neither mode substitutes for the other.
The work packet (off by default on every runtime, for now)
Set OPENRIG_REFOCUS_WORK_PACKET=1 in a seat's harness environment so its
refocus carries the work it is actually doing as text rather than as pointers.
=0 (or off, false, no) turns it off. Unset, each runtime's default
applies. A runtime's default becomes on only after a native run shows the
packet reaching the agent and leading to the right next action. No runtime has
been shown that yet, so today both Claude Code and Codex default to off.
With the switch on:
- The hook asks
rig queue whoami --work-candidatesfor labelled evidence. That is each in-progress row's ownmission:tag (a slice tag is optional), or for an untagged row the nearest local handoff ancestor's mission, plus blocked rows as held work and pending rows as possible next work. The strictcurrentWorkderivation is unchanged. - One direct, resolved tag is shown as the work, labelled with its source so
the agent can correct a stale tag. Anything less certain lists every
candidate's one-line intent and asks the agent to name its mission (or to say
it has no current work), giving the
--work-startroute that then shows that mission's intent as text. The project-level chain appears only as labelled orientation. - A tag that does not resolve on this host is named, never replaced. A retargeted handoff or a remote ancestor gives no assumed mission. A mission named only in body text is not evidence.
- The seat's
LEARNED.mdcontributes itsMY JOB HEREandSTANDING DUTIESsections, and notes and each node'sPROGRESS.mdcontribute theirCurrent statesection. When a file has no such heading, the packet says so and shows a bounded excerpt. No heading is required. - A notes or
LEARNED.mdfile older thanOPENRIG_REFOCUS_NOTES_MAX_AGE_DAYS(default 14) is named with its age, the age's source (updated:frontmatter, else mtime) and the threshold: a cue for judgment, not a staleness verdict. - Each field is bounded on its own, with no total cap. A held or next row's
summary and continuation are one line each (about 160 characters), with
rig queue show <id>named when one was shortened. At most 20 held and 10 next rows are shown, with how many there are and therig queue listcommand for the rest. A matched section's body is cut at about 1,500 characters, pointing to--depth full.
trace-to-root.py --packet --work-candidates <path|-> renders the same packet
by hand.
Relation to chain files
The chain files are the durable, altitude-addressed home of orientation
content; the refocus channel is its delivery schedule. A practice added to a
rig's LEARNED.md today reaches running seats through their next refocus
trace, which reads LEARNED.md at each topology level. The default light trace
shows only about the first 800 characters of each LEARNED.md body, so a
practice appended to a longer file reaches seats only with
OPENRIG_REFOCUS_DEPTH=full or through the configured refocus content. A CRAFT.md practice
reaches them only through the configured refocus content (REF or FILE), and future
installs through the shipped defaults
(discovery → curation → ship, per the convention doc).
What a refocus is NOT
A refocus corrects drift; it is not a wake (which restores liveness and must not reframe work) and not a checkpoint (a deliberate phase-boundary pause). Sending the heavy intervention when the light one was due is the most common self-inflicted stall — the hook fires the light one automatically, which is the point.