Skip to content

OpenClaw full capture (LLP 0157-0162) - #510

Merged
philcunliffe merged 32 commits into
masterfrom
integration/openclaw-full-capture
Jul 31, 2026
Merged

OpenClaw full capture (LLP 0157-0162)#510
philcunliffe merged 32 commits into
masterfrom
integration/openclaw-full-capture

Conversation

@philcunliffe

@philcunliffe philcunliffe commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

OpenClaw full capture: shadow-provider steering, cross-shape projection, settlement, and backfill.

Implements the openclaw-full-capture change set: spec LLP 0157, decisions LLP 0158 / 0159, design LLP 0161, executable plan LLP 0162.

See llp/0161-openclaw-full-capture.design.md for the technical design and llp/0162-openclaw-full-capture.plan.md for the eleven-task breakdown.

Draft while the remaining tasks land. Opened by neutral so the change set has a PR to reconcile against; held for a human merge.

Change-Set: openclaw-full-capture

test and others added 20 commits July 30, 2026 22:39
Technical design covering LLP 0157's two deliverables (the OpenClaw-side
steering plugin and the @hypaware/openclaw adapter rework) and its core
removal (json_path). Resolves the forks the spec leaves open: where the
steering plugin package lives, the before_model_resolve steering
precedence that maps onto the three warning causes, whether the adapter
still registers a gateway client once attach-probe is gone, single vs
two exchange projectors, and cross-shape content-match-key normalization
for LLP 0159 settlement.

Co-Authored-By: Claude <noreply@anthropic.com>
Refines LLP 0161's design into an eleven-task graph with real
code-dependency edges (a shared session reader and match-key module as
foundational tasks, the steering plugin and adapter rework split along
their actual file/import coupling), rates each task's complexity for
the LLP 0022 model-tier seed, and pins every open item LLP 0161 Section
10 named to the task that must resolve it.
New plugin-local module (`hypaware-core/plugins-workspace/openclaw/src/session_file.js`)
implementing the one reader LLP 0158 asks for: a bounded first-line
`type: "session"` header read returning `{ sessionId, cwd, startedAt }`
(non-blank-string fields, absolute-path predicate on cwd shared in
behavior with the Codex `sessionMetaCwd` precedent), plus a full
`type: "message"` transcript iteration. Stays plugin-local per LLP
0158's placement decision until a second plugin needs it.

Tests cover the guard/blank/relative-cwd edge cases LLP 0150's Codex
precedent already names, applied to OpenClaw's flat header shape, plus
the message iteration's skip/normalize behavior.

Task-Id: T3
hypaware.plugin.json drops the write_openclaw_settings permission and
the attach_probe declaration (R7); config_sections[0].summary now
mentions the backfill block. NOTE: write_home is a candidate for
re-audit down to read_home now that the settings-file write is gone
(the LLP 0158 session reader only needs read_home) - flagged here for
a follow-up audit, not resolved in this change.

config.js gains validateBackfillSection (a same-shape copy of
@hypaware/codex's validator: on_join boolean, window_days positive
integer, unknown keys rejected), wired into validateOpenclawConfig.
The stale "no backfill block" comment is gone.

src/settings.js is deleted along with index.js's import of it.
activate() keeps gateway.registerClient({ name: 'openclaw', ... })
but attach() becomes an honest no-op: it writes nothing and reports
that OpenClaw routing is now owned by the
@hypaware/openclaw-steering-plugin npm package, installed on the
OpenClaw side. This keeps `hyp attach openclaw` / `hyp detach
openclaw` / client-descriptor resolution working instead of erroring
`unknown client`.

test/plugins/openclaw-settings-attach.test.js (tested the retired
settings.js directly) is replaced by
test/plugins/openclaw-client-registration.test.js, which proves the
client keeps resolving through activate()'s registered adapter, the
live attach command path, the disk-driven detach path, and the
descriptor map behind client listing/status.

Task-Id: T5
Adds hypaware-core/plugins-workspace/openclaw/src/match_key.js per LLP
0161 design Section 5: canonicalMatchKey (one hash function over a
{kind, identity}[] tuple format), wireMatchKey and sessionMatchKey (the
two shape-specific builders that feed it), and a deliberately separate
ordinal/time fallback matcher (withRoleOrdinals,
buildOrdinalFallbackIndex, matchOrdinalFallback).

The session-side block-kind synonym table (toolCall/toolUse/
function_call/functionCall -> tool_use, redacted_thinking -> thinking)
and the toolResult message-shape reconciliation are built from the real
openclaw package (packages/llm-core/src/types.ts) and its own test
fixtures, not guessed. tool_use/tool_result identity is content-hash
based rather than id-based because openclaw's own
createStandaloneTextToolCallId() proves ids are not stable across
capture routes.

Task-Id: T4
…(T1)

New top-level `@hypaware/openclaw-steering-plugin` package, the OpenClaw-
installed half of LLP 0157/0161's full-capture rework. Registers the two
shadow providers (`hypaware-anthropic` / anthropic-messages,
`hypaware-openai` / openai-completions) via OpenClaw's own
`api.registerProvider({ catalog: { run } })`, with `baseUrl` read from an
env var (HYP_GATEWAY_ENDPOINT, falling back to the fixed default gateway
port) rather than a HypAware kernel import, since this package runs inside
OpenClaw's process, not HypAware's.

The core of this task is `resolveSteering` (src/steering.js): the
four-branch precedence LLP 0161#steering-precedence resolves (shadow-shape
lookup -> canonical-provider check -> DEFERRED_SET membership ->
credential resolution), returning `providerOverride` plus the
`x-hypaware-upstream` request metadata on the terminal steer branch, and
one of `no_credential` / `no_preset` / `deferred` on every pass-through.
Wired into a `before_model_resolve` hook in src/index.js, with a
rate-limited warning ledger (src/warning_ledger.js) for the pass-through
cases per LLP 0149.

OpenClaw's own plugin-manifest/entry-point/hook-registration shape
(`definePluginEntry`, `api.on('before_model_resolve', ...)`,
`hooks.allowConversationAccess` gating, the `openclaw.plugin.json`
manifest fields) was verified against OpenClaw's published plugin docs
before writing openclaw.plugin.json and src/index.js's registration calls;
see the comment block at the top of src/index.js for the two residual
open items (exact before_model_resolve event field names, and the
metadata-return channel) worth reconfirming against a live install.

Credential borrowing (prepareRuntimeAuth) and Anthropic wire parity
(wrapStreamFn) are deliberately out of scope here - LLP 0162 T2, layered
onto this same provider registration.

Unit tests cover resolveSteering's every branch, DEFERRED_SET membership
(including the shape-matching-but-wrong-vendor non-deferred cases:
minimax, synthetic, kimi-coding, openrouter), each of the three warning
causes, and the warning ledger's per-provider+cause rate limiting.

Task-Id: T1
…opic wire (T2)

Layers LLP 0161 Section 2.2's two owner-scoped hooks onto T1's shadow
provider registration.

`prepareRuntimeAuth` (src/runtime_auth.js) resolves the *shadowed* provider's
credential through OpenClaw's public
`openclaw/plugin-sdk/provider-auth-runtime` `resolveApiKeyForProvider` and
returns `{ apiKey, baseUrl, expiresAt? }` for that one request. It caches
nothing, writes nothing, and re-resolves on every call; the shadow-to-real
provider map is derived from the two maps `resolveSteering` already branches
on rather than restated, so a third API shape cannot be steered without also
being borrowable.

`wrapStreamFn` (src/wire_parity.js) mirrors OpenClaw's
`extensions/anthropic/stream-wrappers.ts` for `hypaware-anthropic`: the
default and OAuth beta sets merged as a header-name-keyed `Set` union
(idempotent by construction, R4), `service_tier` / `fastMode` as a payload
patch, and the thinking-prefill strip. `hypaware-openai` gets no wrapper,
per LLP 0148's per-shape scope note.

Open item resolved (LLP 0148 Open questions, LLP 0161 Section 10): does
`@mariozechner/pi-ai` add the default betas itself? It does.
`pi-ai@0.73.1`'s Anthropic provider adds both defaults from its own flags and
prepends `claude-code-20250219` / `oauth-2025-04-20` whenever the key matches
`sk-ant-oat`, the same OAuth predicate OpenClaw uses. So the mirror shrank:
it installs only under OpenClaw's own `needsAnthropicBetaWrapper` condition,
because merging unconditionally would put the interleaved beta back on the
wire for adaptive-thinking models that pi-ai deliberately omits it for, which
is a parity change in the other direction. Both LLPs record the answer.

Three further facts came out of reading the shipped OpenClaw bundle and
pi-ai, and each changed the code (all recorded in LLP 0161 Section 2.2):

- pi-ai merges `options.headers` over its own defaults with `Object.assign`,
  so a mirrored `anthropic-beta` replaces rather than extends pi-ai's value
  and must therefore carry the full set.
- `service_tier` is a payload field, not a header, and OpenClaw gates it on
  `provider === 'anthropic'` plus a public endpoint class. Both halves fail
  for a shadow provider on a loopback gateway, which is exactly the loss
  LLP 0148 names, so the mirror keeps the conditions it can still evaluate
  honestly (the shape, the OAuth and Sonnet 5 carve-outs) and treats the
  endpoint as public, which it effectively is: the gateway's `anthropic`
  preset forwards to a static `https://api.anthropic.com`.
- The shipped OpenClaw treats `context-1m-2025-08-07` as
  `ANTHROPIC_CONTEXT_1M_BETA_LEGACY` and strips it from every emitted set, so
  the mirror strips it too. The `context1m` opt-in still decides whether the
  mirror runs, exactly as it does for OpenClaw's own wrapper.

Two smaller corrections the borrow could not work without:

- `resolveSyntheticAuth` returns a non-secret placeholder for the two
  `hypaware-*` ids. OpenClaw's embedded runner throws
  `MissingProviderAuthError` for a provider with no resolvable credential
  *before* `prepareRuntimeAuth` runs, so without it the borrow is never
  reached. LM Studio's bundled plugin uses the same seam with `custom-local`;
  the manifest declares the marker in `nonSecretAuthMarkers` and both ids in
  `syntheticAuthRefs`.
- T1's steering hook passed `resolveApiKeyForProvider`'s result straight to
  `resolveSteering`'s credential probe. The SDK resolves to a
  `ResolvedProviderAuth` record, never a bare key, so the object was always
  truthy and the `no_credential` branch could not fire. The probe and the
  borrow now share one unwrapping adapter, which is what makes the ledger's
  coverage claim true.

`expiresAt` is returned for OAuth-mode borrows only, as a re-resolution
deadline rather than a claim about the token's life: OpenClaw schedules
background re-preparation only when one is present, and
`resolveApiKeyForProvider`'s own OAuth branch refreshes under lock when asked
again. It stays clear of OpenClaw's 5-minute refresh margin so the
re-preparation cannot busy-loop.

The pass-through ledger grew per-record `operation`/`status`/`detail` so the
two new hooks report through the same rate limiter instead of a second copy
of it, keeping LLP 0149's ledger vocabulary for `before_model_resolve` alone.

Tests: 40 new cases across runtime_auth and wire_parity (62 in the package).

Task-Id: T2
…m precedence rung (T6)

Adds `openaiUpstreamPreset()` to the openclaw projector, byte-identical in
shape to @hypaware/codex's existing openai preset (name, base_url,
path_prefix, provider), and registers it in index.js's activate() the same
"register iff not already present" way the anthropic preset already is.

Both anthropicUpstreamPreset() and openaiUpstreamPreset() gain a match()
precedence rung above their existing path/header checks: an unconditional
match when the steering plugin's x-hypaware-upstream header names this
preset's provider, so steered OpenClaw traffic routes correctly even on a
path/header signature shared with another adapter. Claude/Codex traffic,
which never sends the header, is unaffected (regression test included, plus
an integration-style test against the gateway's real matchUpstream/
compileUpstreams).

