Files
t0sakiandTRAE CLI aa3ee2f431 refactor(plugins): resolve every harness's knobs from one declaration, and generate the packaged copies at pack time (#4773)
* fix(codex): stop dropping turns on an outage, and honour bypass patterns

Two capabilities every other JS harness has were wired in the shared library
but never reached codex.

Offline queue. `catchUpTurns` called `sendSessionMessages` without
`enqueueOnRetryable`, so a 5xx or a connection failure returned a count and the
turns were gone: codex hooks are short-lived subprocesses with no retry of
their own, and the `capturedTurnCount` cursor only compensates while the
process survives long enough for another Stop. The queue module was vendored
into `scripts/shared/` all along with no caller, and nothing ever replayed it.
The send now enqueues, and SessionStart drains the queue after its health check
— the one codex hook that runs against a known-healthy server.

Bypass. `isBypassed` had no codex caller, so `bypassSessionPatterns` did
nothing there: a directory a user had excluded still had its turns captured and
still got memories injected, and the `bypass.session_patterns` key a repository
commits in `.openviking/config.json` was silently inert for every codex user in
that repository. All five hooks now consult the shared matcher, and the loader
reads the same keys and the same `OPENVIKING_BYPASS_SESSION[_PATTERNS]` env
vars as claude-code.

SessionStart is deliberately only half-suppressed under bypass: it skips peer
registration and injection, but still replays the queue and still sweeps. Both
finish work recorded by sessions that were not bypassed, and this hook is the
only place codex runs either, so bailing out would strand that data for as long
as the user kept working in a bypassed repository.

Claude-Session: https://claude.ai/code/session_01GJouP1ghK4gwXw7sDjuZX6

* fix(pi): use the shared bypass matcher, and keep the pre-git peer reachable

`config.ts` kept only `.peerId` out of `resolveEffectivePeerId()` and dropped
`legacyPeerId`, so under `recallPeerScope: "actor"` — where the effective peer
is the only one asked — every memory written before the git-derived peer
replaced the path-derived one became unreachable. The shared recall builder has
read `options.legacyPeerId` for the dual read all along; pi just never passed
it.

Bypass was a hand-written `matchBypass()` under this extension's own
`bypassPatterns` key: it understood a leading or trailing `*` and nothing else,
so `**/scratch/**` did not work here while it did everywhere else. It now calls
the shared `isBypassed`, reads `bypassSessionPatterns` like every other
harness, and honours `OPENVIKING_BYPASS_SESSION[_PATTERNS]`. `bypassPatterns`
still reads.

That does change one behaviour for existing setups: a bare path used to match
its subdirectories as a prefix, and a glob does not. The README says to write
`"/tmp/scratch**"` where `"/tmp/scratch"` used to be enough.

Claude-Session: https://claude.ai/code/session_01GJouP1ghK4gwXw7sDjuZX6

* chore(plugins): remove the deprecated standalone TRAE CLI integration

`examples/trae-cli-memory-hooks` was added on 2026-08-17 (#4026) for TraeCode
CLI 1.0 and deprecated the next day by #4079, which routed `--harness trae-cli`
to the codex plugin alias instead. Since then `install_trae_cli` has not been in
the installer's dispatch block — `normalize_trae_cli_harness` rewrites the
harness to `codex` before it could run — so the directory, its 588 lines and its
CI test have been dead weight that the marketplace archive still shipped to
every user.

The uninstall path stays: `remove_legacy_trae_cli_integration` and
`agent_remove_trae_cli_configs` delete what an old install left on disk and
never read this directory, which is what the retention test in
`release-marketplace.test.mjs` claimed to protect. `install-agent-hooks.test.mjs`
covers that path and still passes.

Also drops the write half that only `install_trae_cli` called.

Claude-Session: https://claude.ai/code/session_01GJouP1ghK4gwXw7sDjuZX6

* fix(plugins): make a changed plugin prove it shipped

Claude Code resolves an installed plugin by the `version` string in its
manifest, not by the commit the marketplace ref points at. That string has read
0.4.5 since #4389 on 2026-08-27, with seven commits to the plugin and the shared
library behind it — so nobody running the marketplace build has received any of
them. Codex's manifest has been frozen at 0.8.1 for ten. Every vendored-copy
diff those commits paid for went to a payload no user received.

Both versions move, and a new PR check fails when files under a
marketplace-distributed plugin change without its version string moving with
them. The generated `shared/` copies live inside each plugin directory, so a
shared-library change reaches the check through them.

Also removes `claude-code-memory-plugin/package-lock.json`: 1174 lines locking
95 packages for a private manifest that declares no dependencies, still pinned
at 0.4.4.

Claude-Session: https://claude.ai/code/session_01GJouP1ghK4gwXw7sDjuZX6

* docs(agent-integrations): record what codex, pi and trae-cli now do

The capability matrices said codex has no on-disk queue and counted four hooks
where hooks.json declares five; both are now wrong. pi's bypass is no longer a
prefix match under its own key. And the removed TRAE CLI 1.0 integration is
described by what remains of it: the installer's uninstall path.

Claude-Session: https://claude.ai/code/session_01GJouP1ghK4gwXw7sDjuZX6

* refactor(plugins): derive each plugin's vendored set from what it imports

sync.mjs kept hand-written capability groups, and the groups got spread into
each other: the file's own header states the rule it could not enforce ("what a
plugin ships must equal what it imports"). Both directions had failed in
practice — a module a plugin imported but no list named became
ERR_MODULE_NOT_FOUND on the first hook of a fresh install, and modules nobody
imported sat vendored in plugin trees, landing in every review diff forever.

The lists are gone. Each target now says only where its code lives and where
its copies go; the file set is the transitive closure of the imports that code
actually resolves into the vendored directory — including re-export shims like
claude-code's scripts/lib/, which are the sole importer of several modules. The
sync reports a copy nothing imports and a lib module no plugin reaches, and
sync.test.mjs asserts what is on disk equals that closure in both directions.
The run is byte-identical for everything still shipped, and it named the two
copies nobody had noticed: claude-code's capture-utils (515 lines, its
auto-capture hand-rolls the same filter) and codex's uri-guard (78 lines, codex
has no PreToolUse hook).

ZCode stops vendoring altogether. Its only install path is
`assemble_agent_integration`, the same one cursor and trae use, which already
placed the canonical runtime beside it — it just imported its own committed
copy instead. Pointing its nine imports at `../../memory-plugin-shared/lib/`,
the path that resolves both in the repository and under
`$OV_HOME/agent-integrations/`, drops 4294 generated lines and leaves cursor,
trae and zcode reading one runtime. The installer's assembly list grows by the
three modules zcode needs, derived by the same closure code so it cannot drift
either, and the end-to-end archive install test covers it.

Claude-Session: https://claude.ai/code/session_01GJouP1ghK4gwXw7sDjuZX6

* fix(plugins): run cursor and trae capture through the shared filter

zcode got this in #4594; cursor and trae never did. Both sent whatever their
transcript parser produced straight to the extractor: `/compact`, a bare "ok",
a punctuation-only turn and a `[openviking-memory]` status line all became
memories, and a turn past `captureMaxLength` went out uncapped. Every other
harness has run `shouldCaptureText` on the way in for months.

The decision now lives beside the runtime the thin harnesses compose, as
`filterCaptureTurns`, so a harness gets it by calling rather than by carrying
its own copy — and the installer already assembles `capture-utils.mjs` for
these three since they share one runtime.

Both hooks keep hashing the raw turn rather than the filtered text, so raising
captureMaxLength never resends a turn the server already holds truncated, and a
dropped turn is recorded as handled so a re-read of the same transcript does not
re-evaluate it every run.

The TRAE hook test used `last_assistant_message: "done"` — an acknowledgement
the filter drops, and correctly so. Its fixture now carries a turn worth
remembering.

Claude-Session: https://claude.ai/code/session_01GJouP1ghK4gwXw7sDjuZX6

* fix(plugins): bump every plugin the shared change reached, and pair the manifests

The version check added earlier in this branch caught what a human review would
not have: `filterCaptureTurns` landed in the shared library, its generated copy
changed inside opencode and dsh, and neither manifest moved — so both hosts
would have kept installing the build without it. cursor, trae and pi move for
the same reason.

