Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
16f1fd4
Keep the Stop hook reachable when its version cache is replaced
thisisjun786 Sep 21, 2026
6195597
Report the three failures Devin found instead of absorbing them
thisisjun786 Sep 21, 2026
d11f9ec
Follow the interpreter's exit rule, and let a live surface fail the c…
thisisjun786 Sep 21, 2026
5151ef8
Read an exit code's value instead of asking it whether it equals zero
thisisjun786 Sep 21, 2026
9dbefa4
Merge remote-tracking branch 'origin/dev' into codex/crw-178-hook-pat…
thisisjun786 Sep 21, 2026
60d9583
Read the base int, not whatever the subclass says its value is
thisisjun786 Sep 21, 2026
35f83de
Give the migration the fallback it owes, and read both halves back
thisisjun786 Sep 21, 2026
89a169f
Let a busy launcher lock stop the migration instead of answering it
thisisjun786 Sep 21, 2026
a8918e0
Place the launcher before anything is taken away, and share one budge…
thisisjun786 Sep 21, 2026
ad4dc4e
Pin the precondition that keeps an unusable document away from the fa…
thisisjun786 Sep 21, 2026
8abd13c
Document the fallback where each command owns it, and compare bytes
thisisjun786 Sep 21, 2026
00e5594
Say in AGENTS.md that a launcher also lives outside the cache
thisisjun786 Sep 21, 2026
c4ba2c8
Judge the document before placing the fallback, which I said was unne…
thisisjun786 Sep 21, 2026
668bb1e
Count what the records hold, and say which failures were measured
thisisjun786 Sep 21, 2026
0b36dfb
Report how those turns actually ended, and drop the only-case claim
thisisjun786 Sep 21, 2026
351f8cf
Stop asking for a command that cannot exist
thisisjun786 Sep 21, 2026
82e2528
Merge remote-tracking branch 'origin/dev' into codex/crw-178-hook-pat…
thisisjun786 Sep 21, 2026
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
- Read [POLICY.md](POLICY.md) for repository CI, merge and release authority and [CONTRIBUTING.md](CONTRIBUTING.md) for the contribution path. `dev` is the default integration branch; `main` accepts only explicitly authorized release promotions from this repository's `dev`.
- This repository holds the skill instructions under `plugins/crw/skills/` and two imported Python packages under `packages/`. The repository root keeps `skills` as a link to that directory so installations made before the move still resolve; `scripts/install.py`, the checks and the package all read the path the manifest declares. Read the target skill and its linked references before editing. Keep skill names, folder names, UI prompts, and relative links consistent.
- `plugins/crw` is the published plugin package and `.agents/plugins/marketplace.json` is its marketplace entry. Installation copies the plugin root verbatim, including untracked and ignored files, so what may live there is `.codex-plugin/`, `LICENSE` and the components the manifest declares: today `skills/` and `wiring/`. A component nobody declares installs without ever loading, so adding a directory means declaring it. Run `python3 scripts/ci/plugin.py` after touching the package, the manifest or the skills; it derives the permitted roots from the manifest. See [plugin packaging](docs/plugin-packaging.md).
- `plugins/crw/wiring` holds the declared Stop hook and MCP server and the two launchers they start. The launchers stay standard-library-only and never bundle a runtime: the version cache is replaced wholesale on every install. One owner registers each surface; `scripts/runtime_install.py` writes the records they read and refuses the second owner. See [runtime installation](docs/runtime-install.md).
- `plugins/crw/wiring` holds the declared Stop hook and MCP server and the two launchers they start. The launchers stay standard-library-only and never bundle a runtime: the version cache is replaced wholesale on every install. That applies to the launchers themselves, so the Stop declaration opens the packaged copy first and a copy at `<CODEX_HOME>/crw-stop-hook.py` when the cache path is already gone; `scripts/runtime_install.py` and `scripts/plugin_transition.py` both place that copy, launcher before settings. One owner registers each surface; `scripts/runtime_install.py` writes the records they read and refuses the second owner. See [runtime installation](docs/runtime-install.md) and [the cache lifetime](docs/plugin-packaging.md#the-cache-lifetime).
- Maintain the `plugins/crw/skills/crw-*` directories together. Shared workflow rules live in `plugins/crw/skills/crw-plan/references/integrations.md`; operation-specific rules stay in their owning skill.
- `packages/codex-thread-bridge` and `packages/codex-session-relay` keep their own `pyproject.toml`, module names, CLI names, tests and `.gitignore`. Preserve the bridge's upstream MIT notice. The root `pyproject.toml` and `uv.lock` are the uv workspace; run `python3 scripts/ci/packages.py` after changing either package.
- Keep product documents in Linear and private receipts outside this repository. Do not vendor CXC/Paperthin, credentials, session history, or generated runtime state.
Expand Down
115 changes: 111 additions & 4 deletions docs/plugin-packaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,17 +184,124 @@ that layout.

Bump `version` in the manifest when the change should reach installations, and
install the plugin again: a cached version changes only on installation, and a task
already running keeps the package its session started with.

already running may still hold cache-bound references to the version its session started
with — the same directory the next install removes.


## The cache lifetime

Installing a version replaces the cache directory whole. `codex plugin add` removes the
previous version directory, and so does a rollback to an earlier one. Anything a running
session still points into that directory stops resolving at that moment, and the question for
each declared surface is whether its reference outlives the directory it names.

| Reference | Bound to the cache | What a replacement does to it | Owner |
| --- | --- | --- | --- |
| Stop launcher, first candidate | Yes | Falls through to the second candidate | This package |
| Stop launcher, second candidate at `<CODEX_HOME>/crw-stop-hook.py` | No | Nothing | `runtime_install.py hook --owner plugin` |
| Stop settings at `<CODEX_HOME>/crw-completion-hook.json` | No | Nothing | The same command |
| Adapter, relay and bridge executables | No, they sit under the installer pointer | Nothing | `runtime_install.py install` |
| Hook document path in the run identifier | Yes | Held as an identifier and never re-read | The host |
| MCP start `cwd` and `args` | Yes | A server already running survives, because it has already replaced itself with the installed bridge. A restart inside that session is expected to fail: inferred from the `cwd` the host holds, not measured here | The host |
| Skill reads | Yes | A read against the removed directory is expected to fail: inferred from where the host reads skills, not measured here | The host |

Two of those this package can answer for and two it cannot, and the difference is a host rule
rather than a preference. A hook command goes through a shell, so it can be written to resolve
its own program at run time. An MCP `command` must be a bare executable name or a contained
`./` path, and its `cwd` must be a contained `./` path, `${PLUGIN_ROOT}` or `${PLUGIN_DATA}`;
skills are read by the host from the directory the manifest names. Neither can be pointed
outside the version cache by anything this package declares.

### Why the Stop hook is declared as a bootstrap

A hook command is fixed when a turn starts, with the plugin root already resolved into it, and
the whole turn reuses that string — including every Stop re-fire. Replace the package while a
task still holds that command and it names a file that no longer exists; nothing measured shows
a later turn of the same task resolving it afresh. `python3` exits **2** for a
missing script, and 2 is the hook protocol’s blocking code, so the host feeds the error back
to the model and fires Stop again. Measured on the user's host: one removed directory, eleven
repeated Stop prompts in a single turn of one task and eight in a single turn of a second, and
neither turn able to finish while that path was absent. Both completed once a compatibility path
was restored there, at 06:35:13Z and 06:35:59Z. An isolated reproduction of the same manoeuvre
produced thirty-seven in one turn.

So the declaration names two candidates and opens the first one it can read:

1. `${PLUGIN_ROOT}/wiring/crw_stop_hook.py` — the packaged copy. Always the current version, so a
fallback left by an older install can never outrank it.
2. `<CODEX_HOME>/crw-stop-hook.py` — the copy `runtime_install.py` places. Reached only when
the first one is already gone.

If neither can be opened it exits 0 and prints nothing. That is not error suppression: the
launcher’s own contract has always been that a Stop it cannot judge is a Stop it releases, and
the one failure outside that contract was the interpreter failing to open its own argument.
A candidate that opens and then fails while running is a different thing and is reported, as
exit 1, which the host reads as an ordinary failure rather than as a hold. Once a candidate has
been read it owns that Stop: the second one is not tried, because a launcher that raised after
doing half its work has already acted on the turn.

A launcher that exits non-zero deliberately is reported the same way. The launcher's own
contract is to exit 0 on every path, so a copy that breaks it is saying something, and turning
that into a success would hide the one failure it went out of its way to report. It surfaces as
exit 1 rather than as the code the launcher chose, because 2 is the blocking code and no path
through this declaration may produce it.

Which exits count as success follows the interpreter rather than the code's truthiness.
`SystemExit.code` is not restricted to integers: CPython exits 0 only for `None` and an integer
zero, and every other object exits 1 even when it is falsey. An empty string and `0.0` are
therefore failures, and the declaration treats them as failures.

Changing the command text changes the hook’s `trusted_hash`, so an update that changes it needs
one re-trust per installed hook identity. Trust is keyed to the declaration content and not to
the version path, so an update that leaves the command alone keeps its trust.

### The supported range

| Task holding the old package reference | Stop | MCP restart | Skill reads |
| --- | --- | --- | --- |
| Existing task, including an idle interval between turns | The fallback is available if installed; a quiet turn does not establish package-reference reload | Cache-bound reference may be stale; not measured as safe | Cache-bound reference may be stale; not measured as safe |
| Existing task during a turn, removed cache | Fallback invocation measured after removal | Failure is inferred from the retained cache-bound reference, not independently measured here | Failure is inferred from the retained cache-bound reference, not independently measured here |
| Fresh task created after update | Invocation measured from the updated installation | Verify the new task's actual MCP call | Verify the new task's actual skill read |

The fallback protects the Stop launcher. It does not make every old package reference survive
replacement. A task can remain alive across many turns; an idle interval is not a session reload.

### Updating safely

1. Identify tasks that still hold the version being replaced. Finish and replace those tasks
through the supported new-task path, or establish a supported way to keep every referenced
path available continuously. Merely observing no active turn is insufficient.
2. Run `python3 scripts/runtime_install.py hook --adapter completion --owner plugin --apply`
first, so the fallback is current before the directory it backs up can disappear.
3. Run `codex plugin add crw@<marketplace>`.
4. Re-trust the hook once if its command changed.
5. Read back the installed payload and validate each required surface in a fresh task.
Preserve existing recovery evidence and compatibility paths until their readers are gone.

`codex plugin add` removes the prior version cache. This procedure does not promise to retain
that directory automatically or prescribe copying it back after a gap as uninterrupted support.
If path continuity cannot be established before replacement, use the finished-task boundary
in step 1; do not proceed on an assumption that the old cache will remain.
`python3 scripts/plugin_transition.py swap-state` reports what a replacement actually left. It
reads the pointer and the host records, not the tasks holding references, so it cannot establish
that the last reader is gone; that needs evidence of its own.

A host carrying temporary compatibility files — an old cache path kept alive by hand after an
update went wrong — needs a record of its own, kept with the task record outside this
repository. Record the path, who made it, why, and the condition under which it may be removed.
Nothing here enumerates the tasks still holding a reference, so the evidence that the last reader
is gone has to come from the host; record which observation was used. Such a file is a repair, not
a guarantee that the next update will be survivable.

## Update and roll back

The cache keeps one version per plugin, and installing a new version replaces the
previous directory instead of keeping both. Rolling back therefore means making
the source offer the earlier revision again and reinstalling it, not selecting an
older copy from the cache. Bump `version` in the manifest for a release; a new
task picks up the new package when its session starts, and work already running
keeps the version it started with.
task picks up the new package when its session starts, and work already running may still hold
cache-bound references to the version it started with, which is the directory the new install
removes.

Removing the plugin deletes the cached version directory and the plugin entry in
`config.toml`. It leaves the marketplace registration, so removing that is a
Expand Down
41 changes: 29 additions & 12 deletions docs/plugin-transition.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,17 +52,34 @@ declaration is the double fire this exists to prevent, whoever created it.
## The order, and the two windows

preflight
1 retire the settings the registration names
2 remove the registration that runs our adapter
3 write the plugin-owned settings, recording the adapter under the destination pointer
4 retire the user-owned bridge record
5 remove the config.toml table
6 write the plugin-owned bridge record
7 remove the CRW-owned skill links

Part of that order is forced, and the forced part is what prevents doubles: the registration
1 place the fallback Stop launcher this host will need
2 retire the settings the registration names
3 remove the registration that runs our adapter
4 write the plugin-owned settings, recording the adapter under the destination pointer
5 retire the user-owned bridge record
6 remove the config.toml table
7 write the plugin-owned bridge record
8 remove the CRW-owned skill links

Step 1 goes first because it is the only step here that takes nothing away and the only one that
can refuse on a condition outside this command: a file at the stable launcher path that is not
ours, or another run holding its lock. Placed after the retire and the standdown, such a refusal
left the host with its settings archived and its manual registration removed and nothing to put
them back, which is a worse host than the one the command started with. First, it refuses before
anything has been taken.

It is here at all because this command writes the same plugin-owned settings
`runtime_install.py hook --owner plugin` writes. A migration that stopped at the settings would
leave the package's Stop declaration with one candidate again, and the first package replacement
while a task still held the old command would land back in the loop that declaration exists to
avoid. See
[the fallback launcher](runtime-install.md#the-fallback-launcher-and-why-this-command-places-it)
for what it refuses and who removes it, and [the cache lifetime](plugin-packaging.md#the-cache-lifetime)
for why a second candidate is needed at all.

The rest of that order is forced, and the forced part is what prevents doubles: the registration
goes before the new settings, the old settings go before the new ones, and the table goes before the
plugin record. Steps 4 to 6 are held under the same ownership lock `register-mcp` takes, because a
plugin record. Steps 5 to 7 are held under the same ownership lock `register-mcp` takes, because a
user-owned registration landing in the middle would put the record back and leave the host with no
bridge.

Expand All @@ -84,12 +101,12 @@ where it found nothing to archive and where every document it did find already n
Every step decides from the host as it stands at that step, not from the snapshot the run opened
with. The bridge surface is re-read inside the ownership lock, the hook file is re-read and its
registrations re-proved inside the hook lock, the bridge table's span and its proof are re-derived
before a byte is removed, and the skills directory is inventoried again at step 7 and once more
before a byte is removed, and the skills directory is inventoried again at step 8 and once more
after it. That last one is a weaker guarantee than the other two and is named as such: the
directory has no lock, so a link arriving during the removals is reported rather than prevented,
and the run refuses instead of reporting success over it.

Step 7 removes nothing until every link it would remove has been proved, and then proves each one
Step 8 removes nothing until every link it would remove has been proved, and then proves each one
again in the moment before it is unlinked. Neither pass is a lock. The first stops a refusal from
leaving half a manual installation behind; the second stops a link replaced during the removals
from being deleted as though it were still ours, and narrows that window to the gap between a
Expand Down
Loading
Loading