hypaware.plugin.json's contributes.client.required_upstreams grows from
["anthropic"] to ["anthropic", "openai"], now that the plugin actually
registers both presets.

Task-Id: T6
json_path was the last-consumer format LLP 0143 accepted removing with
no migration (LLP 0157 core removals). Delete the write-side branch in
client_detach_disk.js (detachClientFromDisk's json_path dispatch and
detachJsonPathMarker) and the read-side branch in status.js
(probeClientAttachFromDescriptor's json_path dispatch and
parseJsonRecordString), plus the helpers left dead by their removal
(parseRecordString, stringEntries, setAtDottedPath, deleteAtDottedPath
in client_detach_disk.js) and the now-unused json_util imports in
status.js. json and toml formats, and the MALFORMED_MARKER guard, are
untouched. Delete test/core/client-detach-json-path.test.js.

Task-Id: T10
New `hypaware-core/plugins-workspace/openclaw/src/backfill.js`,
`createOpenclawBackfillProvider(opts)`, registered via
`ctx.backfills.register(...)` in `activate()` (design Section 7).

It scans `~/.openclaw/agents/*/sessions/*.jsonl` through the LLP 0158
shared reader (R9: no private parse), gates each file once on the
header's `cwd` through the shared usage-policy resolver (R10), excludes
CLI-backend turns with a fail-closed allowlist (R10, LLP 0147), and
builds an `AiGatewayProjectedExchange` straight off each record's own
fields. The record's native `message_id` goes through verbatim, so a
backfilled row never touches `computeMessageId`'s fallback-hash path and
lands on the same `message_id`, and therefore the same `part_id`, a
settled live row does. That is R11 by construction, and the new test
proves it against the real live projector plus the LLP 0159 match keys
standing in for T8's enricher.

Two implementation-time refinements, landed in LLP 0161 Section 7 with
the code:

- The allowlist reads a record's effective provider. Only assistant
  records carry `provider`, so read literally the allowlist would have
  excluded every user prompt; an unstated record now takes the provider
  of the turn it belongs to, which excludes a CLI-backend turn whole.
- One item per session, not per record, so the materializer's
  `previous_message_id` chain and tool-call lookup stay whole (the Codex
  precedent). `native_id` is the session's id accordingly.

Open item carried forward, not resolved: no live OpenClaw install was
reachable, so the exact `provider`/`api` values a CLI-backend turn
records could not be verified. The allowlist fails closed either way,
which is why LLP 0161 Section 10 says not to block on it.

Task-Id: T9
New hypaware-core/plugins-workspace/openclaw/src/settle.js:
createOpenclawSettlementEnricher(opts) returns { name:
'openclaw-settlement', clientName: 'openclaw', settle(rows, ctx) },
registered in activate() right after registerExchangeProjector (the
claude/src/index.js placement).