It also caught a mismatch this branch introduced: cursor carries the version in
both `.cursor-plugin/plugin.json` and `openviking.integration.json`, the host
installs by the first and the installer decides "nothing changed" by the second,
and bumping only one makes a plugin report upgraded while behaving as it did.
zcode had been sitting at 0.1.1 against 0.1.2 on main for the same reason. The
check now compares the pair, so neither can drift again.

Claude-Session: https://claude.ai/code/session_01GJouP1ghK4gwXw7sDjuZX6

* refactor(plugins): resolve every harness's knob from one declaration

A setting only stayed uniform across the harnesses when a shared module read
it on the hot path. `recallLimit`, `scoreThreshold` and `captureMaxLength`
agree everywhere because `recall-core` and `capture-utils` read them; the
switches, which nothing shared read, drifted into four spellings — a boolean
`autoRecall`, an `autoRecall` object in opencode, `syncTurns` in dsh and pi,
and no recall switch at all in the thin hook runtime. Three more inventories
had to be kept in step by hand: the doctor's known-knob set, the workspace
file's dotted-key map, and the implicit list inside `loadAgentHookConfig`.

`lib/config-schema.mjs` is now the one declaration, and the rest are
projections of it. `resolveSettings()` resolves any harness through the layers
the shared README has documented since the `plugin` section landed — env, the
workspace file and registry, `ovcli.conf` `plugin.<harness>`, `ovcli.conf`
`plugin`, ov.conf's harness section, defaults — which only Claude Code and
Codex implemented. cursor, trae, trae-cn and zcode read the environment and
nothing else before this, so a `plugin` entry named after them was inert and
`ov config switch` moved their credentials while leaving their behaviour
behind.

The two per-plugin config files are gone with it. pi's `config.json` shipped
with the extension holding exactly the code defaults, so nothing could tell an
operator's choice from the factory setting; opencode searched four paths for
`openviking-config.json`. Both harnesses read `ovcli.conf` now, as the others
do. The `.d.mts` stubs move to lib/ beside the modules they describe — pi and
openclaw each carried a different, both incomplete, declaration of
`recall-core` — and sync ships them to the targets written in TypeScript.

The switches move into the capability modules that own them:
`isRecallEnabled` and `isCaptureEnabled` accept every spelling and treat any
of them saying off as off, which is the one mechanism in this repository that
has actually held a name still.

* build(plugins): generate the packaged plugins' shared copies at pack time

`sync.mjs` had zero references under `.github/`: the generator ran on whoever
last remembered, and CI only checked the result byte for byte. So every change
to a 6,000-line library arrived as a 20,000-line diff, and the copies were
committed for plugins that never needed them committed.

The split is delivery, not taste. A host that installs Claude Code, Codex or
the Agent Plugins package points at a directory in this repository and can only
load what git holds, so those copies stay committed — and a push to main
regenerates them, which is what stops one going stale behind a merged pull
request. opencode, dsh and openclaw publish as npm packages and pi is tarred by
the installer, so those four build their copies on the way out: a `prepack`
script for the packages, the marketplace staging script for the archive the
installer reads, and `sync.mjs` in a source checkout for a direct `install.sh`
run. `npm pack` was verified to regenerate a deleted directory and ship the
same file set.

Two things had been holding those directories in place. Their release triggers
matched only `examples/<plugin>/**`, so a shared fix reached npm through the
vendored copy changing — the triggers name the library now. The version gate
found a changed plugin the same way, so it treats a change under
`memory-plugin-shared/lib` as a change to every plugin, and gains cursor and
trae, which are distributed the same way and were never gated. pi's own
`recall-ledger` moves to `lib/`, where the extension's other own modules live,
so the generated directory holds nothing but generated files.

* test(plugins): pin the layer order for the harnesses that had no config test

cursor, trae, trae-cn and zcode compose one shared loader and none of them owns
a config test, so nothing would have caught the `plugin` section going inert
again. This asserts the whole stack through that loader — the shared block, a
per-harness override under either spelling, the workspace file over ovcli.conf,
and the environment over all of it — and pins that a default never reports as
configured, since several fields reach the server only when the user asked for
them.

* docs(plugins): point opencode and pi at ovcli.conf, and say when copies are made

Both plugins documented a configuration file that no longer exists, down to the
four paths opencode searched for it and the nested blocks neither loader reads
any more. Their knobs live in `ovcli.conf`'s `plugin` section now, so the
examples are ovcli.conf examples, the flattened names are the ones the schema
declares, and each document says the resolution order and links to the one file
that declares every knob.

Two claims that had gone the other way: opencode and pi read the workspace
`.openviking/config.json` now, so a `peer.id` written there does apply, and the
shared README says which plugins keep their generated copies in git and which
build them at pack time, because a fresh checkout has to run the generator once
before their tests will resolve an import.

* fix(opencode): name the file the 401 hint should send the user to

* fix(plugins): let the canonical knob name win over its own alias

A file that carries both spellings — what a rename leaves behind — resolved to
whichever the layer loop reached last, which was the older name.

* fix(plugins): write each generated copy through a rename

The marketplace staging script runs the generator now, so it can rewrite a
vendored module while a test is byte-comparing it. A partial file read that way
fails as a drifted copy, which is a confusing way to say nothing drifted.

* fix(dsh,pi): honour the recall switch these two never read

Both gained a capture switch when `syncTurns` became an alias, but their recall
paths still retrieved on every prompt: nothing in either called the switch, so
`autoRecall: false` — settable from ovcli.conf, a workspace file or the
environment — was accepted everywhere and obeyed nowhere.

* docs(agent-integrations): say which harnesses read which configuration layer

The capability reference had cursor, trae, trae-cn and zcode down as
environment-variable-only, opencode reading a four-level `openviking-config.json`
search and pi its own `config.json`, and the workspace files and `plugin`
section scoped to Claude Code and Codex. All nine resolve through the same
loader now, so the tables, the quick-scope list and the profile cards say so,
and the module table gains the schema the layers project from.

The distribution paragraph was counting modules against a `HOOK_SHARED_FILES`
list that no longer exists and calling zcode the only harness that vendors the
hook runtime, which stopped being true when its copies were dropped. The counts
are the real ones, and the paragraph now says when each target's copies are
generated, since that is what decides whether git holds them.

* build(plugins): keep node_modules out of the marketplace archive

Three of the staged plugin trees carry installed development dependencies,
so the copy shipped 134MB nobody unpacks and spent most of the release test's
54 seconds on them. Copying through tar leaves them behind while still
carrying the generated shared copies, which a tracked-file listing would miss.

* build(plugins): read the installer's shared-module list from the sync

The installer carried a second, hand-written copy of the closure sync.mjs
already computes, and only a regex over the bash text held the two equal. The
sync writes that closure to lib/MANIFEST now and the installer copies what it
names, so the set has one source and the test compares two lists instead of
scraping a script. The installer cannot just run the sync: a marketplace
archive is flat, and the generator resolves an empty list there.

* build(plugins): derive what the marketplace archive must contain

The stage script hand-listed 56 required paths beside three generators that
already knew them, and it had drifted from all three: eleven of the twenty-two
shared modules the installer assembles, four of the thirty-eight copies the sync
generates, and neither marketplace manifest. The list is now computed from each
plugin's own manifests, the imports those entrypoints reach, and the sync's
target list, leaving the script with only the directories it ships.

* fix(plugins): resolve credentials from the calling harness's ov.conf section

The shared resolver read `ov.conf`'s `codex` block for every caller, so the api
key, account, user and peer of opencode, pi, dsh, cursor, trae, trae-cn and
zcode all came out of a block named after another harness, while the block
named after them did nothing. A user who wrote `opencode.apiKey` saw no effect
and a stale `codex` block quietly authenticated everything else.

`resolveOpenVikingCredentials` now takes the harness and reads the section named
after it, under either spelling. The layer keeps its place — after ovcli.conf,
ahead of `server.root_api_key` — and the ovcli-pinned mode still skips ov.conf
entirely, so codex, the caller that keeps the default, is unchanged.

For users this means the `<harness>` block finally configures that harness, and
anyone who was relying on the `codex` block to supply credentials to a different
harness has to copy them into their own block. dsh merges its section in
explicitly because the cordis patch occupies its legacy layer, and codex now
honours an api key set through `ovcli.conf`'s `plugin` section, which the
credential chain has never seen. cursor, trae, trae-cn and zcode honour it too:
the hook runtime spread the resolved credentials over the settings, so an empty
key from the chain used to erase the one `plugin.<harness>.apiKey` had supplied.
The doctor warns about all of these blocks now, since the server refuses to
start on any of them.

* fix(plugins): let the credential chain supply the peer on claude-code and dsh

Claude Code had no peer logic at all: it took only the user agent from the
shared credential module, so `actor_peer_id` in ovcli.conf and `peerId` in
ov.conf's `claude_code` block were read by every other harness and ignored
here. `ov config switch` moved the peer and Claude Code kept writing under the
one derived from the workspace. It now takes the peer from the same chain, and
a `plugin` entry or a workspace file still names the more specific answer for
that directory, which is the rule the thin harnesses already follow.

dsh had the opposite fault: it assigned the credential peer over whatever the
layers resolved, and that chain is the empty string whenever no actor peer is
named, so `plugin.dsh.peerId` was resolved and then thrown away. The credential
peer is a fallback now instead of an overwrite.

* fix(plugins): resolve the peer through one chain on every harness

Six loaders each wrote their own peer order, and two of them disagreed with the
rest: codex let a `peerId` written for the harness win, while opencode and pi
let `ovcli.conf`'s account-wide `actor_peer_id` win. One ovcli.conf therefore
produced two different peers depending on which host read it, and `ov config
switch` moved the peer for some harnesses and not others.

`resolvePluginPeerId()` in the shared library is now that order, once: a peer
the host named, then `OPENVIKING_PEER_ID` (suppressed when the credentials are
pinned to ovcli.conf, where the environment is meant not to apply), then the
workspace file, the registry and `ovcli.conf`'s `plugin` section, then
`ovcli.conf`'s `actor_peer_id`, and last `ov.conf`'s harness block, which keeps
the place it has always had. Nothing named means nothing explicit, which is
what makes `peer.source` derive one.

For opencode and pi users this is a precedence change: a `peerId` under
`plugin` or `plugin.<harness>` in ovcli.conf, or a `peer.id` in a workspace
file, now outranks that file's `actor_peer_id` instead of being outranked by
it. Anyone who wrote both and wanted the actor peer has to drop the plugin one.
It is one for claude-code too: `ov.conf`'s `claude_code.peerId` was the only
peer that harness read, and it now sits under `actor_peer_id` like every other
harness's block. Otherwise nothing moves: `ov.conf`'s harness block is ranked
here rather than left to the credential chain, which drops it whenever the
credentials are pinned to ovcli.conf, so that block names the peer in the same
place whether they are pinned or not.

* fix(plugins): shape the cursor and trae MCP proxies like every other one

Both proxies hand-built the config object the shared `buildMcpProxyConfig`
produces everywhere else, and had done so since before that helper existed, so
they never picked up the fixes it carries. The visible one is the actor peer:
they put whatever peer the config layers resolved straight onto
`X-OpenViking-Actor-Peer`, while the default `recallPeerScope` of `all` means
broad recall and every other proxy deliberately sends no such header. Cursor
and TRAE therefore recalled at a narrower scope than the rest on identical
configuration. Two smaller ones come with it: the watch list now includes the
default credential paths, so a proxy started before the first `ov login` picks
up the file it creates instead of never reloading, and the request timeout is
clamped rather than taken raw.

The guard is the file list in `mcp-proxy-config.test.mjs`, which named five
proxies by hand and so never covered the two that were wrong. It is a directory
scan now, pinned to a count so a rename cannot quietly empty it, and it asserts
that every proxy in the tree reaches the shared builder.

* fix(plugins): put one set of headers on the wire from every harness

Seven request builders each wrote their own header block and three of them
disagreed. Codex sent the api key twice, as `Authorization: Bearer` and again
as `X-API-Key`, which the open-source server prefers when both arrive — so a
gateway rewriting one of them changed which credential authenticated. Claude
Code read `cfg.accountId` where the other six read `cfg.account`. And only
Codex asked whether the server was in trusted mode before naming the operator:
everywhere else `X-OpenViking-Account` / `X-OpenViking-User` went out whenever
they resolved, including to `api_key` servers that read both out of the key,
ignore the headers, and leave the identity visible to every proxy on the path.
Both doctors have been warning users about that as if it were already fixed.

On the wire, from this commit:

- `X-API-Key` stops. Codex's `ov-session.mjs`, `auto-recall.mjs` and
  `session-start-commit.mjs` were the only senders; `Authorization: Bearer` is
  unchanged and remains the only credential header. A gateway that needs
  `X-API-Key` has to add it itself. openclaw keeps sending it and is untouched.
- `X-OpenViking-Account` and `X-OpenViking-User` are sent only under
  `sendIdentityHeaders`, which is `authMode === "trusted"`. Codex already
  behaved this way; claude-code, opencode, dsh, pi and the cursor / trae /
  trae-cn / zcode hook runtime now do too, and so do the two senders outside
  the hook stacks: every harness's stdio MCP proxy, which takes the switch
  through `buildMcpProxyConfig` the way it already takes the actor peer, and
  Claude Code's status-line server probe. Auth mode resolves through
  `resolveAuthMode()` in the shared credentials module: `plugin.<harness>`
  `authMode`, then `plugin.authMode`, then `ov.conf` `<harness>.authMode` on
  the three harnesses whose loader reads that section as a settings layer
  (claude-code, codex and dsh), then `ov.conf` `server.auth_mode`, then trusted
  whenever an account or a user resolved at all — the chain Codex already had,
  now shared. An operator whose server is in `api_key` mode and who has an
  account in ovcli.conf stops sending it; nothing else changes.
- `X-OpenViking-Actor-Peer`, `User-Agent` and `Content-Type` are unchanged.

Claude Code's config now also exposes the resolved identity as `account` /
`user` beside the existing `accountId` / `userId`, and its doctor and its
status-line probe use the headers the plugin would really send instead of
always sending both. The Agent Plugins package resolves an auth mode of its
own, so its proxy still names the operator to a trusted server.

The free half of the same theme: `session-start-commit.mjs` carried a private
`requestJSON` / `commitOvSession` pair that duplicated `ov-session.mjs`'s down
to the envelope. It imports them now, which is one header block fewer to keep
in step.

`wire-headers.test.mjs` drives six of the seven stacks through a stubbed fetch
and asserts one header map for all of them, in trusted mode and in api_key
mode; `auto-recall.test.mjs` covers the seventh, which only exists as a
subprocess, against a real server socket; and `mcp-proxy-config.test.mjs` puts
the proxy's two identity headers on the same switch.

* test(openclaw): run only the live test copies under tests/ut

Six files sat directly under tests/ as copies of suites that had since moved
on, and vitest's default include meant two of them still ran on every
`npm test` against an older shape of the code. What each one is:

- `tests/context-engine-assemble.test.ts` is an earlier cut of
  `tests/ut/context-engine-assemble.test.ts` — same imports one directory
  shallower, 360 lines against the live copy's 838. Five of its six cases are
  in the live copy verbatim; the sixth, "records senderId from runtimeContext
  in assemble diagnostics", is covered by the `senderIdFound` assertions in
  `tests/ut/context-engine-modules.test.ts` and
  `tests/ut/context-engine-afterTurn.test.ts`.
- `tests/context-bloat-730.test.ts` imports `memory-ranking.js`, `config.js`
  and `auto-recall.js`, and every symbol it exercises already has a home under
  `tests/ut`: `postProcessMemories` and `pickMemoriesForInjection` in
  `memory-ranking.test.ts`, `buildMemoryLinesWithBudget` and
  `estimateTokenCount` in `build-memory-lines.test.ts`,
  `recallScoreThreshold` and `recallMaxInjectedChars` in `config.test.ts` and
  `query-config.test.ts`.
- `tests/test-memory-chain.py` and `tests/test-tool-capture.py` are earlier
  drafts of `tests/e2e/test-memory-chain.py` and
  `tests/e2e/test-tool-capture.py`, which drive the same phases against a live
  gateway at greater length.
- `tests/demo-memory-ajie.py` and `tests/demo-memory-xiaomei.py` are one
  manual gateway demo under two personas, differing in the user name and the
  port, and named by no doc, script or manifest.

Nothing referenced them: the only prose pointer to an assemble test names the
`tests/ut` copy, and `.clawhubignore` plus `tsconfig.build.json` already kept
the whole directory out of the published package.