Per session (grouped by session_id) it reads one session file through the
T3 LLP 0158 reader, builds a Map<matchKey, message> from the transcript
via T4's sessionMatchKey, and for each row looks up
attributes.openclaw.match_key: content match first, then T4's
ordinal/time fallback as a separate second pass. On a hit the row is
upgraded to native identity (message_id, recomputed part_id, the
header's session container id per LLP 0159) and the spent
openclaw.match_key / gateway.identity_source attributes are stripped.
Independent of match success the session's single header cwd goes
through the shared usage-policy resolver, returning USAGE_POLICY_DROP at
that row's position when policy.class === 'ignore' and logging
plugin.openclaw.usage_policy_drop with the Claude precedent's field
shape (cwd_hash, never a raw cwd).

Two details the design left to implementation time:

- Which file is "the session file". A live row's session_id is the
  prompt-head hash (LLP 0144), which the session file never sees, so the
  binding is established by CONTENT: the candidate whose transcript
  holds the most of the group's match keys wins, ties to the newer file,
  and a group no candidate matches binds to nothing (settles nothing,
  drops nothing). Weaker signals are not enough to bind because the cwd
  verdict that follows the binding drops rows.
- The fallback's ordinal comes from the file, indexed by the row's
  message_index (OpenClaw re-sends the whole conversation, so a row's
  index in its exchange is its position in the session); a position
  whose recorded role disagrees with the row's declines instead of
  guessing. A content key two session messages share is owned by
  neither, so two rows can never take one native message_id (and with
  it one part_id).

session_file.js gains the shared enumeration both this and T9's backfill
need (defaultOpenclawAgentsDir, listOpenclawSessionFiles), keeping the
agents/<id>/sessions/*.jsonl layout in the one reader. LLP 0161 Section 6
records the binding rule and the fallback's ordinal source.

Measured settlement match rate: 1.00 (5/5 rows) against a realistic
session file covering toolCall blocks, a standalone toolResult record
and a multi-block assistant turn - see the PR description for the
open-item note on OpenClaw's JSONL write timing.

Task-Id: T8
`createOpenclawExchangeProjector()` keeps its priority 110 and its
`x-hypaware-client` match gate, and gains an internal shape dispatch:
`project()` reads `x-hypaware-upstream` to pick `anthropicMessages()`
(unchanged) or a new sibling `openaiMessages()`, an OpenAI Chat
Completions request/response/SSE-stream parser mirroring
`anthropicMessages()`/`reconstructAssistantMessage()` for the other wire.
`provider` is no longer hardcoded `'anthropic'`: it is the header's value,
falling back to `'anthropic'` only when the header is absent (R6).

`openaiMessages()` emits the Anthropic block vocabulary rather than
OpenAI's own: `tool_calls` become `tool_use` blocks and a `role: "tool"`
envelope becomes a one-block `tool_result` message, because
`wireMatchKey`'s per-block reduction only recognizes tool calls and tool
results under those kind names - left native, an OpenAI turn could never
match the same turn read back out of the session file. For the same
reason the leading `system`/`developer` messages are lifted into
`system_text` (no system-role record exists in the session file to match
a system row against), which also keeps the session hash keyed off the
system-prompt head on both shapes. LLP 0161 Section 3.5 records both.

Every projected row, being fallback identity, now carries T4's
`wireMatchKey` as `attributes.openclaw.match_key` (R8), merged one level
deep so it never evicts the `usage` namespace beside it. `usageAttributes`
reads both wires' token spellings, subtracting OpenAI's cached reads from
its gross `prompt_tokens` so the stored input count is net on both.

Tests cover both shapes streamed and non-streamed, the header-absent and
unrecognized-header fallbacks, tool-call accumulation across chunks, a
truncated stream, match-key stamping on every row of every shape, and
that an OpenAI-captured tool call hashes to the same key as its OpenClaw
session-file `toolCall` record.

Task-Id: T7
test and others added 9 commits July 31, 2026 04:29
T6 grew activate() a second upstream preset registration (openai) on the
same import line and in the same registration run T8's settlement enricher
attaches to, so the two land as one import and one ordered sequence.

The registration-order test asserted the whole call sequence, which pinned
a preset COUNT that is Section 3.4's business, not this task's. It now
asserts what it is actually about: the enricher sits directly after the
exchange projector (the claude/src/index.js placement), with the preset
registrations, however many wire shapes there are, ahead of it.

Task-Id: T8
…nto HEAD

Resolved conflict in openclaw/src/index.js: activate() now registers
both the T8 settlement enricher and the T9 backfill provider (additive,
non-conflicting features that both touched the same registration
block). Updated the T8 adjacency test in
openclaw-client-registration.test.js to mock ctx.backfills.register
and to expect the backfill registration between the enricher and the
client, since that test predates T9's backfill provider and otherwise
throws on the now-required ctx.backfills.register call.
Adds `## openclaw_capture` to docs/ACCEPTANCE.md in `## codex_desktop_capture`'s
structure (What it proves / does not prove / Requires / Related / Steps /
If it fails), covering both routes the OpenClaw adapter offers.

Three places where OpenClaw's mechanism differs from Codex's, resolved
against the openclaw repo and its plugin/CLI docs rather than guessed:

- Step 1 replaces the settings-marker check (there is none, R7) with
  `openclaw plugins inspect hypaware-openclaw-steering --runtime --json`,
  the surface that imports the plugin module and reports what
  `register(api)` registered: both shadow provider ids and the
  `before_model_resolve` hook. `plugins list` is a cold registry read and
  cannot answer this. The step also pins the two config entries a human
  will otherwise miss (`allowConversationAccess`, without which the raw
  conversation hook never runs for a non-bundled plugin, and
  `HYP_GATEWAY_ENDPOINT`), and pre-empts the inert `client_attach_missing`
  warning R7's probe removal leaves behind.
- Step 4 names the `openai` row as the on-the-wire proof that
  `x-hypaware-upstream` arrives: the projector falls back to `anthropic`
  when the header is absent, so an anthropic row alone cannot distinguish
  a steered turn from a lost header.
- Step 5 exercises the warning ledger against a deferred family, with a
  config-declared `anthropic-vertex` provider so it needs no cloud
  credentials (the deferral is decided before any credential resolves),
  and warns that a Google-family id reports `no_preset` instead because
  the deferred branch only runs for a shape a shadow already covers.
- Step 6 keeps Codex's backfill shape and pass condition (`rows_written: 0`
  with `rows_skipped >= 1`), preceded by a settled/total query that both
  guards the pass condition and measures the LLP 0159 open question about
  real-time session-JSONL appends.

Docs only; R12's human run is not this task.

Task-Id: T11
…ved (T11)

`## openclaw_capture` landed with its step-1 registration check written
against guessed `openclaw plugins inspect --runtime --json` field names
(`.plugin.status`, `.plugin.providerIds`, `.typedHooks[].name`). OpenClaw
documents the report's contents in prose and publishes no key schema, so
those selectors would have failed as missing keys rather than as missing
registrations: a false negative aimed at HypAware when the plugin was fine.
That is exactly the failure this document exists to prevent.

Step 1 now keeps the command (it is the right surface: `--runtime` imports
the module in a live gateway, plain `inspect` is a cold registry read that
passes on a plugin that never loaded) and asserts by token presence in the
report instead - two provider ids this repo owns plus OpenClaw's own hook
name. It also carries the fallback the design named, for builds without
`--runtime`: skip to the live-capture step, where an `openai` row is only
reachable through a registered shadow provider and a hook that steered.

Two host preconditions the docs made visible are now in Requires: the
`plugins.entries.<id>.hooks.allowConversationAccess` gate (it drops a
non-bundled plugin's `before_model_resolve` registration silently, leaving
only a `pluginDiagnostics` warning) and the OpenClaw version floor that
gate implies. Both are failure modes that otherwise read as HypAware bugs.

LLP 0161 Section 9's "exact mechanics ... verified at implementation time"
is struck through and resolved, matching Section 10's existing convention.

Task-Id: T11

Co-Authored-By: Claude <noreply@anthropic.com>
# Conflicts:
#	src/core/daemon/status.js
@philcunliffe

Copy link
Copy Markdown
Contributor Author

REVIEW round 1 — openclaw-full-capture (T1–T11), head 1077910

Verdict: changes requested, but nothing blocking merge on the privacy or credential paths. The two highest-risk seams (usage-policy gating, credential borrowing) are correct and well tested. Eight actionable findings were fixed and pushed as eafd5c2; two need a maintainer decision and one is a deliberately-unresolved open item the change set ships without answering.

Baseline held throughout: npm test = 3228 tests, 8 failures, all of them the known test/core/leave-command.test.js set (issue #512, fix in #527); npm run typecheck clean.


1. Privacy / usage-policy gating (T8, T9) — PASS, with one shipped-unverified residue

settle.js and backfill.js both hold. Verified line by line rather than by test-reading:

  • hypaware-core/plugins-workspace/openclaw/src/settle.js:166,188-205 — the gate is genuinely independent of match success (gate is computed once per bound session file, and the ignore branch continues before settleRow regardless of whether match is set). openclaw-settlement.test.js:360 proves that directly.
  • The drop actually removes the row: dataset.js:319-322 passes allowDrop: true on the flush path, and openclaw live rows reach the enricher via settleSelect's rowHasNullCwd arm (dataset.js:333), not only the isFallbackRow arm — so a row with native identity but no cwd still gets gated. That is the right wiring.
  • backfill.js:226-257 — one resolve per file, ignore continues past the whole file before any record is read. openclaw-backfill.test.js:390 asserts zero items and one event.
  • No raw cwd in any log: settle.js:198 and backfill.js:237 both hash to a 16-hex prefix, matching the Claude precedent's hashCwd. The backfill event carries source_path (the session file under ~/.openclaw), not the cwd.

RESIDUE (major, informational — cannot be fixed in review). The settlement seam is OpenClaw's only .hypignore gate (live proxy rows carry no cwd), and it only fires once the session file is on disk and content-binds. Two consequences the maintainer should weigh before the human acceptance run:

  • If OpenClaw buffers session JSONL until session end, no live row of an ignored tree is ever gated. resettleBatch runs with allowDrop: false (dataset.js:361-366), so there is no second chance after commit. LLP 0162 pinned this open item to T8 ("measure the settlement match rate at flush against a real OpenClaw session … note the observed behavior in the PR description"); it was not resolved — backfill.js:74-79 records that no live OpenClaw install was reachable, and openclaw-settlement.test.js:276 measures the normalization against a synthetic file, not write latency.
  • bindSessionFile (settle.js:286-308) binds on a score of ≥1 shared match key. A batch whose real session file has not landed yet can bind to an older file that happens to share one common prompt, and then inherit that file's cwd verdict. The code's own comment says a wrong binding is silent data loss; the reverse (an ignored session escaping the drop) is equally reachable. Worth a minimum-score floor if the field match rate turns out low.

Both are consistent with LLP 0159's accepted residue, so I have not treated them as defects — but R14's guarantee is weaker in practice than the spec reads, and R12's human run is where that gets measured.

2. Credential handling (T2) — PASS, no findings

openclaw-steering-plugin/src/runtime_auth.js:132-158. No module-level state, no cache, no write path; resolveCredential is awaited fresh on every prepareRuntimeAuth call and the borrowed key is returned in the value OpenClaw asked for and nowhere else. expiresAt is only stamped for mode === 'oauth' and is a re-resolution deadline, correctly kept above OpenClaw's 5-minute refresh margin. The onError path (index.js:99-107) forwards error.message to the ledger, not the credential. wire_parity.js:122 reads the key to detect OAuth by shape but never logs or persists it. Nothing reaches a captured row.

3. Identity correctness (T4, T7, T8) — PASS

  • The ordinal/time fallback is a genuinely separate second pass: settle.js:177-183 only calls ordinalFallbackMatch when the content lookup missed, and match_key.js:303-318 scores purely on time distance within a 5-minute window. Never merged into one score. Two extra guards (row position must exist in the file; the file's role at that position must equal the row's) are correct and tested (openclaw-settlement.test.js:397,413).
  • buildOpenclawSessionIndex (settle.js:360-408) deletes an ambiguous content key rather than last-wins — the right call, since a shared native message_id becomes a shared part_id and dedupe would then collapse two distinct messages. Tested at :430.
  • Messages with no native id still feed the ordinal index (so ordinals do not silently renumber) but carry Infinity as their timestamp, which is outside every window. Correct.
  • R11's test does prove it, not assert it (test/plugins/openclaw-backfill.test.js:313-382): it runs the real backfill provider, the real live projector, and the real sessionMatchKey/wireMatchKey on both sides of the upgrade, then expands both through the real row materializer and compares message_id/part_id. It hand-rolls the upgrade rather than calling createOpenclawSettlementEnricher — reasonable, since T9 landed before T8 — and the comment says so honestly. Now that both are merged, calling the real enricher would be strictly stronger; nit, not fixed.

4. T10's deletions — one MAJOR, fixed

MAJOR — hypaware-plugin-kernel-types.d.ts:183 (pre-fix). The json_path branches were correctly removed from client_detach_disk.js and daemon/status.js, json/toml/MALFORMED_MARKER are untouched (diffed against master; the surviving MALFORMED_MARKER throw at client_detach_disk.js:679 is byte-identical to master's :884), and no helper was orphaned. But the published plugin contract still declared the format: format: 'json' | 'toml' | 'json_path' plus marker_path/marker_record, both of which now have zero readers anywhere in the tree. Nothing validates attach_probe at runtime (src/core/manifest.js:148-171 treats contributes opaquely), so a manifest declaring json_path would type-check against the shipped .d.ts, probe as never-attached, and slip past action_attach.js:312's !descriptor.attachProbe orphaning guard on reverse — dropping the attach marker while the client's settings stay written. That is #212 reopened through the type. LLP 0143 R7 says core loses the format in the same change; fixed — union narrowed to 'json' | 'toml', dead fields removed.

MINOR — living docs. llp/0109 still presented its json_path section as live, Accepted guidance, and four JSDoc sites still named the format (src/core/cli/types.d.ts:352, src/core/config/types.d.ts:652,669, hypaware-core/plugins-workspace/claude-desktop/src/index.js:56). Fixed: 0109's section carries a superseded-by-0143 note, prose updated.

NIT (not fixed). test/core/client-detach-json-path.test.js:127 on master also asserted @hypaware/openclaw is in V1_BUNDLED_PLUGIN_ALLOWLIST; that assertion died with the file and is not covered elsewhere.

5. T5 — honest no-op attach() — PASS

attach() (openclaw/src/index.js:172-210) makes exactly four calls: withSpan, span.setAttribute, attachCtx.stdout.write, logger.info. No fs, no settings module; settings.js and its test file are gone; materializeAttachAssets copies nothing (no skills ship). All four callers pass stdout/stderr, so the no-op cannot throw on a missing stream. hyp detach openclaw resolves the descriptor from the manifest map ahead of the gateway gate (src/core/commands/clients.js:99-113) and lands on client_detach_disk.js:108's no-probe guard → { changed: false }, exit 0. hyp attach / hyp clients key on registerClient, which is retained. The registration test uses the real manifest for the detach and clients cases; the attach case stubs getClient (nit).

MINOR, fixed. The manifest's description and the user-visible picker summary (hypaware.plugin.json:5,30) still claimed the plugin "injects a dedicated 'hypaware' model provider into OpenClaw's openclaw.json". Both rewritten. llp/0163:40 linked to the deleted settings.js; de-linked.

MINOR, fixed. validateBackfillSection (openclaw/src/config.js:105) is byte-identical to Codex's — verified by diff, no semantic divergence — but shipped with zero test coverage, while Codex's copy is covered. CLAUDE.md requires traditional tests for config validation, and the whole point of an independent copy is that it can be edited independently. Added 3 tests (full block, 8 malformed cases, pointer mounting).

6. T6 — preset precedence — MAJOR, NOT fixed (needs a maintainer decision)

The critical property holds: Claude and Codex traffic never sends x-hypaware-upstream, headerValue lowercases both sides and requires a non-empty string, steersTo uses strict === so an absent header is undefined === 'anthropic' → false, and compileUpstreams breaks the priority-100 tie by descending prefix length so /v1/messages still resolves to anthropic. Verified empirically. No routing regression.

But the rung is activation-order-dependent. It exists only on this plugin's copies of the presets. registerUpstreamPreset is a name-keyed last-write-wins Map.set (ai-gateway/src/api.js:55), and neither claude/src/projector.js:629 nor codex/src/index.js:73 carries it. If Codex activates after OpenClaw, its openai registration — which declares no priority and no match() — wins the slot, and both the header rung and priority: 100 vanish. That is load-bearing: the shadow baseUrl is a bare origin with no /v1 (gateway_endpoint.js:6), so a steered openai-completions turn arrives on /chat/completions and then matches no upstream at all. The equivalence guard (test/plugins/openclaw-projector.test.js:296) passes only because none of its 7 input cases carries the header, and :327 asserts the openai preset against hardcoded literals rather than importing Codex's real registration — which is why the priority divergence went unnoticed.

Closing this means either adding the rung to Codex's (and Claude's) presets, or changing registerUpstreamPreset to genuinely register-iff-absent — both of them changes to sibling adapters' routing that a review round should not make unilaterally. What I did fix: the code no longer asserts the copies are interchangeable. projector.js:187-200,215-233 and index.js:107-110 now state the divergence and its consequence explicitly, so nobody reads the old "whichever registers last changes nothing" comment as true.

MINOR (not fixed). The rung is additive-only — it never makes a preset decline when the header names a different provider. x-hypaware-upstream: openai on /v1/messages routes to anthropic. LLP 0161 §3.4 says the header "wins routing outright"; it only wins when no higher-sorted preset independently matches. Not reachable with today's two shadow providers.

7. Two more MAJORs found outside the named risk areas, both fixed

OPENCLAW_HOME split between the two consumers. backfill.js resolved the agents root with a private path.join(homeDir, '.openclaw') while settle.js used the LLP 0158 reader's defaultOpenclawAgentsDir, which routes through resolveClientSettingsPath and honours $OPENCLAW_HOME. On a relocated install (the override is real and exercised — hypaware-core/smoke/flows/cli_bundled_plugins_activated.js:214), hyp backfill openclaw scanned an empty directory and reported success while settlement read the real sessions. This is precisely the two-consumers-two-copies drift LLP 0158 exists to prevent, applied to the file's location rather than its parse — and session_file.js:140-146 says so in as many words. Fixed: backfill goes through the reader, activate() threads ctx.env, regression test added.

The steering plugin's tests never run. openclaw-steering-plugin/test/ holds 62 passing unit tests covering resolveSteering's four-branch precedence (LLP 0162's single highest-complexity item, where a wrong branch is a wrong answer to "is this provider captured") and the credential-borrow contract. npm test is deliberately scoped to root test/** (CLAUDE.md), so none of them was gated. The package was also outside tsconfig.json's include, so its source never typechecked either. Fixed with one root test module that imports them — 62 tests now run in CI, and the package's source typechecks through the same import graph, with no change to the runner's scope or the package's layout.

8. @ref honesty and doc accuracy — PASS

Every @ref anchor introduced by this change set resolves: all ten LLP 0161#* anchors exist as explicit {#…} targets, and 0157#backfill/#requirements/#steering-plugin, 0150#usable-cwd, 0085#telemetry, 0035#one-carrier, 0158#decision, 0159#decision/#open-questions all resolve to real headings or <a id> targets. LLP 0161 and 0162 still describe what shipped, with two exceptions worth noting: 0162's T6 brief calls the openai preset "byte-identical in shape to @hypaware/codex's" while also specifying priority: 100, which Codex's does not have (see §6); and 0162's T5 note to flag write_home was carried out (see below) but 0162 itself was not updated. docs/ACCEPTANCE.md's ## openclaw_capture is genuinely runnable — it resolves the introspection mechanic (openclaw plugins inspect --runtime --json, grepped for tokens rather than jq'd against an unpublished key schema), names the allowConversationAccess gate and the HYP_GATEWAY_ENDPOINT entry as mandatory-and-silent-when-missing, and gives the Codex-shaped rows_written: 0 / rows_skipped >= 1 pass condition.

MINOR, fixed. gateway_endpoint.js:11 claimed HYP_GATEWAY_ENDPOINT is "the environment variable the HypAware install sets". Nothing on the HypAware side sets it; the operator puts it in openclaw.json (which ACCEPTANCE.md correctly documents). Docstring corrected.

NIT (not fixed). llp/0162 carries 35 em dashes, against CLAUDE.md's blanket ban. Reported rather than fixed: 81 of 151 files in the LLP corpus already violate it (1406 lines), the file is Generated-by: neutral, and — to the change set's credit — llp/0161, docs/ACCEPTANCE.md, and every source file added here are clean.

HOUSE RULE, fixed. warning_ledger.js:21 used @typedef, which CLAUDE.md forbids. Moved to an interface in the package's own types.d.ts and imported via @import.


write_home re-audit (asked for, not fixed)

It can go, down to read_home. Nothing left in @hypaware/openclaw writes under $HOME. settings.js was the only writer and is deleted; attach() writes to stdout and the log only; backfill.js and session_file.js are read-only (fs.readdir/stat/readFile/openSync('r')); settle.js reads the same files; projector.js touches no filesystem. The .hypignore resolver reads under $HOME (covered by read_home) and the machine-local list under the state root (covered by read_state). I left the manifest alone because LLP 0162's T5 brief says explicitly "flag write_home for re-audit down to read_home in the PR description, do not resolve it here" — this is that flag, with the answer.


Fixed and pushed — eafd5c2

  1. hypaware-plugin-kernel-types.d.tsjson_path and its marker_path/marker_record fields removed from the attach-probe contract (T10 completion, LLP 0143 R7).
  2. llp/0109json_path section marked superseded by LLP 0143; four stale prose references updated in src/core/cli/types.d.ts, src/core/config/types.d.ts, claude-desktop/src/index.js.
  3. openclaw/src/backfill.js + index.js — agents root resolved through the LLP 0158 reader, so OPENCLAW_HOME is honoured; defaultOpenclawHome (now unused) removed; regression test added.
  4. test/plugins/openclaw-steering-plugin.test.js (new) — gates the package's 62 unit tests under npm test.
  5. test/plugins/openclaw-config.test.jsvalidateBackfillSection coverage (full block, 8 malformed cases, pointer mounting).
  6. openclaw/src/projector.js + index.js — the two preset comments now state the activation-order divergence instead of denying it.
  7. openclaw/hypaware.plugin.jsondescription and picker summary no longer claim a settings write.
  8. openclaw-steering-plugin/src/{warning_ledger.js,types.d.ts}@typedef → interface + @import; gateway_endpoint.js docstring corrected; match_key.js typo.

npm test: 3228 tests, 3219 pass, 8 fail (the known leave-command baseline). npm run typecheck: clean. No rebase, squash, or reset — all eleven task merge commits remain ancestors.

Round 1 gated the steering package's 62 unit tests under `npm test` by
importing its test modules from a root shim, which also pulled the package's
source into `tsconfig`'s file graph. It reached six of seven source files.
`src/index.js` - the entrypoint that registers both shadow providers, wires
the credential borrow into `prepareRuntimeAuth`, and installs the
`before_model_resolve` steering hook - was still checked by nothing: no test
imports it (importing it would execute `definePluginEntry` from a module that
does not resolve here), and `tsc` never saw it.

The only thing keeping it out was its two `openclaw/plugin-sdk/*` imports.
`openclaw` is the host that installs this package, not a dependency of it, so
those specifiers do not resolve in this repo and the file failed with two
TS2307s and nothing else. `src/types.d.ts` already says it exists to mirror
"the slice of OpenClaw's own plugin SDK this package touches", naming exactly
those two subpaths, but it only declared the value shapes, never the modules.

Declare them, and add the package to `tsconfig`'s `include` so the entrypoint
is reached by path rather than only through the test graph. Verified
non-vacuous: a type error injected inside `register()` is caught.

`npm test` 3228/8 (the known `leave-command` baseline, #512).
`npm run typecheck` clean.
@philcunliffe

Copy link
Copy Markdown
Contributor Author

REVIEW round 2 (final) - openclaw-full-capture (T1-T11), head eafd5c2

Verdict: one MAJOR left open for triage, deliberately unfixed. Everything else this round is closed. Round 1's eight fixes all landed and were verified in the committed tree, not taken on the comment's word. One new finding was found, fixed, and pushed as a2b6425. The change set is otherwise ready: the two highest-risk seams (privacy gating, credential borrowing) passed round 1 line by line and are unchanged since.

Baseline held: npm test = 3228 tests, 3219 pass, 8 fail, all 8 the known test/core/leave-command.test.js set (issue #512, fix in #527), zero non-baseline failures. npm run typecheck clean.

Merges cleanly with master. git merge --no-commit --no-ff origin/master reports Already up to date: 71c6231 (#505, #502, #491 included) is an ancestor of this head. No conflict, nothing to resolve.

Branch invariant intact. All eleven task merge commits remain ancestors of the pushed head (T1-T10 once, T11 twice). No rebase, squash, or reset.


1. THE OPEN ITEM: T6 preset precedence - MAJOR, not fixed, needs a maintainer decision

Confirmed real and still present at this head. This is the item most likely to decide the PR's fate, so it is stated in full rather than by reference.

registerUpstreamPreset is a name-keyed last-write-wins Map.set (hypaware-core/plugins-workspace/ai-gateway/src/api.js:55). It has no register-iff-absent branch and no collision warning.

Two plugins register a preset under the name openai:

  • @hypaware/codex (hypaware-core/plugins-workspace/codex/src/index.js:73-78), where UPSTREAM_NAME = 'openai' (:25): base_url, path_prefix: '/v1', provider, and no priority, no match().
  • @hypaware/openclaw (hypaware-core/plugins-workspace/openclaw/src/projector.js:244-256), the same four fields plus priority: 100 and a match() whose first rung is steersTo(headers, 'openai'), the x-hypaware-upstream check.

The x-hypaware-upstream rung exists only on OpenClaw's copies. Neither claude/src/projector.js nor codex/src/index.js carries it. So on an install where Codex activates after OpenClaw, Codex's registration wins the slot and both the header rung and priority: 100 silently vanish.

That is load-bearing rather than cosmetic, because the shadow provider's baseUrl is a bare origin with no /v1 (openclaw-steering-plugin/src/gateway_endpoint.js:6, http://127.0.0.1:18521). A steered openai-completions turn therefore arrives at the gateway on /chat/completions, and isOpenaiPath (projector.js:278) matches only /v1 and /v1/.... With the match() gone, that request matches no upstream at all.

The consequence is sharper than "uncaptured". matchUpstream returning nothing is an HTTP 404 back to the caller (hypaware-core/plugins-workspace/ai-gateway/src/proxy.js:129-131, no upstream matches path). The steering hook has already committed the providerOverride by then, so the user's OpenClaw turn fails. LLP 0157 R5 (llp/0157-openclaw-full-capture.spec.md:227-230) says in as many words: "the user's turn MUST NOT fail because of capture". Under this activation order, it does. The Anthropic side of the same divergence is harmless (a steered anthropic-messages turn still matches on the /v1/messages path, which is what routed OpenClaw before the header existed), and projector.js:193-201 says so correctly.

Why the tests did not catch it. test/plugins/openclaw-projector.test.js:327 asserts the openai preset against hardcoded literals rather than importing Codex's real registration, so the priority/match() divergence between the two copies is invisible to it. The equivalence guard at :296 passes only because none of its seven input cases carries the header.

Not fixed, on purpose. Closing it means either adding the rung to Codex's (and Claude's) registrations, or changing registerUpstreamPreset to register-iff-absent (or to reject a colliding name). Both are changes to sibling adapters' routing, or to a shared kernel capability's contract, which a review round should not make unilaterally on a change set that does not own them. Round 1 declined for the same reason and I agree.

What the change set does do honestly: projector.js:187-200 and :227-241 now state the divergence and its consequence in the code, so nobody reads the earlier "whichever registers last changes nothing" comment as true.

Note for whoever runs the acceptance procedure: docs/ACCEPTANCE.md step 4 would catch this. Its pass condition requires an openai row, which on an affected install cannot appear. That is the natural gate if the maintainer prefers to measure before deciding.

Related MINOR, also not fixed. The rung is additive-only: it never makes a preset decline when the header names a different provider, so x-hypaware-upstream: openai on /v1/messages still routes to anthropic. LLP 0161 §3.4 says the header "wins routing outright"; it only wins when no higher-sorted preset independently matches. Not reachable with today's two shadow providers.


2. Round 1's eight fixes - all verified landed in the tree at eafd5c2

Checked against the committed files, not the commit message.

  1. json_path contract. hypaware-plugin-kernel-types.d.ts:194 now reads format: 'json' | 'toml'. A repo-wide grep for json_path, marker_path, marker_record across *.js/*.d.ts/*.json returns only the explanatory note at :184. client attach: probe-less contributes.client can attach but reverse() silently no-ops, orphaning settings #212 is no longer reachable through the type.
  2. LLP 0109. llp/0109:113 carries the > **Superseded by LLP 0143** block; the four stale JSDoc prose sites are updated.
  3. OPENCLAW_HOME split. openclaw/src/backfill.js:143 resolves through defaultOpenclawAgentsDir (session_file.js:151); the private path.join(homeDir, '.openclaw') is gone, with the reason recorded at :140-142. Regression test at test/plugins/openclaw-backfill.test.js:558.
  4. The 62 steering tests. See §3 below, verified separately.
  5. validateBackfillSection coverage. Three tests present at test/plugins/openclaw-config.test.js:69, :76, :96 (full block, malformed cases, pointer mounting).
  6. Preset comments. projector.js:193-201 and :227-241 state the divergence (quoted in §1).
  7. Manifest honesty. openclaw/hypaware.plugin.json:5,30 no longer claim a write to openclaw.json; both now say routing is owned by the steering-plugin package.
  8. House rules. Zero @typedef remain in openclaw-steering-plugin/src/ or openclaw/src/; gateway_endpoint.js:9-16 correctly says nothing on the HypAware side sets HYP_GATEWAY_ENDPOINT.

3. The 62 tests genuinely run - confirmed, and the shim is not silently empty

This was the specific worry, so it was checked three ways rather than by reading the shim.

  • node --test test/plugins/openclaw-steering-plugin.test.js alone reports # tests 62 / # pass 62 / # fail 0.
  • 62 is the right number, not a coincidence: counting test( in the five imported files gives 3 + 14 + 14 + 6 + 25 = 62. Nothing is being dropped on import.
  • They run inside the full suite, not only standalone: the package's assertions appear in the npm test log at real ordinals (e.g. ok 3067 - a trailing assistant tool_use is not a prefill).

The shim's paths resolve correctly (test/plugins/ + ../../openclaw-steering-plugin/test/ lands on the repo-root package, which is where it actually lives).

4. NEW - MAJOR, fixed and pushed as a2b6425: the steering plugin's entrypoint was checked by nothing

Round 1's shim pulled the package's source into tsconfig's file graph as a side effect of importing its tests. tsc --listFiles shows it reached six of seven source files. The seventh is openclaw-steering-plugin/src/index.js (220 lines): the entrypoint that registers both shadow providers, wires borrowShadowedCredential into prepareRuntimeAuth, and installs the before_model_resolve steering hook. It had no test and no typecheck. No test can import it (that would execute definePluginEntry from a module that does not resolve in this repo), and tsc never saw it. This is the file most exposed to a silent wiring mistake, and precisely the one nothing was checking.

The only thing keeping it out was its two openclaw/plugin-sdk/* imports (index.js:32-33). Probed directly: with the file forced into the graph it produced exactly two errors, both TS2307 unresolved-module, and nothing else. Meanwhile openclaw-steering-plugin/src/types.d.ts:1-3 opens by declaring it exists to mirror "the slice of OpenClaw's own plugin SDK this package touches", naming those two subpaths by name - it declared the value shapes but never the modules, so the claim was not yet true.

Fixed by making it true: openclaw-steering-plugin/src/openclaw_plugin_sdk.d.ts (new) declares the two ambient modules, and tsconfig.json gains openclaw-steering-plugin in include so the entrypoint is reached by path rather than only through the test graph. Two TypeScript constraints shaped the form and are documented in the file: an ambient module body may not import through a relative specifier (TS2439), so definePluginEntry takes its callback loosely and index.js:218-220 annotates the parameter itself with the existing OpenclawPluginApi import.

Positively verified non-vacuous, since a declaration file that typechecks nothing is worse than none: tsc --listFiles now lists index.js, and a type error injected inside register() is caught (index.js(221,31): error TS2345: Argument of type 'number' is not assignable to parameter of type '{ warn(record: UncapturedTurn): boolean }'). Reverted after the probe.

No defect was found in index.js once it was checked. In particular the two different resolveCredential shapes it passes are both correct: the object form { provider, context } to createPrepareRuntimeAuth (runtime_auth.js:125) and the bare-string form to resolveSteering (steering.js:92).

5. Fresh review at this head - the areas round 1 deprioritized

The OpenAI wire-shape parser (projector.js:482-760) - PASS, no findings. Read mechanically rather than through its tests.

  • Non-streamed (openaiAssistantFromBody:640) and streamed (reconstructOpenaiAssistantMessage:678) both hold. The stream stitcher latches id/name per tool call by the wire's own index, accumulates arguments across chunks, tolerates a chunk carrying only usage, skips [DONE], and marks a stream that ended without a finish_reason as stop_reason = 'error', matching the Anthropic side. openaiToolArguments:626 correctly makes an absent or empty argument string {} rather than the empty string parseMaybeJson('') yields, and keeps an unparseable string verbatim.
  • leadingSystemCount:524 counts only the leading system/developer run, so a mid-conversation steering message stays a row instead of being folded into the session key. That is the right call and it is the same text that feeds system_text, so both shapes hash the same session.
  • Normalizing into the Anthropic block vocabulary (rather than leaving OpenAI's tool_calls/role: "tool" native) is load-bearing, not stylistic: wireMatchKey only recognizes tool calls and results under those kind names, so a native-shaped turn would hash through the generic fallback and never match its own session-file record. Covered by 12 tests in test/plugins/openclaw-projector-shape.test.js, including the round-trip at :417.
  • The header-absent fallback is safe. Shape dispatch keys on x-hypaware-upstream and defaults to the Anthropic parse (projector.js:104-111), which in principle could misparse an OpenAI body. It is not reachable: without the header the same request does not route either (/chat/completions fails isOpenaiPath), so it 404s at the gateway and is never projected. Anthropic-shaped traffic that never met the steering plugin still projects, which is the documented intent.

T5's honest no-op attach() - PASS. openclaw/src/index.js:172-213 makes exactly four calls: withSpan, span.setAttribute, attachCtx.stdout.write, logger.info. No fs, no settings module. It writes a changed: false record in both JSON and human forms and names routing_owned_by, so it is honest rather than merely silent. registerClient is retained purely so hyp attach/hyp detach/hyp clients openclaw keep resolving by name, with the reason stated at :165-168; round 1 traced all three command paths and they resolve.

docs/ACCEPTANCE.md ## openclaw_capture (:173-480) - PASS, and unusually careful. Every step is one a human can actually run. It pins the OpenClaw version and says why (before_model_resolve in 2026.4.21, the allowConversationAccess schema in 2026.4.23) and what the failure looks like on an older build; it flags that both openclaw.json entries are mandatory and silent when missing; it explains why it greps three tokens instead of writing a jq selector against an unpublished key schema; it warns off openclaw agent exec because its temporary state dir means the session JSONL steps 5 and 6 need may never land; it gives a fallback path for builds without --runtime; and it pre-empts the inert client_attach_missing warning so the runner does not chase it. Step 6's rows_written: 0 with rows_skipped >= 1 is correctly called a pass, with the reason. Step 7 restores the machine. Step 5 explains why a Google-family provider would verify the wrong branch.

6. Restated for the maintainer, not re-litigated

These are round 1's residues. Nothing changed at this head; they are repeated only so triage sees them in one place.

  • Settlement is OpenClaw's only .hypignore gate, because live proxy rows carry no cwd. It fires only once the session file is on disk and content-binds. resettleBatch runs with allowDrop: false (ai-gateway/src/dataset.js:421-423), so there is no second chance after commit: if OpenClaw buffers session JSONL until session end, no live row of an ignored tree is ever gated. Consistent with LLP 0159's accepted residue, but R14's guarantee is weaker in practice than the spec reads.
  • LLP 0162's "does OpenClaw append JSONL in real time" open item was never resolved. No live OpenClaw install was reachable (openclaw/src/backfill.js:74-79 records this), and openclaw-settlement.test.js:276 measures normalization against a synthetic file, not write latency. docs/ACCEPTANCE.md step 6 is where it gets measured, and it correctly asks for the settled/total ratio to be recorded in the release notes even when it is 1.0.
  • write_home can drop to read_home. Re-audited: nothing left in @hypaware/openclaw writes under $HOME. settings.js was the only writer and is deleted; attach() writes to stdout and the log; backfill.js, session_file.js, settle.js are read-only; projector.js touches no filesystem. The manifest (openclaw/hypaware.plugin.json:12) still declares write_home, left alone because LLP 0162's T5 brief says to flag it in the PR rather than resolve it. This is that flag, with the answer.

Pushed this round - a2b6425

  • openclaw-steering-plugin/src/openclaw_plugin_sdk.d.ts (new), openclaw-steering-plugin/src/index.js, tsconfig.json - the entrypoint is now typechecked, verified non-vacuous.

npm test: 3228 tests, 3219 pass, 8 fail (the leave-command baseline only). npm run typecheck: clean. All eleven task merge commits remain ancestors; no rebase, squash, or reset.

@philcunliffe

Copy link
Copy Markdown
Contributor Author

Triage rung (LLP 0017) at head a2b6425, review rounds exhausted with residual findings. Judgement: this change set can ship — every residual is non-blocking. Filed the deferred findings, with full evidence, as #536:

  • T6 (the deciding residual): registerUpstreamPreset is last-write-wins by name, and Codex's openai registration carries no priority/match() while OpenClaw's does — so if Codex's registration ever won the slot, a steered openai-completions turn would 404 and the user's turn would fail, which is exactly what LLP 0157 R5 forbids. I traced this all the way through: the registration race, the 404, and R5's text all check out as described. But I also verified reachability, which round 2 flagged as open: plugin activation order for the daemon is decided by a deterministic alphabetical toposort tie-break (src/core/dep_graph.js), @hypaware/codex always sorts before @hypaware/openclaw, and SIGHUP reload never adds/removes plugins (a restart is required, which re-runs the same deterministic order). I confirmed this empirically by calling the resolver directly with several input orderings — codex always activates first, openclaw always wins the openai slot, in every full boot. So the failure mode is real in the code but not reachable today. It's a landmine held safe by an accidental (untested, undeclared) ordering fact rather than a designed invariant — worth hardening (add priority/match() to Codex's own registration, or a regression test on activation order), but not a reason to block this merge. Full chain and hardening options are in Follow-up: deferred review findings from PR #510 #536.
  • Settlement's single .hypignore gate (T8's real-time-JSONL open question, never resolved): an accepted design trade-off from LLP 0162, not a new finding. Flagged again in Follow-up: deferred review findings from PR #510 #536 with a pointer to run openclaw_capture step 6 on a live install to get the real settled ratio.
  • write_home permission is stale: nothing writes under $HOME anymore; T5 explicitly deferred the re-audit. Cosmetic, noted in Follow-up: deferred review findings from PR #510 #536.

Spot-verified per LLP 0002 rather than trusting prior rounds' prose: npm test (3219/3228 pass, the 8 failures are the known leave-command.test.js baseline from #512/PR #527, unrelated to this change set), npm run typecheck clean, and the steering plugin's 62-test suite green.

@philcunliffe
philcunliffe marked this pull request as ready for review July 31, 2026 07:25
@philcunliffe philcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Jul 31, 2026
@philcunliffe philcunliffe added neutral:stuck neutral attempted this but cannot complete it autonomously — needs a human and removed neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) labels Jul 31, 2026
@philcunliffe

Copy link
Copy Markdown
Contributor Author

🤖 neutral: stuck - a misrouting hazard found after this PR was marked approved

Retracting my own approval. I flipped this PR to ready-hold + neutral:approved after triage cleared it. A worker on the follow-up issue (#536) then found a defect that two review rounds and the triage all missed. It is a production misrouting risk, so this PR should not merge as-is.

What neutral was doing: the issue-fix rung on #536 (hardening the openai upstream slot on master) surfaced this while building its routing matrix.

The defect

openaiUpstreamPreset() in hypaware-core/plugins-workspace/openclaw/src/projector.js:244-256 carries priority: 100, and its match() falls through to isOpenaiPath (:278-280):

export function isOpenaiPath(path) {
  return path === '/v1' || path.startsWith('/v1/')
}

That matches /v1/messages - Anthropic's Messages API path.

The chain, each link verified against origin/master:

  1. OpenClaw wins the preset slot. registerUpstreamPreset is a name-keyed last-write-wins Map.set (ai-gateway/src/api.js:55), and toposort() breaks ties alphabetically (src/core/dep_graph.js:248,259), so @hypaware/codex activates before @hypaware/openclaw and OpenClaw's copy is the one that survives.
  2. Config upstreams compile at priority 0. proxy.js:329 - priority: typeof u.priority === 'number' ? u.priority : 0. hyp init's composePickerConfig writes upstreams with no explicit priority.
  3. The table sorts by descending priority (proxy.js:336-337), first match wins.

So on any install with a configured anthropic and no configured openai - the ordinary Claude-only setup - the openai preset at priority 100 sorts ahead of anthropic at 0, and its /v1/ anchor swallows /v1/messages. Claude traffic routes to OpenAI.

Demonstrated on master with the same preset shape:

today        order: anthropic,openai | /v1/messages -> anthropic
p0+match     order: anthropic,openai | /v1/messages -> anthropic
p100+match   order: openai,anthropic | /v1/messages -> openai     <-- the hazard

Why the review chain missed it

test/plugins/openclaw-projector.test.js:327 asserts the preset's literal values (including priority === 100) rather than compiling the real preset table and exercising routes. It pins the constant that causes the bug instead of testing the behaviour, so it passes.

The fix, and why it costs nothing

Drop priority: 100 from openaiUpstreamPreset(). Priority is not needed for the property this change set actually wants: with the x-hypaware-upstream rung present on the preset's match(), a steered turn routes correctly whichever copy wins the slot. Removing the priority also makes this copy identical in effect to the hardened Codex registration in PR #537 (fix/issue-536), which adds match: matchOpenaiUpstream on master and deliberately declines priority: 100 for exactly this reason, with a test pinning the ordering so it cannot be reintroduced silently.

Consider whether anthropicUpstreamPreset()'s priority: 100 (:207-219) wants the same treatment. Its path_prefix is the narrower /v1/messages, so it is not the same hazard, but the symmetry is worth a glance.

What it needs from you

Two smaller items for the same pass, both noted by #537 and neither blocking: openaiUpstreamPreset()'s comment saying the Codex rung "is not made here" goes stale once #537 lands, and the shape test above should compile the real table rather than assert literals.

How to unstick: reply with a comment on this PR (or push to the branch); neutral monitors this thread and will re-engage with your guidance on its next tick.

@philcunliffe

Copy link
Copy Markdown
Contributor Author

🤖 neutral: raising the severity on this - it is credential exposure, not just misrouting

My earlier stuck report described the priority: 100 hazard as Claude traffic routing to OpenAI. The review of the sibling PR #537 reproduced it in a differential harness rather than reasoning about it, and the consequence is worse than I stated.

With priority: 100, on an install with a config-declared anthropic and no config openai, requests to /v1/messages and /v1/messages/count_tokens route to api.openai.com - including requests carrying anthropic-version, x-api-key, or Bearer sk-ant-.

That is a Claude prompt and an Anthropic API key sent to a third-party vendor. Measured blast radius: 128 routing cells with anthropic declared at /v1/messages, 512 with it at /v1.

The mechanism is unchanged from my earlier report and independently re-confirmed: proxy.js:336-337 (descending priority sort), :329 (config upstreams compile at 0), source.js:264-282, and composePickerConfig writing upstreams with no priority (walkthrough.js:634), with no manifest gateway_upstream block declaring one.

The fix still costs nothing. #537's review checked the converse, which no one had: with the steering rung present, openai is reached on the bare-origin /chat/completions path in all 16 config x activation-order shapes at priority 0. Dropping priority: 100 loses no capability.

Two further notes for the eventual merge-base pass:

  1. This PR's match() is line-for-line equivalent to Codex's openai preset carries the x-hypaware-upstream rung, and the preset-slot race gets a regression test #537's. The only difference is priority: 100 - and since OpenClaw activates last and the slot is last-write-wins, this copy would silently overwrite Codex's openai preset carries the x-hypaware-upstream rung, and the preset-slot race gets a regression test #537's deliberate choice.
  2. Codex's openai preset carries the x-hypaware-upstream rung, and the preset-slot race gets a regression test #537 has since hardened its guard (52ea8f9) so that overwrite fails loudly instead of silently. Worth knowing: the guard was originally keyed to Codex's registration rather than to the surviving slot, and Codex's openai preset carries the x-hypaware-upstream rung, and the preset-slot race gets a regression test #537's reviewer proved the hole by adding this PR's preset to OpenClaw locally - the suite stayed green 8/8 while the misroute was live. So this exact defect would have walked straight through the pin that exists to prevent it.

For completeness: issue #536's original recommendation to add priority: 100 to Codex was wrong, and #537 declined it for exactly the reason above. This PR carries the constant that recommendation would have spread.

The ask is unchanged and small: drop priority: 100 from openaiUpstreamPreset() in hypaware-core/plugins-workspace/openclaw/src/projector.js:250. Reply here and neutral will make the change, re-run the ladder, and re-hold this PR.

@philcunliffe

Copy link
Copy Markdown
Contributor Author

Yeah that seems reasonable, continue

@philcunliffe philcunliffe removed the neutral:stuck neutral attempted this but cannot complete it autonomously — needs a human label Jul 31, 2026
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Re-engaging with your guidance - taking option (a): dropping priority: 100 from openaiUpstreamPreset(), then re-running the ladder and re-holding this PR.

… literals

`openaiUpstreamPreset()`'s `match()` falls through to `isOpenaiPath`, the
whole `/v1` tree, so it also answers `/v1/messages`. At `priority: 100` it
sorted above a TOML-configured `anthropic` (priority 0) and swallowed Claude
traffic on an ordinary install, forwarding the prompts and their `x-api-key`
/ `Bearer sk-ant-` credentials to api.openai.com.

The priority bought nothing it was needed for: at 0 the prefix-length
tiebreak puts the narrower `/v1/messages` ahead of `/v1`, and the
`x-hypaware-upstream` steering rung still reaches the preset on the
bare-origin `/chat/completions` path the steering plugin actually produces.

The test that guarded this preset asserted its literal field values, which
is why two review rounds and a triage missed the defect: `priority === 100`
was exactly what the test wanted to see. Replaced with route assertions
over the table an install really compiles (`mergeUpstreams` +
`compileUpstreams` + `matchUpstream`), covering a configured `anthropic`
with no configured `openai`, steered `/chat/completions`, and unsteered
Claude/Codex traffic. Verified by mutation: re-adding `priority: 100`
reddens three of the new tests.

`mergeUpstreams` is exported from the ai-gateway source so the test can
compile the real merged table instead of hand-building one.

Also records the config-wins seam the fix does not close (an
operator-configured `openai` replaces the preset and its steering rung, at
either priority) and refreshes the stale comment claiming Codex's
registration declares no priority.

Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Triage rung (LLP 0017) re-run on 5fbc882 after the maintainer's guidance to drop priority: 100 from openaiUpstreamPreset() was applied.

Verified, ground-truth:

  • Branch merges cleanly with origin/master (confirmed with a real git merge --no-commit --no-ff, not just merge-base --is-ancestor).
  • All eleven task/openclaw-full-capture/T1..T11 commits remain ancestors of 5fbc882.
  • npm test: 3222 pass, 8 fail, and the 8 are exactly test/core/leave-command.test.js (issue test/core/leave-command.test.js: 8 of 11 tests fail on master (exit 1 from hyp leave), red since the tests were introduced #512 / PR hyp leave: the daemon status query must answer, not throw #527), matching the known baseline.
  • npm run typecheck: clean.
  • Mutation re-verified myself: re-adding priority: 100 to openaiUpstreamPreset() reddens exactly 3 of the 18 tests in test/plugins/openclaw-projector.test.js; reverting restores 18/18. The new routingTable() helper genuinely drives the real pipeline (createGatewayState -> registerUpstreamPreset -> mergeUpstreams -> compileUpstreams -> matchUpstream), not a hand-built table — this is the property the old literal-asserting test lacked.
  • mergeUpstreams's export from ai-gateway/src/source.js is an internal-repo widening only: it sits outside the package's exports map, so no external consumer gains a new deep-import surface. Consistent with the file's existing exports.

All four residuals classified non-blocking (full evidence and reasoning posted on #536, which already tracks this PR's deferred findings — reused rather than duplicated):

  1. anthropicUpstreamPreset() still carrying priority: 100 is identity-preserving (a test now pins the two copies' priorities equal), not routing-load-bearing — confirmed directly against compileUpstreams's sort (priority, then prefix-length tiebreak, then registration order): the narrower /v1/messages prefix already wins the tiebreak regardless of the anthropic preset's own priority value.
  2. The config-wins-name seam tracked as issue A configured openai upstream drops the x-hypaware-upstream steering rung, so a steered turn routes nowhere #539 is real but requires a compound, non-default configuration (a separately-configured TOML openai upstream, e.g. via the pre-existing raw-openai picker entry, combined with OpenClaw running an OpenAI-shaped model) — OpenClaw's own picker path never triggers it, and the Anthropic side is unaffected because its client naturally hits a path plain prefix-matching already catches. Documented via A configured openai upstream drops the x-hypaware-upstream steering rung, so a steered turn routes nowhere #539 (four remediation options recorded for a maintainer decision), a passing regression test asserting current behavior, and an LLP 0161 §3.4 note.
  3. The two residuals carried from the previous triage (settlement as OpenClaw's only .hypignore gate with no second chance after resettleBatch(allowDrop: false), and the unresolved "does OpenClaw append JSONL live" open item) are unchanged by 5fbc882 — re-confirmed directly against the current tree rather than re-derived from memory, since that commit never touches settle.js, dataset.js, or the plugin manifest.

New findings and one factual staleness note (issue #536's item #1 still quotes the now-removed priority: 100 on the openai preset in its prose; its actual conclusion about deterministic activation order is unaffected) are on #536.

Follow-up: #536

@philcunliffe philcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Jul 31, 2026
@bgmcmullen

Copy link
Copy Markdown
Contributor

hyp status shows a permanent attach openclaw [pending]

Running hyp status on this branch, on a joined host:

  clients:
    - openclaw  [configured, not attached]  [local]
  client actions:
    - attach openclaw  [pending]
    - backfill @hypaware/openclaw  [done]  (0 rows, at 2026-07-31T17:15:42.889Z)
  diagnostics:
    [WARN ] client_attach_missing: '@hypaware/openclaw' is enabled but openclaw settings show no HypAware marker - run 'hyp attach --client openclaw'
        repair: hyp attach --client openclaw

Nothing is actually broken here - routing is owned by the steering plugin (LLP 0152 / 0159), and the backfill ... [done] line confirms the real path ran. But three status surfaces still report against the attach contract OpenClaw no longer participates in, and the pending can never clear.

Root cause: status derives declared-attach targets by a different rule than the reconciler.

action_attach.js's desired() skips probe-less descriptors, exactly as LLP 0143 intends:

// src/core/config/action_attach.js:114
if (!descriptor.attachProbe) continue

So the reconciler never emits an attach target for OpenClaw, perform() never runs, and no marker is ever written. But buildClientActionsReport builds declaredAttach from every enabled client descriptor on a joined host, with no attachProbe check (src/core/daemon/status.js:885-900), and then at status.js:945: no marker + declared + hasCentral -> pending. Permanently, since nothing will ever write that marker.

The doc comment above buildClientActionsReport already states the intended invariant - "pending / n/a are derived for declared targets the reconciler would act on but has not yet" - so this reads as a missed gate rather than a design choice.

Two adjacent surfaces have the same gap:

  • src/core/daemon/status.js:591 maps a probe-less descriptor to { attached: false }, so the clients row renders not attached where the honest answer is "not applicable".
  • The client_attach_missing diagnostic at status.js:601 fires on configured && !probe.attached, ungated. Its repair (hyp attach --client openclaw) resolves the adapter directly - the manual path deliberately does not gate on attachProbe - and lands on the intentional no-op in openclaw/src/index.js:174 ("This adapter no longer writes to openclaw.json..."). The manual command writes no marker either, so running the printed repair clears neither the warning nor the pending.

Not new to this PR. attach claude-desktop [pending] shows the same way, since Desktop dropped its probe first (LLP 0115 #no-attach-on-join, #444/#445). This PR just makes it a second instance.

Suggested fix: gate all three on descriptor.attachProbe, so a probe-less client is n/a rather than pending, is not rendered not attached, and does not raise client_attach_missing. That keeps status agreeing with desired() the same way readAttachPolicy / readBackfillPolicy already keep the two from disagreeing about on_join.

Happy to push that as a follow-up if you'd rather not grow this PR.

@bgmcmullen

Copy link
Copy Markdown
Contributor

Backfill imports 0 sessions against real OpenClaw session files

Ran this branch's backfill provider against a live ~/.openclaw (4 sessions, 7 files) on macOS. It scans every file and projects nothing:

files_seen: 7, sessions_projected: 0, sessions_ignored: 0, records_excluded: 18
EVENT excluded_backend  session=4ea0c49e...          provider="unknown"  record_count=14
EVENT excluded_backend  session=probe-anthropic-...  provider="unknown"  record_count=2
EVENT excluded_backend  session=probe-claude-cli-... provider="unknown"  record_count=2

hyp query sql "select count(*) from ai_gateway_messages where client_name = 'openclaw'" returns 0, and hyp status reports the client action as backfill @hypaware/openclaw [done] (0 rows), which reads as "nothing to import" rather than as a failure.

Cause: the message envelope is read one level too high

parseOpenclawSessionMessage (hypaware-core/plugins-workspace/openclaw/src/session_file.js:272) reads the fields off the top-level record:

const provider = nonBlankString(row.provider)

Real OpenClaw v3 session files nest them under message:

TOP keys:     ['id', 'message', 'parentId', 'timestamp', 'type']
MESSAGE keys: ['api', 'content', 'errorMessage', 'model', 'provider', 'role', 'stopReason', 'timestamp', 'usage']

  provider: top=None  message='anthropic'
  model:    top=None  message='claude-opus-4-8'
  api:      top=None  message='anthropic-messages'
  usage:    top=None  message={'input': 0, 'output': 0, 'cacheRead': 0, 'cacheWrite': 0, ...}

The only top-level provider in a real file sits on model_change, which the reader drops as a non-message record. So every message parses with provider: undefined, effectiveProviders has no stated value to propagate forward or backward, every record resolves to unknown, and PROJECTABLE_PROVIDERS (backfill.js:97) excludes all of them.

It fails silently because this lands exactly on the fail-closed path the doc comment describes ("A file where no record ever states a provider projects nothing, which is the fail-closed direction PROJECTABLE_PROVIDERS exists to hold"). A parse miss and an intended exclusion are indistinguishable at the log seam.

model, api, stopReason, usage, and content are on the same footing, so record.content reads in the projector need the same treatment. Worth fixing as one envelope-shape change rather than patching provider alone.

Why CI is green

test/plugins/openclaw-session-file.test.js:196 builds its fixture with role, content, provider, model, and usage flat on the record, a shape OpenClaw does not write:

JSON.stringify({
  type: 'message',
  id: 'msg-2',
  role: 'assistant',
  provider: 'anthropic',
  usage: { input_tokens: 10, output_tokens: 20 },
  content: [{ type: 'text', text: 'hello' }],
})

The suite asserts against an invented envelope, so it cannot catch this. Fixing the reader without re-grounding the fixtures will just move the failure into the tests. Suggest deriving fixtures from a real captured session file (redacted), which would also pin the usage key names: OpenClaw writes input / output / cacheRead / cacheWrite / totalTokens, not the input_tokens / output_tokens the fixture uses.

Expected result after the fix

Session 4ea0c49e carries 3 anthropic assistant turns and 4 claude-cli ones, so it should partially project, with the claude-cli turns still excluded and labelled covered_by: claude_transcript. That makes a good regression case, since it exercises both sides of the allowlist in one file.

Note on the two probe sessions

probe-anthropic-* and probe-claude-cli-* are attach-probe artifacts. Worth deciding explicitly whether backfill should import them or skip them by session-id prefix, independently of this bug.

@philcunliffe
philcunliffe merged commit b54bb8c into master Jul 31, 2026
9 checks passed
@philcunliffe
philcunliffe deleted the integration/openclaw-full-capture branch July 31, 2026 17:49
philcunliffe added a commit that referenced this pull request Aug 3, 2026
…p, json_path revival (#570)

* Design: OpenClaw two-lane capture (LLP 0172)

* Plan: OpenClaw two-lane capture executable tasks (LLP 0173)

* openclaw config: validate sweep_cron and quiesce_ms in backfill section

validateBackfillSection now accepts sweep_cron (a 5-field cron
expression, validated with core's shared isCronExpression grammar so a
malformed schedule is rejected the same way a sink's config.schedule
is) and quiesce_ms (non-negative integer) alongside the existing
on_join/window_days keys, added together so the unknown-key rejection
loop recognizes both from the same merge.

Task-Id: T6

* Restore json_path attach-probe format, add sweep field to BackfillContribution

hypaware-plugin-kernel-types.d.ts: PluginAttachProbeManifest.format regains
'json_path' (removed by LLP 0143 after #212's orphaning danger), plus the
new container_path, provider_keys, and cache_glob fields the format needs,
reusing the existing marker_header. The comment at the removal site is
revised, not deleted: it now explains that the runtime support LLP 0173
T2/T3 add closes the gap #212 warned about, so a manifest can only declare
this format once both sides exist.

BackfillContribution gains an optional sweep?: { cron: string } field for
the daemon's periodic sweep (LLP 0173 T9), absent-by-default for every
existing contribution.

Both additions are purely additive: npm run typecheck (tsc --noEmit over
the whole tree, including hypaware-core/plugins-workspace/openclaw) and
npm test pass unchanged, proving no existing consumer's typecheck shifted.

Task-Id: T1

* OpenClaw attach writes the two provider overrides, refusing to merge

LLP 0169 reverses LLP 0152's premise: there is a real, reversible
settings write for OpenClaw again, so the adapter's honest no-op
attach() has nothing left to be honest about.

New hypaware-core/plugins-workspace/openclaw/src/attach.js exports
createOpenclawAttach({homeDir, env, fs}), mirroring the Claude adapter's
attach() shape (same AiGatewayClientAttachContext, same
withSpan('client.attach', ...), same dry-run branch). It reads
openclaw.json through the one core settings-path seam (so $OPENCLAW_HOME
relocation resolves the same file the manifest's probe will), refuses
with {status:'failed', reason} when models.providers.anthropic or
.openai is already there, and otherwise writes both entries whole from
attachCtx.endpoint: bare origin for anthropic, endpoint + '/v1' for
openai, each with the x-hypaware-upstream marker header and the
mandatory empty models array.

The refusal check runs entirely before the single atomicWriteFile, so
there is no partial write to roll back, and it returns rather than
throws, which is the whole mechanism by which a refuse during
attach-on-join warns instead of failing the join. Every other key in
openclaw.json is carried through by reference. Both output modes end
with the 'openclaw gateway restart' instruction, since a --json caller
is as blocked on the restart as a human is.

index.js drops the no-op body, STEERING_PLUGIN_NAME,
ROUTING_OWNED_BY_STEERING_PLUGIN_MESSAGE and the @ref LLP 0143#decision
block, and wires activate() to the new effect. The registered attach()
keeps the kernel's Promise<void> contract, so the outcome object is
dropped there on purpose: both callers already derive success from a
throw plus the one-line JSON the effect writes.

Tests cover the refusal (including that the file is byte-identical
after it, and that it never throws), the exact two-entry shape with the
bare-origin/+v1 asymmetry, key preservation, the restart instruction on
both output modes, dry-run, $OPENCLAW_HOME, and the missing/malformed
config hard failures. The two attach tests in
openclaw-client-registration.test.js are retargeted at the new behavior
so the suite stays green; T10 owns that file's fuller rewrite.

Task-Id: T4

* daemon/status.js: restore json_path attach-probe read branch

Restores the probe.format === 'json_path' read branch removed by LLP
0143 / PR #510, parallel to the existing json/toml branches in
probeClientAttachFromDescriptor: navigate container_path + each
provider_keys entry, read headers[marker_header], and report attached
when it equals the expected provider key for at least one configured
key. Pure read, no ownership/backup concerns.

Task-Id: T3

* openclaw backfill: quiesce window skips recently-modified session files

listSessionFiles(agentsDir) gains an optional quiesceBeforeMs cutoff, and
runOpenclawBackfill() computes it once per run as Date.now() - quiesceMs.
quiesceMs resolves from the plugin's own config.backfill.quiesce_ms,
defaulting to 180000ms (QUERY_FLUSH_DEBOUNCE_MS plus a one-minute margin),
so a run never imports a session file OpenClaw is still mid-write on, or a
settlement pass is still mid-flush against. Composes with the existing
effectiveProviders/partitionByBackend CLI-backend logic (R10) rather than
replacing it.

Task-Id: T8

* Lane B sweep metadata: openclaw backfill provider + narrowed runner ctx

createOpenclawBackfillProvider now populates the contribution's opt-in
sweep field from config.backfill?.sweep_cron, defaulting to every 5
minutes when absent (LLP 0172#lane-b-sweep, R7).

src/core/commands/backfill.js's runBackfillProvider, runProvider, and
resolveOwnersForRun now declare a new BackfillRunnerContext interface
(env, config, storage, backfills, backfillMaterializers) instead of the
full CommandRunContext, a pure structural narrowing so the daemon-side
sweep driver (LLP 0173 T9) can build one without assembling registries
it never uses. Existing hyp backfill CLI-path and onboarding-finale call
sites keep typechecking and passing unchanged.

Task-Id: T7

* json_path detach returns: ownership, backup-not-discard, best-effort cache purge

LLP 0143 pulled the `json_path` branch out of the disk-driven undo because
LLP 0152 left nothing on disk for it to reverse. LLP 0169 reverses that
premise, so the branch comes back - reshaped for the two provider entries
attach now writes, not the single shadow provider of the old design.

`detachJsonPathProviders` judges each `provider_keys` entry on what it points
at, because this format has no HypAware-owned marker to replay: its undo
record IS the entry. Ours (the gateway's own `baseUrl`, in either the bare
origin or `+ /v1` spelling, with `marker_header` naming its own key, in the
shape attach produces) is deleted. Anything else present at our key is backed
up to a `_hypaware_detach_backup.<key>` sibling inside the same container
before the live key goes, following the `prev_malformed` precedent of LLP
0163: never discard a value HypAware did not write. That closes the
json/toml-vs-json_path asymmetry LLP 0163 flagged as worth its own look,
converging on the outcome without the top-level marker key LLP 0163 correctly
ruled out for this client.

The derived caches (`cache_glob`, relative to the client's config home) are
then purged of the same keys, best-effort: they do not self-heal, so a
partial purge beats none, and one unreadable cache file is logged and skipped
rather than failing a detach whose settings half already landed.

An unknown gateway base URL has no safe default here - guessing either way
silently deletes a foreign value or reports a finished detach over a client
still routed at a dead port - so it refuses (`EXPECTED_BASE_URL_UNKNOWN`).
Both callers degrade correctly: `reverse()` keeps the marker, `hyp detach`
prints the reason.

Both real callers thread the base URL: `detachClientViaCore` resolves it
through the same three rungs manual attach already walks (live
`localEndpoint()`, configured `listen`, the daemon's persisted bound port),
every one optional so detach keeps working with the gateway capability
unloaded; `reverse()` passes the `ctx.endpoint` `perform()` attached with.

Task-Id: T2

* OpenClaw manifest: restore json_path attach_probe, retire steering-plugin copy

hypaware.plugin.json gains contributes.client.attach_probe (design 1.4:
json_path format, .openclaw/openclaw.json settings file, models.providers
container, anthropic/openai provider keys, x-hypaware-upstream marker
header, agents/*/agent/models.json cache glob). description and
picker[0].summary drop every @hypaware/openclaw-steering-plugin reference
and state the two capture tiers directly: live gateway capture once
attached, plus a periodic transcript sweep.