So that a file dropped beside the suite is not silently picked up again,
`vitest.config.ts` now pins `test.include` to `tests/ut/**/*.test.ts`. The
CI job's `--exclude` filters still apply on top of it — 42 files listed, 41
after the architecture-boundaries exclude. The one file left in `__tests__`
falls outside the pin until it moves into `tests/ut` with the rest.

`architecture-boundaries.test.ts` kept `tests/context-bloat-730.test.ts` in
the list of files it reads for index-facade imports, which would have thrown
on the missing path, so that entry goes too.

* test(openclaw): move the shouldBypassSession cases under tests/ut

`vitest.config.ts` now collects only `tests/ut`, and the six cases in
`__tests__/bypass-session-patterns.test.ts` were the sole behaviour coverage of
`compileSessionPatterns`, `matchesSessionPattern`, `shouldBypassSession` and the
`ingestReplyAssistIgnoreSessionPatterns` fallback in the config schema, so they
had stopped running. They move beside the other `text-utils.ts` tests, one
directory deeper, and the emptied `__tests__` directory goes with the two build
files that still had to name it.

* docs(openclaw): drop five reports that nothing points at any more

Four are dated one-off write-ups of manual runs, and three of them name the
repository and branch they were made against, neither of which is this one:

- `docs/openviking-install-real-scenario-verification-report.md` (2026-06-05)
  walks one machine's install of plugin `2026.6.2` from
  `iaasng/arkclaw-openviking-plugin`, against a hosted endpoint and a specific
  OpenClaw build.
- `docs/openviking-dynamic-query-config-test-report.md` (2026-06-04) is the
  acceptance run for the runtime query-config work in that same repository;
  `docs/openviking-runtime-query-config.md` is the reference that documents the
  feature and stays.
- `docs/workmemory-v2-test-report.md` (2026-05-02) is a `locomo10` benchmark of
  Working Memory v2, beside the design note that still describes it.
- `openclaw-multi-tenant-test-report.md` records a session driven through two
  ad-hoc remote ports and a live Feishu bot, reproducible by nobody.

The fifth, `docs/oc-resource-skill-import-design.md`, is an RFC whose own
opening paragraph says the implementation went the other way: no unified
`ov_import(kind=...)` tool and no `/ov-import` command, which is the shape the
rest of the document is about.

One line pointed at any of them — the Working Memory entry in the guide's
reference list, whose neighbour is the design note and stays. Nothing shipped
moves: `docs/` is outside the package's `files` list, the top-level report was
never in it, and the ClawHub release workflow names no document.

* test(openclaw): drop the unreferenced tool-result compression bench

tests/toolresult_compression_tests/ was manual measurement tooling: run-sccs-bench.mjs
spawns an external openclaw binary turn by turn and reports token usage, so it asserts
nothing and needs three repositories cloned to hardcoded /root paths plus a live server
before it can run at all. Nothing outside the directory named it — the two invocation
lines the plan cites were in its own README.

The behaviour it exercised is covered by tests/ut: tool-round-trip.test.ts asserts that
an externalized tool result survives conversion with its preview text, its
viking://session/<id>/tool-results/<id> ref and original_chars intact, and tools.test.ts
asserts the restore half through openviking_tool_result_read / _search / _list.

* test(openclaw): assert the prepack script that regenerates shared at pack time

The packaged plugin's prepack gained a `sync.mjs` prefix when the generated
shared copies stopped being committed, but the manifest contract still pinned
prepack to `npm run build` alone. The openclaw-tests job runs that file, so the
suite has been red on a stale expectation rather than on a real contract break.

* chore(claude-code): drop the two unwired debug scripts

Neither debug-recall.mjs nor debug-capture.mjs is reachable from hooks.json,
install.sh, or the sync target, so they drifted away from the hooks they were
meant to mirror; debug-capture also rewrites the live session's capture cursor
through an API flow the plugin no longer uses. The doctor skill, both READMEs
and the capability reference lose their pointers in the same change.

* docs(pi): rewrite DESIGN.md around the modules that exist

The spec was written in the future tense of a plan only partly executed: it
specified an `index_builder.ts` that was never built (the profile block from
shared/profile-inject.mjs folded into systemPrompt took its role), gave no
section at all to config.ts or takeover.ts, carried per-file line estimates
that went stale on every commit, and repeated its Knowledge Index sample
twice. Its Implementation Order and Testing Strategy described work the code
and tests/ have both already done.

It is now one section per module actually shipped, each written from that
module's current code, plus the event walkthrough, the design ancestry and
the two rationales the code cannot state for itself. TAKEOVER.md's model,
runtime flow, compaction and failure modes move into the takeover section so
the one harness capability that is unique to pi is documented beside the
module that implements it; its configuration table was already in README.md.

* docs(codex): fold VERIFICATION.md into a README Testing section

The 320-line manual SOP was pinned to plugin v0.6.0 and every step it
walked is now asserted by the node --test suite that CI runs. Only its
two live legs -- extraction landing in the user namespace and the
interactive Codex smoke test -- need a real server, so they stay as a
short appendix next to the command that actually runs the suite.

* chore(opencode): drop three unreferenced helpers from lib/utils.mjs

makeMultipartRequest was a near-copy of makeRequest that no caller ever
reached, and the wired URI check is lib/viking-uri-guard.mjs, not
validateVikingUri; ensureRemoteUrl had no caller either.

* chore(pi): drop six unreachable OVClient methods

createSession, addMessageParts, addMessagePayload and deleteSession have no
caller: the OV session id is derived locally and the server-side session is
created implicitly by the batch messages endpoint, messages go out as one
addMessage or through that batch endpoint, and nothing in the extension
deletes a session. resolveScopeSpace and resolveTargetUri were a URI-space
rewriter that no tool path ever reached, and they held the only readers of
RESERVED_USER, RESERVED_AGENT and the resolvedSpaces cache.

The sync-barrier stub kept an addMessagePayload key for the same reason; sync.ts
queues and replays through fetchJSON, so the key asserted nothing.

* chore(dsh): drop nine unreachable OpenVikingClient methods

health, ensureSession, getSessionArchive, find, read, list, stat, forget and
addResource had no caller: the runtime only ever uses fetchJSON, healthResult,
ensureSessionResult, getSession, addMessage and commitSession, and the tool
surface the read and write helpers look like they serve is bridged through the
stdio MCP proxy, so they could never be reached.

Only tests called them. The peer-override and live-recall cases move onto
ensureSessionResult and healthResult, which take the same arguments, and the
409/ALREADY_EXISTS tolerance moves to a runtime test: initializeState carries
its own copy of that check, so the behaviour stays pinned where it now lives.

* chore(codex): drop the ov-credentials pass-through

scripts/ov-credentials.mjs re-exported two names from shared/credentials.mjs
and repeated that module's CLI verb-for-verb. Nothing ran it as a command, so
the file only added a second path to the same resolver, one that every
credential fix since the shared library landed had to remember to keep in step.

Its three importers now reach the shared module directly. The test file keeps
its name because .github/workflows/pr.yml lists it by path, and it asserts the
shared resolver either way.

* chore(install): drop the pre-stdio layout migrations

The rc-wrapper stripper and the two legacy-marketplace migrations cleaned up
after installs that predate the stdio MCP proxy and the unified `openviking`
marketplace name, both of which landed in July 2026. Every install since then
writes the current layout, so the three helpers ran on every upgrade only to
find nothing, while keeping four constants and a TOML rewriter alive for a
shape the installer can no longer produce.

The installer-side cleanup is what goes; both doctors still detect a leftover
`openviking-plugins-local` install and tell the user how to remove it by hand.
The Codex README's step list loses the line that promised the migration, and
the installer test that asserted a Cursor-only run left the rc blocks alone
goes with the code it was pinning.

* chore(claude-code): drop the dead archive-abstract loop

SessionStart rendered up to five `<archive-abstract>` entries from the session
context's `pre_archive_abstracts`, but the server hard-codes that field to an
empty array: openviking/session/session.py returns `[]` from both context
builders and says so in a comment ("保留字段返回空数组,保持 API 向下兼容").
The loop is dead twice over, since the field's entries are objects while the
filter kept only strings.

What resumed and compacted sessions actually receive is unchanged: the
`<session-archive>` block still carries `latest_archive_overview`, which is the
one field the endpoint fills. The capability reference loses the "≤5
pre_archive_abstracts" claim it made for claude-code alone.

* refactor(plugins): put the four JS hook stacks on one HTTP module

Claude Code, Codex, dsh and the thin-harness runtime each carried their own
AbortController, header block and envelope parser for the same server, which is
how the wire drifted apart in the first place. lib/ov-http.mjs owns that shape
now — Bearer only, identity headers only when the config says the server is
trusted with the operator's name — so the next change to it lands once instead
of four times.

Also fixes the usage line in credentials.mjs, which still named the wrapper
that was deleted out from under it.

* refactor(plugins): move the Claude Code and Codex session stacks onto the runtime

Claude Code's scripts/lib/ov-session.mjs kept its own copy of the session
helpers the shared hook runtime already had — a fetch builder, the pending-queue
enqueue, add-message, commit, and the two session getters — so a fix to how a
failed write is parked had to be made twice, and the two copies had already
drifted: only Claude Code's set pendingQueued / pendingEnqueueFailed and warned
about a non-retryable failure.

The runtime carries that contract now. makeAgentFetchJSON takes the timeout and
the actor-peer getter its callers vary (Codex knows its peer only after loading
state under the session lock, and Claude Code names one per call), and resolves
the workspace peer on demand so a caller that never reads it pays nothing.
addAgentMessage and commitAgentSession report what became of a failed write the
way the capture hooks log it, and commitAgentSession takes the retention payload
Claude Code's threshold commit sends. Both harnesses' modules are wrappers over
it, keeping Codex's result-or-null fetchJSON beside the envelope fetchJSONRes.

That report now reaches further than Claude Code: the three thin harnesses on
this runtime — cursor, trae/trae-cn and zcode — name a write no retry can fix
on stderr too.

* refactor(plugins): put the last header blocks on the shared builder

opencode and pi still carried a whole fetch stack of their own, and the doctor,
the MCP proxy, the Claude Code statusline probe and the Codex recall hook still
spelled the header block out by hand. All six go through lib/ov-http.mjs now:
opencode's fetchJSON and makeRequest are a shell over it that keeps its throwing
contract and its two hints, pi's client keeps its typed methods over the same
envelope, and the four header-only callers ask buildOvHeaders.

A test asserts what that buys: X-OpenViking-Actor-Peer appears in the plugin
family's non-test sources only in lib/ov-http.mjs, so the next hand-rolled
header block fails before it can drift. Its scope comes from the sync targets,
so a harness added there is covered without touching the test.

Codex's recall hook stops overriding the per-call actor peer, which makes its
legacy-peer sweep ask for the legacy peer instead of asking twice for the
effective one.

* refactor(claude-code): put auto-recall back on the shared recall core

Claude Code's auto-recall.mjs carried a line-for-line copy of the recall core's
query profile, lexical overlap boost, ranking, dedup, user-space resolution,
multi-source search and budgeted block builder — 280 lines that only its own
statusline numbers kept it from calling. Those numbers now come from the shared
side: buildRecallBlockDetailed returns the block together with the counts the
fallback builder already computed, plus a stage that says which path produced
it, so a host can tell an empty recall the server had nothing to offer from one
the score threshold emptied.

buildRecallBlock stays the string-returning shell over it that opencode and pi
call. The hook is left with the Claude Code envelope and the last-recall.json
snapshot the statusline reads, and a test covers all eight reasons that file
records — including a guard that fails if the ranking pipeline is ever copied
back into this plugin.

* chore(claude-code): drop the dead per-message capture filter

auto-capture.mjs's shouldCapture has had no call site since the hook moved to
batches: its length bounds, slash-command, punctuation-only and question-only
rules each drop a whole multi-turn batch for what one turn in it looks like, and
the comment at the batch gate has said so ever since. That left four regexes
reachable only from a function nothing calls.

The batch gate keeps what it always did — skip an empty batch, and in keyword
mode require some user turn to carry a trigger phrase — and its comment stops
naming the function that is gone.

* refactor(claude-code): read the transcript through the shared capture utilities

Claude Code's transcript walk was written twice — once in auto-capture, once in
subagent-stop — and neither copy was the shared one every other harness uses, so
a fix to block handling reached nine harnesses and skipped this one.

Both now go through scripts/cc-transcript.mjs, which re-exports the shared
capture utilities and adds only what is genuinely Anthropic-shaped: a
tool_result nests its output in a content array and names its call by id, and
that output is dropped from the turn's text while travelling verbatim in the
turn's tool part. The kept turns, their order and their parts are unchanged, so
the capture cursor still means what it did.

The shared sanitizer now also strips <system-reminder> blocks and
[Subagent Context] lines; without them this switch would have started sending
Claude's own notes to itself to OpenViking as if the user had written them.

* test(codex): pin the digest URI repair the shared compressor performs

The recall compressor is a small model spawned through `codex exec`, and it
occasionally rewrites a long viking:// URI while rephrasing a bullet, which
leaves a dead link in the injected digest. Since codex went onto the shared
`compressRecallContext`, every citation is snapped back onto the URIs the
server actually returned before the digest is injected; this test pins that
a mangled URI comes back repaired, with the short-input short-circuit
disabled so the compressor is actually exercised.

* refactor(plugins): open every hook entry with the shared stage

The Claude Code and Codex hook entries each read stdin, re-resolved the config
for the payload's directory and answered the enabled and bypass gates in their
own words, and the copies had drifted: Codex's PreCompact never re-checked its
own switch after the reload, and the two auto-recalls asked the two gates in
opposite orders.

runHookStage is that opening. An entry hands it a config loader, its gates and
its envelope, and keeps only its own work, which now returns what the envelope
should carry instead of writing stdout from wherever it happens to stop. The
stage also carries the bypass verdict for the hook that must not stop on it —
Codex's SessionStart still sweeps and replays for other sessions inside a
bypassed repository — and a single-shot emit for the one that answers before its
worker starts.

The write-path preamble stays in the entry: a detaching hook has to spawn its
worker before stdin is consumed, and only the entry knows the response its host
expects. Claude Code's uri-guard keeps its own code as well; it reads no config
and answers no gates.

* refactor(plugins): fold the agent URI guard into the shared one

lib/agent-uri-guard.mjs was a second module over lib/uri-guard.mjs, holding one
more hint table and nothing else, and the three harnesses that imported it each
carried their own stdin read, entrypoint check and deny envelope — thirty-odd
lines apiece for the choice between two envelope shapes.

evaluateUriGuard is that module's body with the two things a host actually
differs on made arguments: the hint table, and the tool names that host guards
at all — Claude Code and opencode never see the shell in their matchers, pi
does, and the set was previously implied by whichever table a host happened to
import. The deny envelopes and the hook plumbing move in beside it, so cursor,
trae and zcode keep only the envelope they answer with.

* refactor(plugins): let the last four URI guards call the shared evaluator

Claude Code, opencode, dsh and pi each kept a private copy of the guard loop —
normalize the tool name, look it up in a local table, sweep the arguments for a
viking:// URI, format the message — because their tables name different
replacement tools. The loop is the same everywhere; only the table is host data,
and it has to stay host data: read(uris="..."), openviking_read(uris=[...]) and
viking_read(uri=..., level=...) cannot be spelled from one set of strings.

So each of the four now hands its table to evaluateUriGuard and keeps only the
decision its host answers with. Claude Code's table was character-identical to
the shared one, so it passes the guarded set instead — read, glob and grep,
leaving Bash to the model as its test has always pinned — and its stdin read,
entrypoint check and deny envelope go the way the other hook guards' did.

* refactor(plugins): run both doctors from one shared entrypoint

The Claude Code and Codex doctors were forked from one commit and still carry
the same run: parseArgs and tryJson byte for byte, expandHome inlined, the same
eight sections in the same order, the same --json envelope and exit code. The
copies had drifted apart in the details — Codex never learned to say which node
PATH resolves to when it differs from the one running the script.

runDoctor owns that run now. A harness hands it its name, its CLI, the three
sections only it can produce (install, config, activity) and the spelling it
uses for the configured account and user; it gets the argument parsing, the
section order, the envelope and the exit code back. Codex keeps assessHooksFeature,
parseFeaturesList and the isDirectRun guard its test imports, and its auth-mode
verdict arrives through onSummary.