Claude's manifest gains the LLP 0167#onboarding line naming the
claude-cli/<model> case OpenClaw's CLI-backend exclusion produces, so a
user knows which picker entry an OpenClaw-routed Claude Code session
belongs to.

projector.js's UPSTREAM_HEADER comment no longer credits the deleted
steering plugin; it now describes the config-override write attach()
itself makes.

Adds a manifest-shape test asserting attach_probe parses to the exact
design-1.4 fields and that description/summary no longer match
/openclaw-steering-plugin/. Updates the one existing assertion this
manifest change makes false (the R7 "no attach_probe" descriptor check)
to the restored json_path shape; the remaining behavioral rewrites of
that test file are T10's scope.

Task-Id: T5

* Delete openclaw-steering-plugin/ (LLP 0172 Section 5, R9)

Removes openclaw-steering-plugin/ in full (src/, test/, package.json,
openclaw.plugin.json, .d.ts files) and test/plugins/openclaw-steering-plugin.test.js:
Lane A's config-override entries make OpenClaw route to the gateway on
its own, so the credential-borrowing runtime auth shim, the live
wire-parity mirror, the steering decision logic, the live warning
ledger, and the gateway endpoint resolver that fed them no longer have
a purpose.

Also drops tsconfig.json's stray "openclaw-steering-plugin" include
entry (line 19), not named in the design's own deletion inventory but
found verifying the deletion against the real tree; leaving it would
have left a dead include path.