Both wrappers also stop scanning shell rc files for the pre-2026-07 wrapper
blocks: the installer that wrote them is gone, so only the OPENVIKING_* exports
those files may still carry are worth reporting.

* refactor(plugins): compose the doctor configuration section from shared segments

Both doctors printed the same section from two copies that had drifted, so a
check written for one host was invisible on the other. The section is now six
shared segments a host calls in order, and the three checks that only one of
them had — Codex's hook-budget warning, Claude Code's extra_headers X-API-Key
and NODE_TLS_REJECT_UNAUTHORIZED warnings — run on both.

* refactor(plugins): declare which recall knobs the request omits by default

recall-core sends limit, max_tokens, query_expansion and rewrite_max_bullets
only when a layer actually set them, so the server's own defaults stand where
the user expressed no preference. That decision lived in five hand-written
`<name>Configured` projections and nowhere in the schema, so a loader had no
way to know which knobs it owed a flag. The schema marks the four now, and a
test ties the marked set to the fields recall-core actually gates.

* refactor(plugins): assemble every harness configuration in one place

Six loaders spelled out the same sequence — read the credential files,
resolve the knobs, resolve the peer, derive the timeouts, the log path,
the user agent and the send-only-when-configured flags — and each spelled
a slightly different subset of it. ov.conf's `<harness>.authMode` reached
only the three that passed a legacy layer, two of the four knobs the
server defaults for itself were reported as configured on two harnesses,
and Claude Code resolved its api key on a chain of its own.

buildPluginConfig() is that sequence, once. What is left in a loader is
what only that harness knows: Claude Code's four-valued credential source,
Codex's on/off reading of the digest switch, opencode's three section
knobs, pi's older debug-log variable, dsh's cordis input.

The api key now resolves the same way everywhere, with ovcli.conf's
`plugin` section ranked where the file it lives in ranks — under that
file's own `api_key`, over ov.conf. Codex read it last, behind
`server.root_api_key`; its doctor, its reference table and the capability
reference said so, and now say what the code does.

* refactor(plugins): resolve the portable bundle's connection from the shared loader

The Agent Plugins package kept its own copy of the credential chain, the
User-Agent, the timeout and the debug logger, so every fix to the shared
resolution had to be mirrored by hand and the proxy fabricated a credential
source out of whichever config file happened to load. Importing the full
loader is not the answer either: it would pull the knob schema and the
workspace layers into a bundle that has no hooks to run them, and those
copies are committed. `buildProxyConnection()` is the connection half of
that loader, which is all a stdio proxy needs.

The api_key still ends at ov.conf's `server.root_api_key` even when
ovcli.conf pins the chain to itself: this package ships without an
installer, so an install that names only a url there has nobody to migrate
its key.

* refactor(plugins): run every thin-harness hook from one entry

Cursor, TRAE and ZCode kept eleven shim scripts between them whose whole
body was an event assignment and an import of the harness dispatcher, and
each spelled the plugin root its own way — `${CURSOR_PLUGIN_ROOT}`,
`__OPENVIKING_TRAE_ROOT__`, `${ZCODE_PLUGIN_ROOT}` — so the installer
carried one substitution branch per spelling and a template that used the
wrong one failed only at the first hook of a fresh install. The shared
entry takes the event and the client from argv, keeping TRAE's trailing
client-id form, and one placeholder leaves one regex to render it.

The installed hooks.json is now checked against the tree it was rendered
for: a command naming a script the install did not put on disk used to
surface only when the host first ran it.

* refactor(plugins): merge the three thin harnesses into one plugin

Cursor, TRAE and ZCode were three marketplace directories running the same
state machine — the same 2000ms session-start debounce, the same prompt
dedup by event id and 500ms window, the same recall cache and cross-process
lock — around four things that genuinely differ: the event vocabulary, the
response envelope, how a prompt is read out of the payload, and how a
finished turn is captured. Everything else was triplicated, so a fix landed
in whichever copy the author happened to open: the URI guard existed three
times over one shared evaluator, the MCP proxy three times over one shared
builder, and the guard that pins how many proxies exist counted eight.

`agent-hook-plugin` keeps one dispatcher on stage 4's `runHookStage`, one
URI guard, one MCP proxy and one doctor, and puts the four differences in
`hosts/<id>.mjs`. The adapters sit at that depth because
`../../memory-plugin-shared/lib` has to resolve both here and in an
installed `agent-integrations/<client>/`, so `hosts/<id>/` holds only what a
host reads as configuration. The installer copies per host and reads the
templates from there, and the thin harnesses get the doctor entry the full
plugins have had since stage 5.

The archive guard was reaching none of this: the three plugins' hooks.json
named the shared entry across the plugin boundary, so nothing required
their own dispatchers to be in the release archive. hooks.json names the
plugin's own `scripts/hook.mjs` again, which puts every adapter back in the
derived requirement set, and the checker is handed the directories the
staging script declares instead of listing whatever the stage happens to
hold.

* fix(install): reclaim the URI-guard hook entries on uninstall

`--uninstall` recognised an entry as OpenViking's only by the hook script it
named, and the URI guard was not on that list. Uninstalling Cursor therefore
left beforeReadFile and beforeShellExecution in ~/.cursor/hooks.json, and TRAE
kept its PreToolUse entry, all of them running a uri-guard.mjs under
agent-integrations/<client>/ that the same uninstall had just deleted: the host
reported a failing hook on every file read and shell command until the user
edited the file by hand.

The uninstall filter now reclaims anything the installer wrote, by the
OPENVIKING_INTEGRATION_ID the rendered command carries, the way the install-time
filter and the TRAE CLI uninstall already did.

* ci(plugins): select the plugin test files by glob

The step named 54 paths by hand, so a new test file was only covered once
someone remembered to add it there too. Seven globs select the same set: the
union and the old list differ in neither direction (82 files each), and no
generated shared/ copy holds a test for a glob to pick up by accident.

  examples/*/scripts/*.test.mjs            19
  examples/*/scripts/lib/*.test.mjs         2
  examples/*/servers/*.test.mjs             2
  examples/*/tests/*.test.mjs              20
  examples/*-plugin/*.test.mjs             11
  examples/memory-plugin-shared/*.test.mjs 27
  agent-plugins/*.test.mjs                  1

Two things the job could not see before. A stale lib/MANIFEST or committed
shared copy passed CI, because nothing looked at the tree after the generator
ran; `git diff --exit-code` does now. And the two installer tests ran beside
the other 80: staging the marketplace regenerates the shared copies those
files import, and both fork whole installs, which is the load the two
unexplained single-test failures appeared under during this work. They now run
in a step of their own at --test-concurrency=1, and
install-agent-hooks.test.mjs copies examples/ into a tmpdir so `--source dev`
no longer installs out of the tree everything else is reading.

Neither failure reproduced in five full runs here. The only failure five runs
did find was deterministic and local: a gitignored openclaw-plugin/shared left
behind by work that has moved to another branch, which the generator reports
as stale but never deletes.

The openclaw job keeps its exclude; only its counts change, measured on this
branch: 42 files, 675 tests, and 2 pre-existing architecture-boundaries
failures rather than 4.

* ci(plugins): bring pi into the plugin version gate

pi's version was a hand-written constant in config.ts, which put it out of
reach of check-plugin-version-bumps.sh: the extension is copied wholesale by
the installer and reports that constant as the build talking on the wire, so
a shipped change under a frozen version had nothing to catch it. The
extension now carries a package.json, EXTENSION_VERSION reads it through the
shared readManifestVersion, and the gate watches that file the way it
watches the other five manifests.

The manifest changes nothing about how the extension loads or ships. pi's
loader reads a package.json only for a pi.extensions field and falls back to
index.ts without one; pi install parses a local path as a directory rather
than an npm package, and only installs dependencies for git-cloned sources;
the installer's tar copies the directory whole, so the file is in the
marketplace archive and the installed copy resolves the same 0.3.0 the
constant held. "type": "module" states the format node was already detecting
per load, which is what the warning naming this file asked for.

Against origin/main the gate still reports four plugins: pi's manifest is
new on this branch, so it takes the same no-baseline skip agent-hook-plugin
takes, and is enforced from the next base onward.

* ci(plugins): merge the two npm plugin releases into one matrix

The dsh and opencode release workflows were the same 81 lines with seven
values swapped, so every fix to the publish flow had to be made twice and one
copy could drift unnoticed. plugin-npm-release.yml carries the flow once and
lists the two packages as matrix entries.

Every way the two files differed, and how the matrix expresses it:

  workflow name    one "Plugin npm Release"; the per-package "Publish
                   @openviking/..." survives as the job name
  job name         name: Publish ${{ matrix.package }}
  work directory   defaults.run.working-directory: ${{ matrix.directory }}
  concurrency      moved from the workflow to the job and keyed by
                   matrix.directory, so the two packages still queue
                   independently rather than behind each other
  push paths       the union of both plugin directories plus this filename;
                   the shared lib entry was already identical in both
  install command  matrix.install: npm ci for dsh, npm install for opencode,
                   which ships no lockfile
  name assertion   compared against matrix.package through an env var
                   instead of a literal inside the node script

The union filter means a dsh-only push also starts the opencode leg. That leg
validates and then stops at the npm view already-published check, the same
check that already made a shared-lib push a no-op for whichever package was
not bumped. Everything else is byte-identical to what both files ran:
permissions, checkout, node 24, the validate step, and the publish step with
its NPM_TOKEN-or-OIDC fallback and --provenance. actionlint reports nothing
on the new file.

RELEASE.md named the opencode workflow by filename, so its bullet now names
the merged workflow and both packages.

* refactor(plugins): give each skill copy its own sync target

SKILL_TARGETS was the one list in the generator with its own shape — a skill
plus an array of directories — so the delivery flag every other target carries
had nowhere to live, and nothing asserted that git holds the skill copies a
host installs by path. One entry per copy, `{ skill, dir, committed }`, and the
test that decides which generated copies belong in git reaches skills as well
as vendored modules.

Every skill copy is committed and stays that way: .gitignore covers the
vendored shared/ directories only, and a host installs a skill by copying its
path out of this repository, so a copy git does not hold ships nothing.
Nothing else moves — the same two skills reach the same six directories and the
generator writes the same bytes. openclaw-plugin and agent-plugins stay out on
purpose: their skills are different files over different tool surfaces, not
copies of these.

* refactor(install): move the installer's JavaScript into lib/install

install.sh carried a 328-line JSONC editor and three near-identical hooks/mcp
merges as node heredocs. Nothing could exercise them except running the whole
installer against a scratch HOME, and they had already drifted: the uninstall
copy of the ownership test learned to reclaim the URI-guard hook entries and
the install copy never did, so reinstalling left a stale guard behind.

jsonc-edit.mjs is the editor, moved verbatim behind one entry point.
host-json-config.mjs is the merge, once: a single read that refuses to
overwrite what it could not parse, a single atomic write, a single answer to
"is this hook entry ours", and three commands over them — write, remove and
merge-zcode. The ownership list is the uninstall side's, so an install now
replaces a stale uri-guard.mjs entry instead of appending beside it, and the
zcode fold reclaims by the same rule as the other three hosts rather than by a
substring of its own.

lib/install/ is the installer's code, not the plugins'. No shared module
imports it, so it enters no vendoring closure and no lib/MANIFEST; one
`./install/...` import from a module that hooks do import would put it in both,
and install-lib-closure.test.mjs now fails on exactly that.

Finding it is the one thing that is not a straight move. `--uninstall` runs
before any source is resolved and the documented uninstall pipes this script
from a URL, where there is no sibling directory to read — reaching for the
checkout there would clone a repository just to remove hooks. So the assembled
runtime keeps a copy of the directory beside the manifest modules, and
install_lib_dir looks next to the running script, then there, then at the
checkout.

install-opencode-jsonc.test.mjs is six in-process cases over the editor —
comments wherever they sit, trailing commas, a single-quoted value holding a
brace and a `//`, an existing nested mcp object, idempotence, and a server the
user disabled — plus the one end-to-end install that proves install.sh still
hands the module the right arguments. The capability reference's install.sh
line count goes with them; it was already wrong and this makes it wronger.

* refactor(tests): share the mock server and the hook runner

Seven test files carried their own copy of the same three helpers and the
copies had drifted: two writeJson signatures, and two withMockOpenViking
contracts, one of which called .catch() on the handler's return value and so
could not take a synchronous handler at all. Three more carried a hook runner
that turned a non-zero exit into a rejected promise — a result no test could
assert on, which is why the async ZCode test spawns the hook by hand where it
wants to read the exit code.

testing/support.mjs already held buildConfigForTest and is the right home for
the rest: it sits outside lib/, so sync.mjs vendors none of it into a shipped
plugin, and a helper imported from a *.test.mjs file would have registered
that file's own tests a second time.

Two things the shared versions do that no copy did. The mock logs every
request it saw and hands the log to the callback, which the three tests that
only recorded pathnames now assert against instead of keeping an array of
their own. And runHookScript never rejects: the exit code comes back like
stdout does, and the call sites say expectExit where a clean exit is part of
the expectation.

writeJson takes the status last and defaults it to 200, so the two-argument
callers read unchanged and claude-code's auto-capture moves its status to the
end. claude-code's auto-recall is not one of the seven — it never carried
readRequestBody — and keeps its two local copies.

* refactor(tests): make the config and credential contracts shared

The credential chain is one resolver every harness calls, but only codex
tested it, so a change to the chain broke six harnesses and one suite
reported it. That file moves to memory-plugin-shared, where the glob picks
it up as the shared contract it always was.

The five per-harness config suites had the same problem in reverse: each one
re-tested the layer stack, the peer order and the cwd rule that
plugin-config.test.mjs already holds every loader to, and buried the one or
two cases only that harness answers. Three contract tests finish that file —
a knob walked through every layer, a default told apart from a choice, and
the workspace layer read from the cwd the loader is handed rather than the
process's — and the per-harness suites keep what is theirs.

Trimmed: claude-code and codex scripts/config.test.mjs, opencode and pi
tests/config.test.mjs. Every case deleted from them is answered by a shared
one in plugin-config.test.mjs, workspace-peer.test.mjs or the credentials
contract this commit moves. dsh loses nothing: the cordis input is a layer no
other harness has.

Two things only a harness knows had no test at all, and now do: claude-code's
tri-state digest mode, which it reads from the shared on/off switch under
either env spelling, and codex's boolean reading of that same switch, where a
compressor counts as configured only once it has been told what to run.

* refactor(tests): test the MCP proxy contract once

The proxy is one shared module, but its protocol contract was tested through
two harness entrypoints — twelve cases under codex, one of them again under
opencode — and both entrypoints re-exported the factory they import purely so
a test could reach it. A change to the core meant editing two suites, and the
opencode case differed from codex's only in which User-Agent string its
fixture made up.

The twelve cases and their fixture move to mcp-proxy-core.test.mjs, beside the
two transport-error cases already there, and the file now has one proxy
builder: it takes a fetchImpl, so an injected fetch and a real loopback
upstream are the same harness. Neither host file keeps a case of its own —
every one of the twelve exercises the shared core through the harness's config
shape, which mcp-proxy-config.test.mjs already covers per harness.

One case the split never had: a proxy with no local tool provider. Most
harnesses run it that way, so the local-tool branches have to stay invisible —
the listing is the upstream's, and a call named like a local tool is still the
upstream's to answer.

`examples/*/servers/*.test.mjs` now matches nothing. The pattern stays, for the
next case that really is one host's, under nullglob so it contributes nothing
rather than reaching node as a literal; opencode's test script drops it.
opencode keeps its servers/mcp-proxy.mjs export map entry, which describes the
entrypoint the package exposes, not the deleted test.

* refactor(tests): test the shared runtime where it lives

Three suites tested shared code from a harness directory, so a second
harness reusing that code was covered only by accident. The recall
timeout case, the queue cases that never touch cc's ov-session, and
findLastHumanTurnIndex now sit beside the module they exercise.

findLastHumanTurnIndex moves with its cases: it is a generic helper on
the shared turn shape, and codex's capture-utils.mjs already re-exports
everything shared, so its one caller needs no edit.

* refactor(plugins): label each credential from the chain that resolved it

Three doctors each re-walked ovcli.conf and ov.conf to say where the url, the
key and the identity came from — the walk `resolveOpenVikingCredentials` had
just finished. They were not the same walk. Claude Code and Codex rebuilt the
key's origin by hand and ignored the `apiKeySource`/`credentialPath` every
config now carries, so a key the chain took from one layer could be reported as
coming from another; the thin-harness doctor read `apiKeySource` but then let
ov.conf's harness block name the account under a pin the chain stops above.

`credentialSources(cfg, cliConf, ovConf, { section })` lives in doctor-core now,
with the section defaulting to the calling harness. The key's layer comes off
`cfg.apiKeySource`; the files are asked only which field inside that layer holds
the value, which is the one thing the layer cannot say — `plugin.<harness>.
apiKey` from `api_key`, a harness block from `server.root_api_key`. The identity
follows the chain step for step, plugin section included.

The stage-5 guard could not see the three copies because the core never exported
the name. It does now, so a fourth one fails that test. The two harness test
copies move to doctor-core.test.mjs, beside the function they describe.

* ci(plugins): generate OpenClaw shared runtime before tests

Co-authored-by: TRAE CLI <traecli@bytedance.com>

* docs(plugins): define hook and MCP development standard

Co-authored-by: TRAE CLI <traecli@bytedance.com>

* docs(plugins): publish bilingual development guide

Co-authored-by: TRAE CLI <traecli@bytedance.com>

* fix(plugins): restore runtime and distribution contracts

Generate shared dependencies before validation and source installs. Keep Codex retries owned by the transcript cursor and defer threshold commits until the tail is delivered. Enforce the shared hook enable gate, restore auth-mode environment precedence, and preserve pi request options.

Co-authored-by: TRAE CLI <traecli@bytedance.com>

* refactor(plugins): use a host-neutral hook manifest

Move the shared hook integration metadata out of the Claude-specific directory and update version checks, diagnostics, installation verification, archive validation, tests, and documentation.

Co-authored-by: TRAE CLI <traecli@bytedance.com>

---------

Co-authored-by: TRAE CLI <traecli@bytedance.com>
2026-09-15 18:28:20 +08:00

184 lines
6.3 KiB
JavaScript

#!/usr/bin/env node
/**
* Stop hook for Codex (turn end).
*
* Codex passes JSON on stdin including session_id, transcript_path,
* last_assistant_message. Stop fires per turn — NOT at session end.
*
* Strategy:
* 1. For this codex session_id, derive one long-lived OpenViking session
* id (`cx-<codex-session-id>`) and remember it in state.
* 2. Read transcript_path, parse JSONL rollout, append every new
* user/assistant turn since last capture via add_message.
* 3. If session pending_tokens crosses commitTokenThreshold, commit while
* keeping a recent live tail for continuity.
*
* A Stop for this session also proves the thread is alive again, so the
* SessionEnd marker (if any) is cleared before anything else. Committing is
* SessionEnd's job; PreCompact still commits before context compaction and
* the SessionStart sweep remains the fallback for exits that never fire it.
*
* Stop output schema accepts {} as a no-op.
*/
import { loadConfig } from "./config.mjs";
import { createLogger } from "./debug-log.mjs";
import { catchUpTurns, hasCaptureKeyword, makeFetchJSON } from "./ov-session.mjs";
import { clearEnded, loadState, saveState, withSessionLock } from "./session-state.mjs";
import { runHookStage } from "./shared/agent-hook-runtime.mjs";
import { maybeDetach, readHookStdin } from "./shared/async-writer.mjs";
import { resolveEffectivePeerId } from "./shared/workspace-peer.mjs";
let cfg = loadConfig();
const { log, logError } = createLogger("auto-capture", cfg);
let activePeerId = cfg.peerId || "";
const LOCK_WAIT_MS = 120_000;
// A detached worker can boot long after the turn ended, so it clears end
// markers against the parent's start time rather than its own.
const HOOK_STARTED_AT = (() => {
const inherited = Number(process.env.OPENVIKING_HOOK_STARTED_AT);
return Number.isFinite(inherited) && inherited > 0 ? inherited : Date.now();
})();
const { fetchJSONRes, fetchJSON } = makeFetchJSON(cfg, { getActorPeerId: () => activePeerId });
function output(obj) {
process.stdout.write(JSON.stringify(obj) + "\n");
}
function noop(message) {
output(message ? { systemMessage: message } : {});
}
async function maybeCommitByThreshold(ovSessionId, added) {
const empty = {
committed: false,
pendingTokens: 0,
commitCount: 0,
totalMessageCount: 0,
traceId: "",
};
if (added <= 0) return empty;
const meta = await fetchJSON(`/api/v1/sessions/${encodeURIComponent(ovSessionId)}`);
const pendingTokens = Number(meta?.pending_tokens || 0);
const commitCount = Number(meta?.commit_count || 0);
const totalMessageCount = Number(meta?.total_message_count || 0);
log("pending_tokens", {
ovSessionId,
pending: pendingTokens,
threshold: cfg.commitTokenThreshold,
keepRecentCount: cfg.commitKeepRecentCount,
});
if (pendingTokens < cfg.commitTokenThreshold) {
return { committed: false, pendingTokens, commitCount, totalMessageCount, traceId: "" };
}
const commit = await fetchJSONRes(`/api/v1/sessions/${encodeURIComponent(ovSessionId)}/commit`, {
method: "POST",
body: JSON.stringify({ keep_recent_count: cfg.commitKeepRecentCount }),
});
const committed = commit.ok;
const traceId = commit.traceId || commit.result?.trace_id || "";
log("commit", {
ovSessionId,
ok: committed,
status: commit.status,
trace_id: traceId || undefined,
pending: pendingTokens,
error: committed ? undefined : commit.error?.message || commit.error?.code,
});
return {
committed,
pendingTokens,
commitCount: committed ? commitCount + 1 : commitCount,
totalMessageCount,
traceId,
};
}
async function capture(sessionId, transcriptPath, cwd, heartbeat) {
const state = await loadState(sessionId);
activePeerId = cfg.peerId || state.workspacePeerId || resolveEffectivePeerId({ cfg, cwd }).peerId;
log("start", { sessionId, transcriptPath, hasPeer: Boolean(activePeerId) });
const health = await fetchJSON("/health");
if (!health) {
logError("health_check", "server unreachable or unhealthy");
return "";
}
const { newTurns, added, ovSessionId } = await catchUpTurns({
state,
transcriptPath,
fetchJSONRes,
activePeerId,
cfg,
log,
logError,
heartbeat,
shouldSend: (turns) => cfg.captureMode !== "keyword" || hasCaptureKeyword(turns),
});
let commitInfo = { committed: false, traceId: "" };
if (added > 0) {
log("appended", { ovSessionId, added });
if (added === newTurns.length) commitInfo = await maybeCommitByThreshold(ovSessionId, added);
}
await saveState(state);
if (added <= 0) return "";
return `appended ${added} turn(s) to OpenViking session ${state.ovSessionId}` +
(commitInfo.committed
? ` (committed${commitInfo.traceId ? `; trace_id=${commitInfo.traceId}` : ""})`
: "");
}
async function main(stage) {
cfg = stage.cfg;
const sessionId = stage.input.session_id || "unknown";
const transcriptPath = stage.input.transcript_path || null;
// A turn ended for this session, so it is alive again after any resume — but
// only for markers older than this hook run.
await clearEnded(sessionId, { before: HOOK_STARTED_AT });
const outcome = await withSessionLock(
sessionId,
({ heartbeat }) => capture(sessionId, transcriptPath, stage.cwd, heartbeat),
{ waitMs: LOCK_WAIT_MS },
);
if (outcome.skipped) {
logError("lock_timeout", `another writer holds ${sessionId}; leaving state untouched`);
return;
}
return outcome.value;
}
async function start() {
// Write-path hook: gated by autoCapture against this process's directory,
// before the payload names the session's.
if (!cfg.autoCapture) {
log("skip", { stage: "init", reason: "disabled" });
noop();
return;
}
// Async write mode returns a no-op response immediately; worker stdout is
// intentionally discarded, so appended-count systemMessage is sync-only.
process.env.OPENVIKING_HOOK_STARTED_AT = String(HOOK_STARTED_AT);
if (await maybeDetach(cfg, { approve: () => output({}) })) return;
await runHookStage({
loadConfig,
input: { read: readHookStdin },
gates: { enabled: (reloaded) => reloaded.autoCapture },
envelope: noop,
onSkip: (reason) => log("skip", { stage: "init", reason }),
}, main);
}
start().catch((err) => { logError("uncaught", err); noop(); });