docs/ACCEPTANCE.md and test/plugins/openclaw-manifest.test.js still
mention the package name (an onboarding rewrite reserved for T13, and
a T5 regression test asserting the manifest no longer references it,
respectively); neither is in this task's file list.

Task-Id: T11

* openclaw-client-registration.test.js: finish T4/T5's deferred rewrite

T4 and T5 already retargeted the two attach() no-op tests and the
descriptor's attach_probe assertion to keep the suite green while they
landed; this closes the two pieces both left for T10:

- The "honest no-op" detach test's comment still credited the retired
  R7 no-attach_probe guard. Since T5 restored the manifest's json_path
  attach_probe, the no-op this test actually observes on a fresh temp
  HOME is detachClientFromDisk's absent-settings-file guard instead.
  Corrected the comment to say so.
- Added a companion case that stages a real openclaw.json via the
  actual createOpenclawAttach() effect, then drives the same hyp
  detach CLI entry point (buildClientDescriptorMap's real manifest
  descriptor -> detachClientFromDisk's json_path branch) and asserts
  the ownership-based detachJsonPathProviders (T2) actually fires:
  changed:true, the removed baseUrl, and both provider entries gone
  from the file while everything else in it is untouched.

Task-Id: T10

* Daemon sweep driver: run sweep-bearing backfill providers on the tick loop

New `src/core/daemon/backfill_sweep.js`. `createBackfillSweepDriver({backfills,
backfillMaterializers, env, config, storage})`'s `tick({now})` walks
`backfills.list()`, skips any contribution with no `sweep` field or a cron that
is not due (`cronMatches`, the sink driver's own due-check), and fires
`runBackfillProvider` per due contribution with a `sweep-<name>-<now>` dev run
id. Runs are fired unblocked: `tick()` resolves once each run has been started,
never once one finishes, so a provider's transcript scan cannot stall the sink
snapshots, the source-detail refresh, or `persist()` later in the same tick. Both
settlements are handled, so a failing run is a logged `backfill.sweep_failed`
record (component `openclaw`, operation `backfill.sweep`, `error_kind`) rather
than an unhandled rejection that would take the daemon process down. A malformed
`sweep.cron` is logged and treated as not due rather than thrown, so one
provider's bad metadata cannot skip the rest of the list.

`runtime.js`'s `runTick()` calls `await sweepDriver.tick({now})` directly after
the existing sink-driver tick, riding the same `DEFAULT_TICK_INTERVAL_MS`
60-second loop: a `*/5 * * * *` schedule only needs a due-check once a minute, so
this opens no second timer to start, drain, and account for at shutdown.

Also repairs the typecheck this task's branch point already failed: T7's
`sweep: { cron: opts.config?.backfill?.sweep_cron ?? ... }` does not compile,
because the plugin's config slice is a `JsonObject` and every step below its root
is a `JsonValue`. Read through a `resolveSweepCron` mirroring the
`resolveQuiesceMs` helper already sitting beside it; behavior is unchanged.

Externally blocked for real capture: until PR #552 (issue #543) merges, the
LLP 0158 reader still reads OpenClaw v3 fields flat, so a sweep projects nothing
from a real transcript. These tests passing is not evidence that it does.

Tests: the due-check fires only sweep-bearing, cron-due contributions and builds
the narrowed `BackfillRunnerContext` from the daemon's own runtime fields; a
rejected run neither throws out of `tick()` nor lands as an unhandled rejection,
and a never-settling run does not block the tick. A separate wiring test boots a
real daemon with a fixture plugin whose contribution opts into a sweep and proves
the tick actually runs it, which no unit test of the driver can show.

Task-Id: T9

* docs/ACCEPTANCE.md: rewrite openclaw_capture for two-lane capture

Drops the steering-plugin link/enable setup and the
before_model_resolve/hooks.allowConversationAccess version-gate language
(the plugin is deleted; Lane A depends on no OpenClaw hook API). Adds a
setup step running `hyp attach --client openclaw` followed by the restart
instruction it prints, a sweep step (detach to strip the live route,
confirm the row is absent, confirm it lands within one sweep interval past
the quiesce window), and a zero-duplicate assertion (a turn both lanes
observe resolves to exactly one row for its part_id, proven against the
daemon's own scheduler rather than a manual `hyp backfill`). Re-confirms
LLP 0167#verify-results items 1, 3, and 4 against the current tree's
attach/detach behavior instead of assuming them. Drops the retired
deferred-provider-family warning-ledger step (LLP 0171 retires R13; no
ledger) and the shadow-provider-id failure mode (Lane A overrides the
existing anthropic/openai entries, it does not register new ids).

States in the section's own Requires that the sweep/dedupe steps need PR
#552 merged (the LLP 0158 reader still parses OpenClaw v3 flat), and the
client_attach status-row re-confirmation needs PR #553 merged (a
now-probed openclaw otherwise falls back to pre-#553 status behavior).

This is a doc; the test is a human's successful run against a real
OpenClaw install, which this change cannot perform. It is specified
against what T2 (detach), T4 (attach), and T5 (manifest) actually
implement in this tree, read directly from
hypaware-core/plugins-workspace/openclaw/src/attach.js,
src/core/config/client_detach_disk.js, and
hypaware-core/plugins-workspace/openclaw/hypaware.plugin.json.

Task-Id: T13

* Add backfill_openclaw_fixture hermetic smoke for Lane B sweep

New backfill_openclaw_fixture.js under hypaware-core/smoke/flows,
mirroring backfill_claude_fixture.js / backfill_codex_fixture.js: writes
a minimal OpenClaw v3 session JSONL in the nested-message-envelope shape
(PR #552's reader) under a temp agents/<id>/sessions/ tree with a
controllable mtime, drives the real createBackfillSweepDriver (T9)
through a cron-due tick, and asserts (a) a file inside the default
180000ms quiesce window is skipped, (b) a file backdated past it is
captured with native message identity, and (c) rerunning the sweep on a
later cron-due tick (forcing a fresh devRunId, so the ai-gateway
materializer's dedupe genuinely re-scans committed partitions) nets zero
new rows for the already-written part_ids.

Driving a real, non-dry-run sweep write for the first time (T9's own
tests only ever exercised a mocked runBackfill seam) surfaced a latent
bug: writeRows/flushDataset read ctx.query, which BackfillRunnerContext
never carried and the daemon's createBackfillSweepDriver(...) call never
supplied, so any real sweep write actually crashed on
"Cannot read properties of undefined (reading 'getDataset')" in both the
smoke and the real daemon path. Threaded query through
BackfillRunnerContext, BackfillSweepDriverOptions,
createBackfillSweepDriver, and the daemon's sweepDriver construction, and
updated LLP 0172's field enumeration and ctx samples (Sections 4.3/4.4)
to match. Extended the existing T9 unit tests
(test/core/daemon-backfill-sweep.test.js) to cover the new required
field and its passthrough.

Task-Id: T12

* openclaw-backfill.test.js: backdate fixture mtimes to close a quiesce-window race (#570)

listSessionFiles compares stat.mtimeMs <= Date.now() even when a test sets
config.backfill.quiesce_ms: 0 to opt out of the quiesce gate for something
unrelated: 0ms only removes the margin, not the comparison. A fixture
written moments earlier could race the provider's own later Date.now() call
across two different clocks and occasionally lose, projecting 0 items
instead of 1. Confirmed non-deterministic: PR #570's commit 0c62a21 produced
both a green and a red `test (24)` CI run from the identical commit.

writeSession now backdates every fixture's mtime by a small, fixed margin
(FIXTURE_MTIME_MARGIN_MS), comfortably clearing the race while staying far
below the real 180000ms default quiesce window, so the tests that rely on
genuine freshness against that default are unaffected. The one test that
writes its session file outside writeSession (the OPENCLAW_HOME relocation
test) gets the same backdate applied directly.

* Review round 1: make an attach refusal observable, and three smaller fixes

Finding 1 (major). The registered `attach()` wrapper discarded the effect's
`OpenclawAttachOutcome`, and both callers infer success from "did it throw",
so a refusal recorded a `done` marker whose endpoint and assets_key matched
forever: the join never retried even after the user cleared the conflicting
`models.providers` entry, while the json_path attach probe kept reporting
`not attached`. `hyp attach --client openclaw` printed the refusal and exited
0. LLP 0172 1.3 is authoritative (it promises the `{status:'failed', reason}`
outcome is recorded and retried), so the wrapper now rethrows a failed
outcome: `perform()`'s catch turns it back into that shape (recorded, warned,
retried, the join's other actions untouched) and `runClientLifecycle`'s catch
makes it exit 1. Rethrowing at the wrapper rather than teaching `perform()` to
parse the adapter payload is what fixes both callers, since the CLI hands the
adapter `ctx.stdout` directly and captures nothing to inspect. LLP 0172 1.3
gains the translation step it left implicit; the test that locked in the
swallow now asserts the retryable failure, plus the reconciler and exit-code
halves.

Finding 2. `clientConfigHome` took the first segment of the settings path's
home-relative form, which is not the config home when `$OPENCLAW_HOME` is
nested inside `$HOME`: the cache glob then matched nothing and the purge
silently no-opped while the settings half reported success. Derive it by
stripping the manifest's own `settings_file` tail instead, the exact inverse
of what `resolveClientSettingsPath` joined on. Regression test uses a
two-segment `OPENCLAW_HOME`.

Finding 3. `listSessionFiles`'s JSDoc claimed the CLI path runs unfiltered.
It does not: `runOpenclawBackfill` computes the quiesce cutoff on every run,
and `run()` is the single entrypoint for the CLI, the onboarding finale, and
the sweep. Name `plan()` as the only unfiltered caller. No behavior change.

Finding 4. The sweep fired a due provider with no record of what was still
running, so a pass outliving its cron interval got a second concurrent run
against the same datasets and mid-flush spool. Add the `maintenanceInFlight`
guard shape, widened to a Set because the driver fires one run per provider,
with a `backfill.sweep_skipped` / `already_running` record and clearing on
both settlements. LLP 0172 4.4 states the re-entrancy rule it had left out.

Co-Authored-By: Claude <noreply@anthropic.com>

* Review round 2: thread the plugin config, make attach idempotent, name the sweep's component

Three review findings, each with a doc edit in the same commit.

1. `sweep_cron` and `quiesce_ms` were validated then discarded. `activate()`
   built the backfill contribution without passing `ctx.config`, so both keys
   this PR adds to `validateBackfillSection` resolved to the hardcoded
   `*/5 * * * *` / 180000ms defaults at runtime, with no diagnostic. The
   existing unit tests handed `config` straight to the factory, so the missing
   wiring was invisible to them; the new test starts from an activation.

2. `attach()` refused on bare key presence, so it was not idempotent over its
   own output. This PR's manifest `attach_probe` is what makes openclaw
   eligible for attach-on-join, and `isCurrent()` re-performs attach on an
   ephemeral-port rebind (LLP 0086) or an asset-set change (LLP 0107). Every
   re-perform then refused: the marker churned to `failed`, `hyp attach
   openclaw` exited 1, and `openclaw.json` stayed pinned to the dead port
   while the marker-header probe still reported `attached: true`.

   The refusal is now ownership-aware, on the self-identifying triple detach
   already tests before deleting. `isOwnedProviderEntry`/`ownedBaseUrls` move
   out of `client_detach_disk.js` into a shared
   `src/core/config/provider_entry_ownership.js` so the two halves cannot
   disagree about the same file. Attach passes no base-URL set (on a drift
   re-attach its own entry carries the old origin); detach still passes one,
   because there the wrong answer deletes a value HypAware never wrote.
   Everything that fails the test still refuses, including `null`, a foreign
   entry, and a hand-edited one that merely kept the header.

3. The generic sweep driver stamped `component: 'openclaw'` on all five of its
   records while logging as `backfill-sweep`. It fires any contribution
   carrying a `sweep` field, so a second opt-in would have been misattributed.
   `component` now names the emitting module; plugin identity already rides
   `hyp_plugin` and `provider`.

Docs updated to match: LLP 0167#attach-detach, LLP 0169's decision bullet and
summary, LLP 0171 R2, LLP 0172 sections 1.2 and 2.2, LLP 0173's implementer
note on the sweep's telemetry pair.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: test <test@test.com>
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants