#203 has been seen four times, always on Linux CI, never once on macOS across 1000+
runs in two independent experiments. The documented next step is a Linux + Node 24
reproduction, and there is currently no way to run one without reddening an unrelated
PR and waiting for chance.
workflow_dispatch only — it never runs on push or pull_request, so it costs nothing
until someone asks for it.
Inputs are the two suspected variables:
- `node` is a choice (24 / 22 / 26) rather than pinned, because 22-vs-24 is a
comparison worth running deliberately. A previous Linux VM attempt was invalidated
by Node 22's `node:sqlite` ExperimentalWarning landing on stderr, which the boot gate
read as "server did not start" — ~23 of 50 runs failed spuriously. The workflow says
so, so the next person interprets a 22 result against that confound instead of
rediscovering it.
- `concurrency` is the knob that actually reproduces, not round count: test() is
fire-and-forget for async bodies, so ONE suite run already spawns 15 concurrent
server.mjs children across 11 ltBoot tests. Several suites at once is what multiplies
cross-process contention.
Classification is anchored on the failure marker AND the test name, which is not
cosmetic. ltDiag samples the first 900B of child stdout (#204) — the boot banner — so a
log where only the GATE test failed still contains "Local tools: ON". Validated against
real logs rather than reasoned about: 3 clean runs + 1 gate-mutation run gave
unanchored 'Local tools' -> 1 hit, attributed to the WRONG category
anchored -> gate=1, announce=0 (correct)
totals -> total=4 clean=3 (correct)
My first draft had the unanchored form with a comment claiming the failure mode was
"matches every log". That was wrong — the real defect is cross-category attribution,
caused by the diagnostic quoting the child's own output. Found by running the logic on
real logs; the comment now states the measured reason.
The job does NOT fail on a reproduction: catching the flake is the goal, and a red X
would read as "the hunt is broken" rather than "the flake was caught". Results go to
the step summary; logs upload as an artifact.
server.mjs: unchanged (CI-only; ALIGNMENT.md requires no cli.js citation).
Verified: YAML parses, all three run blocks pass `bash -n`, classify logic exercised
against real suite output.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8
* feat(models): align maxTokens with the CLI registry (#195)
Every Opus entry and claude-sonnet-5 go 16384 -> 64000; claude-sonnet-4-6 and
claude-haiku-4-5 go to 32000 (haiku was 8192). These are the
max_output_tokens.default each model declares in the compiled CLI 2.1.220
registry, extracted id-anchored:
claude-opus-5 max_output_tokens:{default:64000,upper:128000}
claude-opus-4-8 max_output_tokens:{default:64000,upper:128000}
claude-opus-4-7 max_output_tokens:{default:64000,upper:128000}
claude-opus-4-6 max_output_tokens:{default:64000,upper:128000}
claude-sonnet-5 max_output_tokens:{default:64000,upper:128000}
claude-sonnet-4-6 max_output_tokens:{default:32000,upper:128000}
claude-haiku-4-5 max_output_tokens:{default:32000,upper:64000}
This is not cosmetic metadata. OCP itself never enforces maxTokens — server.mjs
does not read it — but it is propagated to OpenClaw (setup.mjs,
scripts/sync-openclaw.mjs), and OpenClaw CAPS the outbound request with it:
maxTokens = Math.min(baseMaxTokens + thinkingBudget, modelMaxTokens)
with reasoning budgets of medium 8192 / high 16384, and a clamp
`if (maxTokens <= thinkingBudget) thinkingBudget = maxTokens - 1024`.
So at `high` reasoning against a model declared at 16384: maxTokens resolves to
16384, the clamp fires, thinking takes 15360, and roughly 1024 tokens are left
for the visible answer. That is what this repo shipped for every model until
now, and it is the actual harm — not the advertised-vs-real mismatch.
ocp-connect keeps its own family-prefix table (/v1/models does not expose
maxTokens, so it cannot derive them). Set to the family FLOOR — opus 64000,
sonnet 32000 because sonnet-4-6 is 32000 while sonnet-5 is 64000, haiku 32000 —
because prefixes cannot distinguish versions and under-advertising merely caps
a client lower than the model allows, whereas over-advertising would promise
capacity a family member does not have. Commented in place.
New test asserts the PRINCIPLE, not the numbers: every maxTokens must exceed
OpenClaw's `high` thinking budget so an answer still fits. A value-pinning test
would need editing on every model addition and would not explain itself.
Mutation-proven: reverting one entry to 16384 fails with
"claude-opus-5 declares maxTokens=16384 <= 16384: at OpenClaw's 'high'
reasoning level the thinking budget would consume all but ~1024 tokens".
Owner-approved decision (option: align with registry defaults). Tradeoff
recorded in CHANGELOG: longer answers, correspondingly higher per-request quota
burn on long generations.
Verified: bash -n on ocp-connect, and py_compile on its embedded python block
(lines 101-334) so the comment sits at a valid indentation.
Tests: 458 passed, 0 failed (457 + 1).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8
* fix(models): rewrite the maxTokens rationale — my justification was factually wrong
The numbers were right, the code was right, and the reason I gave for both was
wrong. Verified every part of the review's finding first-hand before rewriting.
WHAT I CLAIMED: OpenClaw caps outbound requests via
`maxTokens = Math.min(baseMaxTokens + thinkingBudget, modelMaxTokens)`, so a
16384 declaration at `high` reasoning left ~1024 tokens for the visible answer,
and raising it would produce longer answers and higher quota burn.
WHY THAT CANNOT HAPPEN FOR OCP — three independent breaks, each confirmed here:
scripts/sync-openclaw.mjs:75 api: "openai-completions"
The clamp I cited lives behind `case "anthropic-messages"`; the
openai-completions transport has no thinkingBudget concept at all, so that code
never runs for a claude-local provider.
buildCliArgs pushes: --input-format, --disallowedTools, --allowedTools,
--dangerously-skip-permissions, --mcp-config
No output-token flag of any kind reaches the CLI. OCP cannot cap output even if
it wanted to.
grep max_completion_tokens server.mjs lib/ -> 0 occurrences
That is the field OpenClaw would send to a local openai-completions endpoint,
and this repo never reads it.
So "expect longer answers and higher per-request quota burn" was a promise that
would not have materialised. That is worse than a wrong number: a release note
that tells operators to expect a behavioural change which cannot occur.
The change still stands, on the honest reason: models.json is the SPOT (ADR
0003) and its values should be true. maxTokens is ADVERTISED metadata consumed
only by clients that choose to honour it. CHANGELOG and the test comment now
say exactly that, and say plainly that OCP behavior is unchanged.
Test replaced, not just re-worded. The old one asserted `maxTokens > 16384`,
which review showed lets every entry sit at 16385 and still call itself
"registry-aligned" — the actual claim had no coverage. Now pinned per model
against a recorded table of CLI 2.1.220 id-anchored values, with an explicit
failure for any model missing a row. Mutation-proven on both holes:
all entries at 16385 -> CAUGHT (the old test passed this)
sonnet-4-6 wrongly at 64000 -> CAUGHT
Also fixed ocp-connect's unknown-id fallback (8192 -> 32000). Review flagged it
as contradicting the invariant: 32000 is the LOWEST max_output_tokens.default
in the registry, so it is the safe floor and no longer sits below every family
in its own table. Unreachable today, wrong to leave inconsistent.
Verified: bash -n on ocp-connect and py_compile on its embedded python block.
Tests: 458 passed, 0 failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8
* fix(ocp-connect): revert the unknown-id fallback to 8192 — 32000 over-advertised
The previous commit raised ocp-connect's unknown-id fallback from 8192 to 32000 on
the stated grounds that 32000 was "the LOWEST max_output_tokens.default in the CLI
2.1.220 registry". That is false, and the change it justified made this PR violate
its own thesis.
Pairing-free enumeration of the compiled registry (17 occurrences, no grep-window
artifacts):
7 max_output_tokens:{default:64000,upper:128000}
5 max_output_tokens:{default:32000,upper:64000}
2 max_output_tokens:{default:8192,upper:8192}
2 max_output_tokens:{default:32000,upper:32000}
1 max_output_tokens:{default:32000,upper:128000}
distinct defaults: 8192 / 32000 / 64000 GLOBAL MINIMUM: 8192
The floor is 8192, held by claude-3-5-haiku and claude-3-5-sonnet — and those are
exactly the ids that reach the fallback, since "claude-3-5-*" matches none of the
family prefixes. So the raise over-advertised the only two real models the branch
serves, by 4x, in a PR whose entire claim is that advertised metadata should be true.
Verified by exercising the real classifier (verbatim source slice, not a replica):
claude-3-5-haiku 8192 FALLBACK registry 8192 exact
claude-3-5-sonnet 8192 FALLBACK registry 8192 exact
claude-opus-5 64000 family registry 64000 exact
claude-sonnet-4-6 32000 family registry 32000 exact
claude-haiku-4-5-20251001 32000 family registry 32000 exact
claude-sonnet-5 32000 family registry 64000 under (safe)
Also in this commit:
- The family table's comment said "FAMILY FLOOR of the CLI registry". It is the floor
over each family's members CURRENTLY IN models.json, not over the registry family:
claude-opus-4-0 / -4-5 are opus-family at 32000, so the opus row would over-advertise
them 2x. Unreachable today (ocp-connect derives ids from /v1/models, i.e. models.json),
but the comment now states the real scope and what to do if a legacy opus is added.
- The test table keyed haiku on "claude-haiku-4-5-20251001" while telling the reader to
extract values id-anchored. That id has ZERO id-anchored hits: the registry record is
id:"claude-haiku-4-5" and the dated string appears only as its provider_ids.first_party
(10 bare-string hits). Following the instruction on that row returns nothing, which is
precisely what pushes a reader back to the bare-string search this test exists to
prevent. The row now carries the registry id.
Reviewer credit: the reviewer that found this also retracted its own earlier finding,
which is what produced the 32000 fallback in the first place. Both directions verified
independently here before acting.
server.mjs: unchanged by this commit (ALIGNMENT.md requires no cli.js citation).
Suite: 462 passed, 0 failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8
* docs(ocp-connect): my unreachability reason was wrong — state the invariant that holds
Review of cb87b82 approved the change and flagged the new comment's reasoning. Verified
both entry points it named; both are real.
I wrote that claude-opus-4-0/-4-5 are unreachable because "ocp-connect derives ids from
/v1/models, i.e. models.json". Neither half is reliable:
- base_url is user-supplied (ocp-connect:510 builds it from arguments), so /v1/models is
the REMOTE server's models.json — a different OCP version, or a fork, not this checkout.
- on a JSON parse failure the ids come from a hardcoded three-id list (:114), not from
/v1/models at all. Checked all three: opus-4-6 -> 64000, sonnet-4-6 -> 32000,
haiku-4 -> 32000. All exact against the registry, so safe today — but it is a genuine
non-SPOT entry point that my sentence denied existed.
The invariant that actually holds is narrower: no released OCP has ever served a legacy
opus id. Same conclusion, and now it names the thing that would break it.
This is the second reasoning defect in this PR where the number was right and the
justification was not. Recording it in the comment rather than quietly fixing it.
Out of scope, noted for a future touch: the bare `except:` at :114 catches BaseException,
so a KeyboardInterrupt mid-parse silently yields the fallback list.
server.mjs: unchanged (ALIGNMENT.md requires no cli.js citation).
Suite: 462 passed, 0 failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test: harden and instrument the ltBoot integration tests (#203, #199)
I could NOT reproduce either flake, and this commit does not claim to have
root-caused them. Stating that plainly up front, because a "fix" for an
unreproduced fault is a placebo unless you say what it actually is.
What I tried, all negative:
- 150 targeted runs of the #203 boot-gate scenario in isolation (25 rounds x
3 cases, with and without 8 CPU hogs) -> 0 failures
- 6 consecutive full-suite runs -> 0 failures
- a direct test of the exit-before-stdio-drain hypothesis (40 spawns writing
a stderr burst then exiting) -> 0 races
- a direct test of the port-clash hypothesis: with the port already held, the
boot gate still fires FIRST and stderr still carries the FATAL, so a clash
cannot produce the observed empty-stderr signature
So instead of guessing a cause, this removes the mechanisms that were
individually defensible anyway, and makes the next occurrence self-diagnosing.
1. Wait for 'close', not 'exit', wherever a test reads buf.err/buf.out after
the child terminates. 'exit' fires on process termination while its stdio
pipes may still hold unread data; 'close' is the event that guarantees both
are drained. I could not get this to race locally, but the ordering is
simply correct and costs nothing — and it exactly matches #203's signature
(non-zero exit, EMPTY stderr).
2. Stop gating on a proxy signal. The #199 test waited for "listening on" and
then asserted a DIFFERENT line ("ignored in TUI mode") that is written
independently — a race by construction. It now waits for the line actually
under assertion, still bounded by the same timeout, with the boot markers
kept in the predicate so a failed boot ends the wait immediately.
3. Every fixed port is gone. All 10 ltBoot sites and their 8 matching ltPost
calls now use ltFreePort(). #193 already hit a fixed-port variant of this,
and 39330+i in particular was shared across a 3-iteration loop.
4. ltBoot now tracks signal/closed/spawnErr, and an 'error' listener is
attached — without one, a spawn error is re-thrown as an uncaught exception
and takes down the whole runner instead of failing one test.
5. New ltDiag(buf) is used in every assertion message. The historical failure
text was `expected a local-tools FATAL, got: ` — an empty string, which
distinguishes nothing. Both mutations below now produce an immediately
readable cause:
gate neutered -> "[non-loopback] process never closed — exit=undefined
signal=undefined closed=false | stderr(92B)=... |
stdout(1147B)=..." (it booted; the gate is gone)
warning renamed -> "no inert-flag warning appeared — ... stderr(295B)=
\"⚠ OCP_LOCAL_TOOLS=1 is SILENCED-FOR-MUTATION ...\""
(the text changed, and you can see the new text)
Both target tests remain mutation-proven: neutering the non-loopback branch of
localToolsSafetyError fails the boot-gate test, and renaming the TUI inert
warning fails the inert test.
Stability after the change: 4 sequential + 3 concurrent full-suite runs, all
457/0. That shows no regression was introduced; it does NOT show the flakes are
gone, since I never reproduced them. #203 and #199 should stay open until a
run passes through this instrumentation and reports what it saw.
Tests: 457 passed, 0 failed. No production code touched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8
* test: fix the adjacent race the first pass missed, plus four review corrections
The review's most valuable finding is that my SEARCH was incompetent, not just
inconclusive. test-features.mjs:39-58 makes `test()` fire-and-forget for async
bodies, so ~15 ltBoot integration tests run CONCURRENTLY inside one suite run.
My "150 targeted runs in isolation" therefore removed the only condition that
produces the race. Under full-suite x 4-way concurrency the reviewer measured
#199 at 6/200 pre-PR and 0/400 post-PR (Fisher p ~ 0.001).
HIGH — I fixed one race and left its twin one line away (:1092).
`Local tools: ON` is written 14 console.log calls AFTER `listening on`, so
gating on the boot marker and then asserting the announcement reads a buffer
that may hold only the first chunk. Identical shape to #199, same pipe this
time. Measured 9/200 before AND 9/200 after my first pass — my PR did not touch
it, leaving it as the suite's top flake, and it reddens with a message that
looks exactly like a real regression. Now waits for the line under assertion;
review measured the same fix at 0/200.
MED — ENOTEMPTY (4/200). child.kill("SIGKILL") kills server.mjs but not the
fake `claude` grandchildren, which can still be writing into `dir` while rmSync
walks it. New _ltRmRetry uses Node's maxRetries/retryDelay and never lets a
leaked temp dir fail a test. Applied to all 10 lt call sites.
LOW — boot-gate wait was `buf.closed` while the TUI one was
`... || buf.spawnErr`; made symmetric so a spawn failure cannot become a 9s
hang later. ltFreePort moved above its first use (it worked only via hoisting).
Two claims of mine that were wrong, corrected in the PR body:
- "ltDiag is used in EVERY assertion message" — it is used in the two target
tests, 7 sites. Overstatement in a PR whose whole argument is scope honesty.
- "39330+i was shared across a 3-iteration loop" — it was not; i is 0/1/2 so
the ports differ. The real fixed-port risk is CROSS-PROCESS, which the
review's data confirms: 21/200 EADDRINUSE, all on 39360/39361/39364.
Linux experiment for #203, run on the Oracle VM against pre-PR main:
#199 reproduced at 25/50 (vs ~3% on macOS) — platform-dependent stdout/stderr
delivery, exactly the mechanism the review identified.
#203 did NOT reproduce, but that result is NOT usable: Oracle runs Node 22,
whose SQLite ExperimentalWarning broke ~23/50 runs with spurious "server did
not start" failures. The noise floor was too high to conclude anything, and
CI is Node 24. Reporting it as contaminated rather than as evidence.
Tests: 457 passed, 0 failed; 7 consecutive clean runs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8
* fix: correct the PR body, kill a comment I rotted, and get the counts right
The review's blocker was not in the code — it was that my PR body still carried
both retracted claims VERBATIM while my commit message asserted they had been
"corrected in the PR body". They had only been corrected in a trailing comment.
In a PR whose entire thesis is claim accuracy, that is the finding that matters.
The body is rewritten. Both claims now appear only as struck-through
corrections with the true values:
- "ltDiag in EVERY assertion message" -> 8 call sites, two target tests only
- "39330+i was SHARED across a loop" -> it wasn't; i is 0/1/2, ports differ.
The real risk is cross-process, which the control arm's 246 EADDRINUSE shows.
Comment rot I introduced: test-features.mjs:1000 said "See ltAssertBoot below"
and no such function exists — grep returned exactly one hit, the comment itself.
Removed.
Counts corrected after re-measuring with the definition line excluded, which is
where my earlier numbers came from:
ltDiag call sites 7 -> 8
_ltRmRetry sites 10 -> 11
_ltRmRetry's catch now honours LT_DEBUG instead of being unconditionally
silent. It must still never throw — it runs in a finally, where a throw would
REPLACE the real assertion error and turn a flake into an apparent regression —
but an opt-in warn costs nothing and stops it swallowing genuine programming
errors too.
Recording the reviewer's control-arm numbers, since they are what actually
justifies this PR (full suite x 4 concurrent x 50 rounds, per arm):
main this branch
clean runs 42/200 200/200
#199 7 0
:1092 8 0
EADDRINUSE 246 0
ENOTEMPTY 1 0
#203 0 0
Tests: 457 passed, 0 failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8
* test: land the comment fix that didn't, and size ltDiag's window to the banner
Two things, both caught by review of the previous commit.
1. The comment fix I claimed to have made never landed.
800800e's message said it corrected the console.log gap "14 -> 13". The diff has two
hunks (ltAssertBoot removal, LT_DEBUG catch) and never touches that line, so the
comment still read 14 — and 13 was wrong too, in units. Ground truth:
server.mjs:3627 console.log(`... listening on ...`)
server.mjs:3640 if (LOCAL_TOOLS_ACTIVE) console.log(`Local tools: ON ...`)
strictly between: 12 console.log calls, of which :3635 (SYSTEM_PROMPT) and
:3636 (MCP_CONFIG) are conditional and unset in this env -> 10 actually fire.
Now 12, with the conditional note. Also corrected 9/200 -> 8/200 on the same comment:
the control-arm table says 8, and the two disagreed.
2. ltDiag sampled stdout tail-only, which is the wrong end.
Review reversed its own earlier "leave it" with direct evidence: the mutation
diagnostic printed 1147B of tail and did NOT contain "Local tools: ON" — the single
most decisive string for "it booted instead of refusing". Fixed as head+tail.
Sizing is measured, not picked round. On this tree stdout is 1118B with the string at
byte 581, so:
head=120 -> string lands in the elided middle. VERIFIED INSUFFICIENT.
head=900 -> clears it with ~5 banner lines of margin.
900 is robust rather than merely bigger: the banner is emitted first and is bounded, so
however much request noise follows, byte 581 stays in the head. Demonstrated under the
gate mutation:
stdout(1133B)="... Local tools: ON (OCP_LOCAL_TOOLS=1) — model told it may use
local tools; single-user/loopback only ..."
Also folded in the two cheap #203 levers review listed, since the next step on that
issue is a Linux + Node 24 run:
- closeMs (stamped at 'close', not read at assertion time) separates "died before
reaching the gate" from "ran, then gated" — exit=1 with empty stderr is equally
consistent with both today. Prints "(still open)" when close never fired.
- node=${process.version} — exactly what would have flagged the Node 22 SQLite
ExperimentalWarning before it cost 50 contaminated Oracle runs.
Mutation restored from a file backup, not `git checkout` — that command destroyed
uncommitted work earlier in this series.
server.mjs: unchanged (test-only; ALIGNMENT.md requires no cli.js citation).
Suite: 461 passed, 0 failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8
* test: make ltHeadTail's head budget loud instead of silently degrading
Review's LOW on 75ac3e4, taken rather than deferred. Its failure mode is the reason:
the head budget is coupled to banner growth, and when it is exceeded ltDiag degrades
back to exactly the blind spot this PR fixed — with no test going red. Silent
degradation of a diagnostic is the same class of bug as the flakes this PR is about,
so a one-line guard is worth more here than the cycle it costs.
const _ltOffset = buf.out.indexOf("Local tools: ON");
assert.ok(_ltOffset < 900, "...it is now at byte ${_ltOffset}. The banner grew...");
Mutation-verified (budget 900 -> 400):
✗ ltHeadTail's head budget (900B) no longer reaches the local-tools announcement
— it is now at byte 582. The banner grew. Raise the head in ltHeadTail, or
ltDiag will silently stop showing the one line that distinguishes "booted"
from "refused".
Margin is ~17 more models: `Models:` runs ~18B per entry, and the offset is 581 of a
1118B banner. Review independently measured 577/1117 — the delta is port-digit width.
Two micro-fixes it noted:
- `buf.t0 ? ... : "?"` was dead: t0 is always set in the literal and Date.now() is
never 0. Removed rather than left as a branch that can't be reached.
- closeMs now declared in the buf literal alongside signal/closed/spawnErr.
Also acknowledging one change of mine that review found and my own summary omitted:
stderr sampling went 200 -> 240 chars, because the non-loopback FATAL body is ~180
chars before the "FATAL:" prefix and 200 was close to truncating the longest gate
message. Same size-to-the-content reasoning as the head; I just failed to list it.
server.mjs: unchanged (test-only; ALIGNMENT.md requires no cli.js citation).
Suite: 461 passed, 0 failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: guard the asymmetric cache keys (#200); document the ltBoot fault lever (#197)
Two knowledge-preservation fixes. No behavior change: server.mjs gains comments
only, verified by `git diff --stat` (comment lines) and an unchanged suite.
#200 — structuredHash and dedupKey compute the same value and must NOT be
collapsed. They take identical arguments and read as obvious duplicate work,
which is how someone "optimises" them into a bug. Their GUARDS differ, verified
line by line rather than asserted:
structuredHash: if (CACHE_TTL > 0 && !conversationId && !hasCacheControl(...))
dedupKey: (!conversationId && !hasCacheControl(...)) <- no CACHE_TTL
CLAUDE_CACHE_TTL defaults to 0, so in the DEFAULT configuration structuredHash
is null while dedupKey must still be computed — it drives #153's single-flight
stampede protection, which is deliberately independent of response caching.
`dedupKey = structuredHash` would silently disable stampede protection by
default, in exactly the concurrent-AI-Task case it exists to bound. The comment
records that, and says what a safe deduplication would look like if anyone
still wants one (compute under the weaker guard, derive under the stronger,
add a stampede test first).
This was first raised in the #194 review as a cosmetic nit and then RETRACTED
by that reviewer after reading the guards — the retraction is the valuable
part, so it goes in the code rather than staying in a PR thread.
#197 — new "Testing: reaching faults inside server.mjs" section in AGENTS.md.
The premise the issue was originally filed on was wrong twice over, and both
errors were mine: first that no live-server harness existed (ltBoot has been
there all along, and I had used it myself in #191), then that a synchronous
fault deep inside spawnClaudeProcess needed a production test hook (it needs
--stack-size, which ltBoot already forwards).
The section documents the fixture, the --stack-size fault lever with the
concrete numbers (~124k element spread threshold at the default stack vs ~24k
at --stack-size=200, which is what fits Linux's 131072-byte MAX_ARG_STRLEN so
the test RUNS in CI instead of skipping), and the three rules that made it hold:
discover the threshold in a child under the same flags, assert the fault
actually fired, and wait for the thing you are about to assert rather than a
proxy for it. Each rule cites the issue that taught it (#193/#199/#203).
Written to be read BEFORE someone concludes a bug class is untestable, since
that conclusion has now been reached wrongly twice in this repo.
cli.js citation: NOT APPLICABLE — comments only, no wire behavior, no code
path, no argv change. Authorized by ADR 0006 as a behaviour-preserving edit;
the field set and every request/response contract are byte-identical.
Tests: 457 passed, 0 failed (unchanged, as expected for a comments-only diff).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8
* docs: reference only helpers that exist on main (AGENTS.md)
Review caught a real forward-reference: the new AGENTS.md section named
`ltDiag` and told readers to wait on `closed`, and NEITHER exists on main —
both are introduced by the still-unmerged #204. `git grep ltDiag` matched
exactly one line: the documentation itself.
That is worse than a broken link. AGENTS.md is, by its own "Handoff
expectations", the FIRST file a fresh session reads; it would have sent that
session looking for two identifiers it cannot find, in the file whose whole job
is to orient them.
It also meant this PR had an undeclared merge-order dependency on #204, which
I had not stated anywhere.
Fixed so each PR is self-consistent regardless of merge order:
- names `ltPostStatus` (which does exist) instead of `ltDiag`
- states the #203 rule as a PRINCIPLE — a terminated child's pipes may still
hold unread data, so wait for stdio to close rather than for the exit —
instead of naming the `closed` flag that #204 adds
Verified: every helper the section now names resolves to a `function <name>` in
test-features.mjs on this branch, and neither `ltDiag` nor `closed` is
referenced any more.
Tests: 457 passed, 0 failed (comments/docs only, unchanged).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(models): commit models.schema.json and validate the SPOT against it in CI (#196)
models.json has declared `"$schema": "./models.schema.json"` since v3.11.0, but
that file was never committed — `git log --all -- models.schema.json` is empty.
So the SPOT that ADR 0003 makes canonical had NO structural validation: a
missing contextWindow, a typo'd openclawName, or a boolean written as a string
would pass every existing test and only surface downstream — in OpenClaw's
registry, or silently inside a truncation budget.
Validated with the repo's OWN validator (lib/structured-output.mjs, shipped for
#153) rather than adding ajv. AGENTS.md requires minimal dependencies and the
repo currently has ZERO; this keeps it that way and exercises that validator on
a second real input. Capability-probed first rather than assumed: it enforces
type / required / enum / const / additionalProperties (both the boolean and the
schema form) / items / minItems, which covers everything the SPOT needs.
Three tests:
- the $schema reference resolves to a committed file (the #196 bug itself)
- models.json validates against models.schema.json, strict
- the schema actually REJECTS 8 corruption shapes — missing required field,
wrong scalar type, non-integer window, unknown extra field, alias mapped to
a non-string, empty models array, wrong document version, unknown
top-level key
That third test is the guard-on-the-guard: without it a vacuous schema (`{}` or
a typo'd `properties`) would make the second test pass on anything.
Mutation-proven in three directions:
schema replaced with {} -> "schema failed to reject: missing required field"
additionalProperties:false removed -> guard fails (loosened constraint caught)
models.json corrupted (drop a field) -> strict validation fails
What the schema deliberately does NOT encode: referential integrity — that
every aliases/legacyAliases target exists as a models[].id. That is not a JSON
Schema concept; it is already covered by two dedicated tests, and the schema
says so in its description so the next reader doesn't assume it is covered.
The field descriptions carry the non-obvious consequences that have bitten this
repo before: contextWindow is a GLOBAL lever (max across all entries x 3 sets
MAX_PROMPT_CHARS for every model, ADR 0009), maxTokens is advertising only and
is enforced by OpenClaw rather than OCP, and models[] order is load-bearing
because ocp-connect uses model_ids[0] as a fallback.
Documented per release_kit (new file/SPOT/schema -> contributor section):
AGENTS.md "Key files to know" and README's Available Models section.
Tests: 460 passed, 0 failed (457 + 3).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8
* fix(schema): warn that only a subset of draft 2020-12 is enforced; cover the gaps it cannot
Both review findings taken.
MED — the schema declared `$schema: draft/2020-12`, which promises full draft
semantics, while the validator that actually runs it enforces only a subset.
Someone adding `"minimum": 1` to contextWindow would get SILENT no-op with a
green suite — a trap made worse by the file looking authoritative. The
description now enumerates exactly what is enforced (type, required, const,
enum, additionalProperties in both forms, items, minItems/maxItems,
anyOf/allOf/oneOf, $ref, nullable) and names what is ignored (minimum,
maxLength, pattern, uniqueItems, propertyNames, not), with the instruction to
add such a constraint as a test instead.
LOW — three corruptions neither the schema nor any sibling test caught:
duplicate models[].id, empty-string id/displayName/openclawName, and
non-positive contextWindow/maxTokens. They are structurally NOT expressible
with the supported keyword set (no uniqueItems, no minLength, no minimum), so
they are asserted directly rather than by pretending the schema covers them.
Mutation-proven, all three previously slipped through:
duplicate id -> CAUGHT
empty displayName -> CAUGHT
zero contextWindow -> CAUGHT
Tests: 461 passed, 0 failed (460 + 1).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8
* test(schema): close the fourth gap — uniqueness on all three name fields, not just id
Review approved the delta and named this exact follow-up as non-blocking:
"extend the same test to assert uniqueness of displayName/openclawName and use
m[f] === m[f].trim()". Implemented as specified.
Why it matters rather than being tidiness: scripts/sync-openclaw.mjs maps
`claude-local/<id>` -> { alias: displayName } and writes openclawName as the
registry label, so a duplicate in EITHER field collapses two models onto one
OpenClaw entry — the same defect class as a duplicate id, which the previous
version covered while leaving its two siblings open.
The trim check also changed shape. It was `m[f].trim().length > 0`, which a
PADDED id passes by construction — and that id is handed VERBATIM to
`claude --model`, so " claude-opus-5" would have failed upstream instead of
here. Now asserts `m[f] === m[f].trim()`.
Mutation-proven, all three previously slipped through:
duplicate displayName -> CAUGHT
duplicate openclawName -> CAUGHT
whitespace-padded id -> CAUGHT
Tests: 461 passed, 0 failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ci: cover models.json in the alignment guardrail; fix release.yml's no-CHANGELOG path
Two CI-layer fixes found during the v3.25.0 review. Same layer, both small, so
they land together (Iron Rule 11).
#198 — alignment.yml did not run on a models.json-only PR.
The `paths:` filter listed server.mjs / setup.mjs / scripts / lib / ocp /
ocp-connect, but not models.json. That made CLAUDE.md hard-requirement #2 ("CI
blacklist pass") vacuous for exactly the PRs that change model routing:
confirmed on #192, where only gitleaks and test-features ran.
models.json is not inert data. It drives MODEL_MAP and VALID_MODELS, the
default request model, and — since ADR 0009 — the global MAX_PROMPT_CHARS
truncation budget. A models.json-only PR can therefore change server.mjs's
runtime behavior with the guardrail never running. models.schema.json is
included for the same reason one level up: loosening the schema is what would
let a malformed models.json through.
Adding paths only widens coverage; the blacklist grep still scans server.mjs,
so this costs nothing and closes the gap. Per CLAUDE.md this is a PR amendment
to alignment.yml, which is the sanctioned way to change it (removing entries
would need an ALIGNMENT.md amendment; nothing is removed here).
#202 — release.yml's no-CHANGELOG fallback would have failed the release job.
The branch wrote an output named `notes` and exited WITHOUT creating
/tmp/release-notes.md, while the create step hard-codes
`--notes-file /tmp/release-notes.md`. So the one path that exists to "degrade
to minimal notes" instead produced a failed release.
Verified both directions by extracting the step's actual script from the YAML
and running it, rather than reading it:
PRE-FIX, no CHANGELOG -> exit 0, GITHUB_OUTPUT="notes=Release v3.25.0",
/tmp/release-notes.md MISSING
-> gh release create --notes-file WOULD FAIL
POST-FIX, no CHANGELOG -> exit 0, notes_file set, file present ("Release v3.25.0")
POST-FIX, with CHANGELOG -> file present, first line "## v3.25.0 — 2026-07-27"
Fixed by writing the file in that branch, and by removing the hard-coded-path
coupling entirely: both branches now set `notes_file` and the create step
consumes `steps.notes.outputs.notes_file`. That output existed already and was
never read — the two only agreed by accident.
Also added `set -euo pipefail` (the step previously ran unset-tolerant) and an
echo of the resolved notes before creating the release, so a wrong-looking body
is visible in the job log instead of only on the published release.
NOT changed: the empty-extraction guard. My issue text suggested adding one,
but `if [ ! -s "$NOTES" ]` was already there and already handles a heading
mismatch. Correcting that claim on #202 rather than taking credit for it.
Both workflows re-validated as YAML.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8
* ci: drop the alignment.yml paths change — it would have been a green check for a check that never ran
Reverting my own half of this PR. The reviewer proved the alignment.yml change
was theatre, and my issue #198 was filed on a wrong premise.
What I verified myself before reverting:
- the alignment job BODY references models.json zero times; only the paths
filter I added mentioned it
- test.yml is `pull_request:` with NO paths filter, so it already runs on
every PR, models.json-only ones included
- on a deliberately corrupted models.json (contextWindow 1000000, a typo'd
openclawName), `npm test` catches it: 455 passed, 2 failed
So the real guard on models.json already existed and always ran. Adding the
path would only have made a job execute that inspects nothing about
models.json, and then reported a green "Alignment Guardrail" — implying a check
that did not happen. That is strictly worse than the job not running, which is
precisely the reviewer's point.
#198's premise was also wrong. The blacklist greps server.mjs for known
hallucinated tokens. A models.json-only PR cannot introduce a token into
server.mjs, so CLAUDE.md hard-requirement #2 is INAPPLICABLE on such a PR, not
vacuous. I read "the guardrail didn't run" as "coverage gap" without checking
what the guardrail actually inspects.
The release.yml half is unaffected and stays: that one is a real, reproduced
failure (the no-CHANGELOG branch never wrote the file the create step consumes).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(release): v3.25.0 — Claude Opus 5, cache-key resolution, counter pairing
package.json 3.24.0 -> 3.25.0. MINOR because the `opus` alias now resolves to a
different model: every request that names `opus` (and OpenClaw's opus entry)
silently moves from Opus 4.8 to Opus 5. Same $5/$25 per MTok, so no cost change,
but it is a user-visible default change and not a patch.
Consolidates six PRs. Three were already on main without CHANGELOG entries
(#187, #188, #191, merged after the v3.24.0 tag) and are covered here:
#192 Claude Opus 5 + `opus` alias repoint
#194 cache keys hash the resolved model; structured path gets its epoch
#193 active-request counter paired to the process lifecycle
#191 OCP_LOCAL_TOOLS wrapper no longer hard-codes a tool list
#187 TUI --safe-mode so the host CLAUDE.md cannot leak
#188 TUI accepts `shift+tab to cycle` as input-ready
release_kit walk (CLAUDE.md Iron Rule 5.5), every item checked rather than
assumed:
- version_source package.json -> bumped
- changelog CHANGELOG.md -> v3.25.0 section added
- Available Models table -> already 7 rows (landed in #192)
- models.json source_of_truth -> already updated (#192)
- Environment Variables table -> no new env var this cycle
- API Endpoints table -> no new endpoint
- new CLI subcommand / hook / file -> none
- bootstrap_quirk_policy -> see below
Bootstrap quirk documented, per policy. #194 changes the cache key shape, so
alias-addressed rows orphan once and are reaped by the TTL cleanup within one
window — the same shape as the v3.13.0 v1->v2 hash upgrade already recorded in
README. Added to BOTH places the precedent lives: the Response Cache section in
README, and a dedicated entry in docs/troubleshooting.md alongside the existing
"OpenClaw shows old models after ocp update" one-time-quirk entry. Only affects
instances running with CLAUDE_CACHE_TTL > 0; it is off by default.
Verified: release.yml's awk extractor matches the `## v3.25.0 — 2026-07-27`
heading and pulls 22 lines (a mismatched heading would ship an empty release
body). Suite 457 passed, 0 failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8
* fix(release): correct four inaccuracies the reviewer found in the release notes
This PR's entire job is that the text is accurate, so these are not nits.
1. README + troubleshooting overstated the cache-key scope. "Rows keyed on a
literal model id are unaffected" is FALSE for structured-output rows: #194
also added `configEpoch` to the structured key, and keys.mjs:362 folds it
into the digest, so EVERY structured row rekeys regardless of how it was
addressed. Now states both scopes separately — normal cache: only
alias-addressed rows; structured cache: all rows, plus why (the epoch was
never in that key, so a CLAUDE_SYSTEM_PROMPT change did not invalidate
structured answers either).
2. The bootstrap quirk was documented in README § Response Cache and in
docs/troubleshooting.md, but NOT in the "Bootstrap quirks (one-time
migrations)" bullet list at README:540 — which is the location the
release_kit YAML actually names, and where both existing precedents live.
Added there. My "every item checked, not assumed" claim was wrong on the one
item that had a dedicated home.
3. #188's root cause was invented. I wrote "plan/auto mode renders a different
idle footer than manual mode", which contradicts the landed code comment
(lib/tui/session.mjs:184-189) and the test fixture: the two footers are
across claude VERSIONS — the classic `? for shortcuts` vs `shift+tab to
cycle` on newer 2.1.x — and the fixture is bypass-permissions mode, neither
plan nor auto. Replaced with the contributor's own documented cause, plus
the observable symptom (tui_pane_not_ready on every boot, every pre-boot
failing with the warm pool on). Substituting my guess for an external
contributor's evidenced diagnosis would have sent the next debugger looking
for "plan mode".
4. "Suite: 449 → 457" used a mid-cycle baseline. Measured at the v3.24.0 tag:
447. 449 is the count AFTER #187/#188 already landed — and this release
claims those as part of the cycle. Corrected to 447 → 457 with the per-PR
breakdown (#188 +1, #187 +1, #191 +0, #194 +4, #192 +3, #193 +1 = +10).
Also taken from the same review:
- the Added bullet now cites #192 (every Fixed entry had a PR number; Added
did not)
- split out a `### Changed` section for the `opus` repoint, matching how
v3.23.0 recorded #168's `sonnet` repoint — a behavior change should not sit
under Added
- CHANGELOG quoted `MODEL_MAP[model] || model`, the form review REJECTED;
the landed code is the Object.hasOwn guard (a bare lookup returns a truthy
object for "__proto__" and never reaches the `||` fallback)
Re-verified after the edits: release.yml's awk extractor yields 27 lines /
6277 bytes with all four sections; all six PR references present. Suite 457
passed, 0 failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8
* fix(release): qualify the normal-cache scope for OCP_LOCAL_TOOLS instances
Round-2 review. One finding accepted, one REJECTED with first-hand evidence.
ACCEPTED [MED] — my round-1 fix corrected the structured-cache scope but got
the normal-cache scope wrong in a new way. "Rows keyed on a literal model id
keep matching" is true for #194 in isolation, false for this RELEASE: #191
(a32bc9a, in this same release) reworded OCP_LOCAL_TOOLS_WRAPPER, the wrapper
text is one of CONFIG_EPOCH's four inputs (server.mjs:219), and every normal
cache key folds the epoch in. Verified by computing both digests:
epoch with the OLD local-tools wrapper: 9a97a9dace3cadc2
epoch with the NEW local-tools wrapper: 9f4526f7b146adfb
So an OCP_LOCAL_TOOLS=1 instance rekeys its ENTIRE normal cache once, literal
ids included. The doc heading is release-scoped while my claim was PR-scoped —
the same class of over-broad assertion as the original finding, which I then
reintroduced while fixing it. Both docs now carry the exception.
Also anchored the README bootstrap-quirk link at the specific troubleshooting
section (#cache-rekey-v3250) rather than the file, matching the existing
#tui-401 precedent.
REJECTED [claimed HIGH] — "Opus 5 is pricing:\"tier_10_50\" ($10/$50), so
'Pricing is unchanged' is false." It is not false. I re-extracted from the same
binary the review cites (claude 2.1.220), this time ANCHORED ON THE id FIELD
rather than on a bare id string:
id:"claude-opus-5" ... pricing:"tier_5_25"
id:"claude-opus-4-8" ... pricing:"tier_5_25"
id:"claude-mythos-5" ... pricing:"tier_10_50" <- the real owner
id:"claude-sonnet-5" ... pricing:"tier_3_15"
`claude-opus-5` never appears within 200 chars before a tier_10_50 (grep -c: 0).
tier_10_50 belongs to claude-mythos-5. The review appears to have matched an
adjacent entry — the exact failure mode it accused this PR of, which is a fair
thing to have looked for; it just wasn't what happened here.
Worth recording WHY the original extraction was sound even though it looked
suspicious: the substring it matched, `claude-opus-5"},eager_input_streaming`,
is the tail of claude-opus-5's OWN provider_ids object (`gateway:"claude-opus-5"}`),
so the pricing that follows is its own. Ambiguous-looking, correct in fact —
which is why the id-anchored re-extraction was worth doing rather than assuming
either way.
The pricing claim therefore stands unchanged, on first-hand evidence.
Suite: 457 passed, 0 failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(server): pair the active-request counter with the process lifecycle (#180)
`stats.activeRequests` was incremented ~40 lines before the child process was
spawned, but its only decrement site — cleanup() — is wired to that process's
exit/close/error events. Any synchronous throw in the window between them
(buildCliArgs, env assembly, applying the spawn decision, or spawn() itself)
therefore leaked +1 permanently: the increment had happened, and cleanup() was
not yet attached to anything that could undo it. /health and /stats then
over-report in-flight work forever, and the drift is monotonic.
Fix: move the increment to immediately after `activeProcesses.add(proc)`, so
increment and decrement are structurally paired to exactly one process
lifecycle. No try/catch and no reconciliation pass is needed — there is no
longer a window in which the counter is incremented but unowned.
`stats.activeRequests` is observability-only, not a gate: the old
`if (activeRequests >= MAX_CONCURRENT) throw` admission check was removed in
favour of the releaseSlot semaphore (see the comment at server.mjs:1327), and
the only remaining readers are /health, /stats and the settings snapshot. So
deferring the increment past the spawn changes no admission decision.
Reproduced and verified on the real server (no test double). A deliberate
throw injected between the increment and the spawn, then three requests:
before the fix : 3 requests -> http 500 -> stats.activeRequests == 3 (leaked)
after the fix : 3 requests -> http 500 -> stats.activeRequests == 0
happy path : real completion "OK", activeRequests 0, total 1, errors 0
The fault injection was removed before committing (no residue: `grep -rn
OCP_FAULT_INJECT` is clean) — it is documented in the PR so any reviewer can
re-run the same three commands.
cli.js citation: NOT APPLICABLE — this touches no cli.js-derived wire
behavior. Scope is justified under ALIGNMENT.md Rule 2 as OCP-owned internal
bookkeeping: the counter is OCP's own observability state, cli.js has no
equivalent operation, and the spawn arguments and protocol are untouched.
Supersedes the approach in #180, which additionally reconciled the counter to
`activeProcesses.size`. That reconciliation is racy: a concurrent request that
has incremented but not yet registered its process is invisible in
`activeProcesses`, so reconciling stomps it back down and under-reports live
work. Pairing the increment to the spawn removes the drift at its source, so
no reconciliation is required. Original report and diagnosis by @konceptnet.
Tests: 449 passed, 0 failed.
Co-Authored-By: Hermes Agent <hermes-agent@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8
* fix(review): correct three wrong claims in this PR, and add the regression test
The diff was right; three things I wrote about it were not. An independent
reviewer falsified each one, and leaving a correct fix on an incorrect
rationale in git history is exactly what ALIGNMENT.md governance exists to
prevent. Corrections:
1. "No regression test is possible here" — WRONG, and the test is now here.
Two compounding errors. `test-features.mjs:955` already has `ltBoot`, a
real-server integration fixture (spawns server.mjs, fake claude binary,
HTTP) that I had used myself in #191; I reasoned from "cannot import
server.mjs" straight to "cannot test" without grepping for it. And the
synchronous throw is reachable from ENV ALONE, needing no production hook:
buildCliArgs does `args.push("--allowedTools", ...ALLOWED_TOOLS)`, and a
spread of enough elements throws RangeError synchronously, inside the leak
window. Measured, zero production changes:
origin/main : 3 requests -> 500 x3 -> stats.activeRequests == 3
this branch : 3 requests -> 500 x3 -> stats.activeRequests == 0
The committed test DISCOVERS the element count rather than hard-coding it
(the threshold is stack-size dependent, so a fixed number would silently
stop triggering on another machine and pass vacuously), and asserts the
requests actually 500 before trusting the counter. Mutation-proven: moving
the increment back before the spawn fails it with "got 3".
2. My reason for rejecting #180's approach was WRONG. I claimed reconciling to
activeProcesses.size is racy because a request that incremented but has not
yet registered is invisible in the Set. There is no `await` anywhere in that
window — it is fully synchronous, so on Node's single thread that state is
unobservable and the race cannot occur. The real reason reconciliation is
unsafe: the decrement is wired to 'exit' while activeProcesses.delete is
wired to 'close', and exit precedes close (measured exit @2ms, close
@2017ms with a grandchild holding stdout), so activeProcesses.size
OVER-reports during that window — the opposite direction from what I wrote.
Separately, a throw after activeProcesses.add leaves permanent Set residue
that reconciliation would inherit. Corrected publicly on #180; the
contributor was owed an accurate rejection.
3. The alignment citation named the wrong authority. ALIGNMENT.md:17 scopes
Rules 1-5 to Class A only, and Rule 2 is a prohibition, not an
authorization — citing it as justification is a category error. Every reader
of stats.activeRequests is a grandfathered Class B.2 endpoint (/health,
/status, /usage). Correct citation, per ALIGNMENT.md:142 and the in-repo
precedent at server.mjs:3271-3276 (the F6 spawn.isolated fix):
Authorized by ADR 0006 (grandfathered as of v3.16.4). The field SET is
unchanged — no field added, removed or renamed — only the VALUE is made
truthful. Behaviour-preserving; no new ADR required.
The "cli.js: no hit, declared" part stands: cli.js has no equivalent
operation, and spawn argv and protocol are byte-identical.
Also corrected the new comment at the increment: only 'exit' is wired inside
spawnClaudeProcess; 'close'/'error' are wired by each caller, and that is
REQUIRED because a failed spawn never emits 'exit'.
Note for a follow-up issue, not folded in (Iron Rule 11): stats.errors stays 0
across all of these 500s — synchronous throws from spawnClaudeProcess are never
counted. And CLAUDE.md hard-requirement #1 literally says "justify scope under
Rule 2", which is what I followed; CLAUDE.md predates ADR 0006's Class B
section and needs its own governance PR.
Tests: 450 passed, 0 failed.
Co-Authored-By: Hermes Agent <hermes-agent@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8
* test: skip the leak probe where the OS rejects the trigger, instead of failing CI
The regression test I added broke CI with `spawn E2BIG`, and the reason matters
more than the fix: the env-only trigger is NOT portable, and the review that
proposed it was measured only on darwin (the reviewer said so).
Delivering the trigger needs a single env var of ~2n bytes where n is the
spread-throw threshold (~130k elements here). Linux caps ONE env string at
MAX_ARG_STRLEN = 32 * PAGE_SIZE = 131072 bytes, so execve rejects it outright.
Encoding tricks do not rescue it: empty items cost 1 byte each, but 120k empty
items do not reach the throw threshold, and the threshold itself moves with
stack size — the margin does not exist. buildCliArgs has exactly one spread
(verified), so there is no alternative env-only trigger.
So the test now runs where the OS allows the trigger (macOS/BSD) and SKIPS
LOUDLY where it does not, printing the platform and byte count. It never
passes vacuously: it already asserts the requests actually return 500 before
trusting the counter, and E2BIG is detected both as a synchronous throw and as
an async 'error' event.
Consequence, stated plainly rather than buried: CI runs on Linux, so CI does
NOT exercise this test. The fix is protected on macOS dev machines only. That
is weaker than I would like and is why #197 (portable in-process fault
injection) matters — it is the difference between this class of bug being
guarded and merely having been checked once by hand.
Verified after the change:
macOS, fixed -> test RUNS and passes, 450/0
macOS, bug restored -> test goes RED ("got 3"), 449/1
Linux CI -> skips with a printed reason
Co-Authored-By: Hermes Agent <hermes-agent@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8
* test: make the leak probe run on Linux CI — my "impossible" conclusion was wrong
f84d4c6 concluded the trigger could not be delivered on Linux and made the test
skip there. The reviewer refuted that and built the working version; this is it.
Three things I got wrong, all the same shape — treating a darwin measurement as
universal:
1. "empty items don't reach the throw threshold" — the REASON was wrong. The
spread's cost is per ELEMENT, not per byte: empty and "x" have an identical
threshold. What actually kills the empty-item encoding is `.filter(Boolean)`
at server.mjs:355, which strips empty entries, so ALLOWED_TOOLS ends up empty
and the spread branch is never entered. The new control mutation demonstrates
exactly this: tools filtered to empty -> requests return 200, not 500.
2. "the threshold moves with stack size — the margin does not exist" — exactly
backwards. The stack dependence is the LEVER, and ltBoot already spawns the
server, so the test owns its argv. Under --stack-size=200 the threshold drops
~5x: measured 24069 elements, so 1.5x margin is 36104 elements = 72207 bytes,
45% UNDER Linux's MAX_ARG_STRLEN of 131072.
3. "buildCliArgs has only one spread, so there is no alternative trigger" — the
premise is true (verified) but irrelevant. The problem was never finding a
second spread; it was making the one spread throw with fewer elements.
Test-side only; server.mjs is untouched by this commit.
- ltBoot(env, dir, nodeArgs = []) — one line, so a caller can set V8 flags
- the threshold is discovered in a CHILD under the same --stack-size (the old
version measured it in the parent, whose stack is not the one that matters)
- hard assert entryBytes <= MAX_ARG_STRLEN, so a future regression fails loudly
instead of silently degrading to a skip
- dynamic free port instead of a fixed 39370 (a flake was observed on the fixed
port across back-to-back runs)
- the 500s must carry "call stack size exceeded", so an unrelated fault that
also left the counter at 0 cannot be mistaken for a pass
The Linux claim here is still ARITHMETIC (72207 < 131072), not a measurement —
neither I nor the reviewer has a Linux box. CI is the check: the log must show
this test RUNNING, not skipping.
Co-Authored-By: Hermes Agent <hermes-agent@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Hermes Agent <hermes-agent@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(models): add Claude Opus 5 and repoint the `opus` alias to it
Adds `claude-opus-5` to models.json (the SPOT) and moves the `opus` alias
from `claude-opus-4-8` to it. `/v1/models` goes 6 -> 7; OpenClaw picks the
new entry up on the next `ocp update` via scripts/sync-openclaw.mjs.
server.mjs is NOT modified by this PR — models.json is the single source of
truth (ADR 0003) and every consumer (server.mjs MODEL_MAP, setup.mjs,
sync-openclaw.mjs, ocp-connect) derives from it. ocp-connect needs no change
either: its metadata table matches on the `claude-opus` FAMILY prefix, which
`claude-opus-5` hits (the guard added in PR #152 review).
Alignment evidence. The claim "the CLI itself defaults opus -> claude-opus-5"
is verified from the compiled CLI binary 2.1.220 via `strings`, the same
protocol used for #152/#168:
latest_per_family:{fable:"claude-fable-5",opus:"claude-opus-5",
sonnet:"claude-sonnet-5",haiku:"claude-haiku-4-5"}
The same registry entry gives context:{window:1e6,native_1m:true},
max_output_tokens:{default:64000,upper:128000} and pricing:"tier_5_25" --
i.e. identical $5/$25 per MTok to Opus 4.8, so the alias repoint carries no
cost change. Availability confirmed with a live subscription-pool completion
(`claude -p --model claude-opus-5` -> "OK"). This mirrors #168 (sonnet ->
sonnet-5), which established the precedent that following the CLI's own
latest_per_family is the correct default behavior.
contextWindow is deliberately 200000, not the native 1M:
1. MAX_PROMPT_CHARS is a SINGLE GLOBAL budget, not per-model.
derivePromptCharBudget (lib/prompt.mjs) returns
`max(floor, max(...contextWindows) * 3)` across ALL entries, so a 1M entry
would lift the truncation ceiling to 3,000,000 chars for
claude-haiku-4-5-20251001 as well -- genuinely a 200k-native model --
turning clean OCP-side truncation into an upstream API rejection.
2. OpenClaw scales its history budget linearly off this value:
contextWindow * maxHistoryShare * SAFETY_MARGIN (= x0.6), plus an
oversized-message threshold at x0.5 (compaction-planning, OpenClaw
2026.7.1). Its own bundled registry hardcodes 200000 for Claude models,
and the upstream request to raise it to 1M (openclaw#22979) was closed
"not planned" -- 1M needs a beta header its compaction path omits.
Raising the window for real requires per-model budgets and is ADR-level;
a new regression test pins the invariant so that change has to be deliberate.
Tests: 452 passed, 0 failed. Three mutations verified to bite:
- revert aliases.opus -> 4-8 => 1 failure (opus-alias SPOT)
- delete the claude-opus-5 entry => 2 failures (presence + dangling alias)
- set its contextWindow to 1000000 => 2 failures (invariant + budget SPOT)
E2E on an isolated port (:3999, prod on :3456 untouched and verified so):
/v1/models returns 7 ids with claude-opus-5 first; a request with
model:"opus" logs event=claude_spawned model=claude-opus-5 tier=opus and
returns a real completion. Derived MAX_PROMPT_CHARS stays 600000.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8
* fix(docs,test): restore the dropped Opus 4.8 line; assert the window CEILING
Two review findings from the independent Iron-Rule-10 reviewer, both confirmed
before applying:
1. docs/lan-mode.md: the sample `ocp update` output lost `ocp/claude-opus-4-8`
entirely — the edit substituted the id instead of inserting the new one, so
the block listed 6 bullets while line 219 of the same document claims
"7 models available". Self-contradictory. Restored; the block is 7 again.
2. test-features.mjs: "every contextWindow is 200000" was too rigid and
contradicted ADR 0009, which states the budget "scales automatically — no
code change". Asserting every entry turned that documented no-code-change
path into a must-edit-tests path, and would have failed on a future entry
with a legitimately SMALLER window. Now asserts the MAX instead: raising the
ceiling (the actual hazard, since the budget is global) still fails, while a
smaller-window model stays legal.
Verified the reformulation is strictly better, not just different:
- raise a window to 1000000 -> 2 failures (ceiling + derivePromptCharBudget SPOT)
- add a legitimate 128k model -> 452 passed, 0 failed (old test would have failed here)
Tests: 452 passed, 0 failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(server): fold the alias map into CONFIG_EPOCH so alias repoints invalidate the cache
Cache keys hash the model string exactly as the client sent it — `const model =
parsed.model || modelsConfig.aliases.sonnet` (server.mjs:2806), which then goes
straight into `cacheHash(model, ...)`. A request for "opus" is therefore cached
under the literal string "opus", not under the canonical id it resolved to.
models.json is read once at boot, so repointing an alias only takes effect on
restart — and the response cache is SQLite-backed (keys.mjs getCachedResponse
queries the `response_cache` table), so it OUTLIVES that restart. CONFIG_EPOCH
folded four config values into every cache key for exactly this hazard (#176 /
#177) but omitted the alias map. Consequence: an operator running
CLAUDE_CACHE_TTL > 0 who repoints an alias keeps being served the OLD model's
answers under that alias until TTL expiry — silently defeating the repoint.
Fix: add `modelsConfig.aliases` to the epoch digest, so any alias change is an
instant whole-cache invalidation. This is the mechanism #177 already
established for the other config inputs, applied to the one it missed; it
covers the normal, structured and singleflight paths at once because they all
key off the same epoch, and it needs no change at the three call sites.
Reproduced end-to-end on the real server, both directions (NODE_ENV=test +
OCP_DIR_OVERRIDE, isolated SQLite store, CLAUDE_CACHE_TTL=600000):
1. boot with aliases.opus = claude-opus-4-8, request model:"opus"
-> claude_spawned model=claude-opus-4-8, response cached
2. stop, repoint aliases.opus -> claude-sonnet-5, restart
3. same request again:
on origin/main : cache_hit (stale Opus 4.8 answer, no spawn)
on this branch : cache MISS -> claude_spawned model=claude-sonnet-5
Default CLAUDE_CACHE_TTL is 0 (caching off), so shipped default behavior is
unaffected; this bites only operators who deliberately enabled the cache.
cli.js citation: NOT APPLICABLE — no cli.js-derived wire behavior is touched.
Scope justified under ALIGNMENT.md Rule 2 as OCP-owned cache bookkeeping:
cli.js has no response cache and no equivalent operation, and the spawn
arguments and protocol are unchanged.
Found by an independent codex review (gpt-5.6-sol, reasoning=high) of the
Opus 5 alias repoint in #192; verified first-hand before applying rather than
taken on the reviewer's word.
Tests: 449 passed, 0 failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8
* fix(server): hash the RESOLVED model in cache keys; give the structured path its epoch
Supersedes the first attempt on this branch, which folded modelsConfig.aliases
into CONFIG_EPOCH. An independent reviewer falsified that approach with a live
repro: the structured-output cache key never reads CONFIG_EPOCH at all, so
adding inputs to the epoch could not possibly fix it. My PR body claimed it
covered "normal, structured and singleflight" — that claim was false.
Two defects, both now fixed:
1. Cache keys hashed the model string as the client sent it. `model` is
whatever arrived — a canonical id, an alias ("opus"), or a legacyAlias
("claude-opus-4"); MODEL_MAP carries all three. models.json is read once at
boot, so repointing an alias only takes effect on restart, while the SQLite
response_cache outlives it — serving the OLD model's answers under that
alias until TTL expiry. Now all three call sites hash
`cacheModel = MODEL_MAP[model] || model`.
Chosen over folding the alias map into CONFIG_EPOCH because it is strictly
better on three counts: it fixes the normal, structured AND dedup keys at
once (they all already pass `model`); it covers legacyAliases for free; and
it invalidates PRECISELY — only entries for the repointed alias change key,
where the epoch approach flushed the entire cache on any alias edit,
including unrelated sonnet/haiku rows. Only the cache KEY is resolved;
`model` is still echoed to the client verbatim, so the wire is unchanged.
2. The structured and dedup keys omitted `configEpoch` entirely, so #177 never
actually covered the structured path: changing SYSTEM_PROMPT (the original
#176 scenario) still served structured answers composed under the OLD
config. Live-verified by the reviewer, independently reproduced here. Both
now pass `configEpoch: CONFIG_EPOCH`. This has been latent for
structured-output clients since #153 landed.
Tests: +3, all mutation-proven — the previous attempt shipped with ZERO
coverage (reverting it left the suite at 449/0), which the reviewer proved.
Built on the existing `ltBoot` child-process harness (test-features.mjs:987)
that I had wrongly claimed did not exist; a fake CLAUDE_BIN means zero quota
cost. Rather than mutate models.json mid-suite, they assert the equivalent
observable: an alias and its canonical target must land on the SAME cache slot,
which holds only if the key is resolved before hashing.
- revert cacheModel -> model at the 3 sites => 2 failures (both alias tests)
- drop configEpoch from the structured hash => 1 failure (#177-gap test)
- restored => 452 passed, 0 failed
Blast radius, previously undisclosed: on upgrade, alias-addressed cache rows
change key once and orphan, reaped by the TTL cleanup within one window — the
same shape as the v3.13.0 v1->v2 hash upgrade already documented in README.
Literal-id rows are unaffected. Default CLAUDE_CACHE_TTL is 0, so a default
deployment sees nothing.
cli.js citation: NOT APPLICABLE — no cli.js-derived wire behavior is touched.
Scope justified under ALIGNMENT.md Rule 2 as OCP-owned cache bookkeeping;
cli.js has no response cache. The endpoint is Class B.1 under ADR 0006, which
authorizes the OpenAI-compat surface this caching sits behind.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8
* harden: hasOwn lookup for cacheModel + pin legacyAlias coverage with a test
Two non-blocking nits from the approving review, both worth taking:
1. `MODEL_MAP` is a plain object, so a bare `MODEL_MAP[model]` returns an
INHERITED function for "constructor"/"toString"/"valueOf". Unreachable
today — the VALID_MODELS gate 400s first, and it is built from
Object.keys() so it holds only own keys — but the binding sits before that
gate, so a bare lookup would hand cacheHash a function the day anyone
widens the gate or moves the binding. Now Object.hasOwn-guarded.
2. legacyAlias coverage was constructive (MODEL_MAP = models + aliases +
legacyAliases) but untested; all three tests used a plain alias. Added an
explicit claude-haiku-4-5 vs claude-haiku-4-5-20251001 same-slot assertion.
Declined the third nit (structuredHash and dedupKey compute the same value
twice): it is cosmetic, and collapsing them changes evaluation order in a path
that is currently correct — not worth the risk in a fix PR.
Tests: 453 passed, 0 failed. Mutation now bites 3 (was 2): reverting
`cacheHash(cacheModel,` -> `cacheHash(model,` fails the normal, structured AND
legacyAlias slot tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The positive local-tools wrapper (server.mjs) claimed the model has "full access
to the local filesystem, working directory, and shell through your available
tools (Bash, Read, Write, Edit, Glob, Grep, etc.)". That over-promises when an
operator narrows CLAUDE_ALLOWED_TOOLS (e.g. to Read,Grep): the model is told it
has Bash/Write/shell it doesn't hold, then attempts a non-granted tool and gets
denied. The default set matches the text, so the primary/OpenClaw path was fine —
but the enumeration was a prompt-honesty mismatch (flagged in the #182 fact-check).
Reworded to flip the POSTURE only ("you may use your available local tools … do
not assume access beyond the tool set provided to you in this session") without
enumerating specific tools or asserting shell/write capability. The model learns
its real tools from the tool definitions claude is given (--allowedTools), so the
enumeration was redundant as well as fragile — now honest under any allowed set.
No const reordering / no CONFIG_EPOCH change (still a constant; the epoch-fold
test confirms toggling still invalidates the cache with the new text). Test
markers updated (selectPromptWrapper unit + the boot-server integration assertion
that the positive wrapper reaches the -p spawn). Suite 449/0.
Rule 2: OCP-owned prompt composition, no wire change, no cli.js citation.
Closes#185
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
OCP is a proxy: the operator's private ~/.claude/CLAUDE.md and auto-memory
must never enter a proxied turn's context. The pane already sets
CLAUDE_CODE_DISABLE_CLAUDE_MDS + CLAUDE_CODE_DISABLE_AUTO_MEMORY, but on
newer claude (2.1.216) those env vars no longer suppress the injection, so
a proxied turn can obey the operator's private CLAUDE.md instead of the
caller's prompt — a leak of private context into API responses.
Add --safe-mode to the interactive spawn argv (gated off on the streaming
and OCP_TUI_FULL_TOOLS paths). Docs + tests updated.
Co-authored-by: sumlin <3495838+sumlin@users.noreply.github.com>
Newer claude 2.1.x renders the input bar with a `shift+tab to cycle`
footer (part of "⏵⏵ bypass permissions on (shift+tab to cycle)") instead
of the classic `? for shortcuts` hint. tuiInputReady() matched only the
old string, so on those builds every pane read as never-ready: cold boots
timed out (tui_pane_not_ready) and, with the warm pool on, every pre-boot
failed (bootFailures climbed, warm hits stayed at zero) with no error
surfaced to the operator.
Widen the matcher to accept either footer. Keep the test replica verbatim
and add a positive case for the new footer.
Co-authored-by: sumlin <3495838+sumlin@users.noreply.github.com>
* chore(release): v3.24.0 — OpenAI multimodal vision + structured outputs, SPOT prompt budget, agentic-turn fix, OCP_LOCAL_TOOLS
Consolidates #179/#153/#154/#183/#181/#182 (merged since the v3.23.0 tag).
3.23.0 → 3.24.0.
Minor: four user-facing features (multimodal vision #154, structured outputs
#153, SPOT-derived prompt budget #179, OCP_LOCAL_TOOLS #182) + two fixes
(#183 agentic final-answer, #181 deep-reply-500). No breaking change; no new
endpoint; no new cli.js wire behavior. Two features from @vvlasy-openclaw.
Release-kit walk: new env vars all documented in-PR (CLAUDE_IMAGE_ALLOW_URL /
CLAUDE_MAX_IMAGE_* #154, OCP_STRUCTURED_MAX_ATTEMPTS #153, OCP_LOCAL_TOOLS #182 —
grepped present in README env table); ADR 0009 (#179) + ADR 0006 (B.1, #153/#154)
indexed; version from package.json only (no stale refs); the CHANGELOG Unreleased
section (which held #182) is retitled to v3.24.0 with the other five entries added.
Tag push v3.24.0 at this squash commit triggers release.yml.
Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>
* docs(changelog): correct contributor count — four of six PRs are @vvlasy-openclaw's, not two (release reviewer)
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
Motivation (the OpenClaw case). OCP's `-p` path prepends OCP_SYSTEM_PROMPT_WRAPPER,
which tells the model it has NO local filesystem/shell/env access. Correct for a
shared/multi-tenant gateway. But an OpenClaw agent pointed at its own local OCP runs
the model SERVER-SIDE via `claude -p`, which already passes --allowedTools and has the
CLI's built-in tools — and on a loopback instance the OCP host IS the operator's
machine, so those are local tools. The wrapper gags them: the agent replies "I don't
have filesystem access" for tools it actually holds. OCP_LOCAL_TOOLS=1 swaps in a
positive wrapper for that case. (It does NOT enable client-side tool_calls for
OpenClaw/Cline — that remains unsupported by design; OCP is a text-prompt bridge.)
Safety: changes ONLY the system-prompt text, never the tool surface. Tools are governed
solely by --allowedTools/--disallowedTools; AUTH_MODE=multi still --disallowedTools the
whole FS/web/agent surface regardless of the wrapper. Fail-closed boot gate mirroring
OCP_TUI_FULL_TOOLS (ADR 0007): refuse to start when =1 is combined with
CLAUDE_AUTH_MODE=multi, a non-loopback bind, or PROXY_ANONYMOUS_KEY.
Scope/alignment: no new endpoint/header/field/wire operation. The wrapper text is
OCP-owned prompt composition (same class as OCP_SYSTEM_PROMPT_WRAPPER and
CLAUDE_SYSTEM_PROMPT), passed via the already-cited `claude --system-prompt` flag
(unchanged). ALIGNMENT.md Rule 2: nothing invented on the wire.
- lib/prompt.mjs: pure selectPromptWrapper() + localToolsSafetyError() (unit-tested).
- server.mjs: single hoisted flag LOCAL_TOOLS_ACTIVE = OCP_LOCAL_TOOLS && !TUI_MODE
(the wrapper is only applied on the -p path; TUI composes its own prompt, so the flag
is inert under TUI — announced with a warning rather than a misleading "ON"). Wrapper
selection, boot gate, and the CONFIG_EPOCH fold all key off it, so toggling the flag +
restarting invalidates the standard response cache (#177). Default path byte-for-byte
unchanged.
- README env-var row + tool-model note; CHANGELOG Unreleased entry.
Tests (+14): unit-test both ternary branches and every gate condition; plus an
INTEGRATION harness that boots real server.mjs with a fake `claude` capturing the
--system-prompt — asserting the POSITIVE wrapper reaches a request under =1 and the
EXACT negative wrapper when unset, the boot gate refuses all three unsafe configs, the
safe config boots, TUI announces inert, and toggling the flag re-spawns (cache
invalidated). Mutation-verified: reverting the wiring / neutering the gate / reverting
the epoch fold each turns a test RED. 443 passed, 0 failed.
Noticed but scoped out (Iron Rule 11): the structured-output cache path
(handleChatCompletions, the response_format branch) does not fold CONFIG_EPOCH at all —
a pre-existing gap from #177/#153, independent of this flag. Happy to fix in a follow-up.
Co-authored-by: vvlasy-openclaw <vvlasy@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(structured): crash-safe validation façade — deep model reply → refusal, not a 500 (closes#181)
#153's cyclic-$ref guard caps the REF-chain depth but not the DATA depth:
validateJsonSchema recurses on the value's nesting (properties/items/additionalProps),
so a model reply nested ~2000+ levels overflowed the stack with a RangeError, which
handleChatCompletions caught as a generic HTTP 500 instead of the spec-correct refusal.
(Found in the #153 final review, filed as #181; ≤1 spawn, no crash, no client-only
trigger — the value always comes from the model reply.)
New exported validateJsonSchemaSafe() wraps the validator: ANY throw (the deep-data
RangeError, or any future recursion hazard) becomes a single validation error, so the
structured-output retry loop treats a pathological reply as "did not validate" →
refusal. A well-formed reply is byte-identical (passes the inner errors through).
runStructuredCompletion calls the safe façade.
Chose the wrapper over threading a data-depth counter through six recursive call
sites: it protects every internal path at once (impossible to miss one) and stays
deterministically testable — a 6000-deep fixture reliably overflows on any platform.
Tests: +2, mutation-proven (revert the wrapper to a bare call → the deep test throws
RED). Suite 431/0. Rule 2: OCP-internal validation, no wire change, no cli.js citation.
Closes#181
Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>
* fix(structured): narrow validateJsonSchemaSafe to RangeError-only + drop unused import (review fold-in)
Reviewer of #184: the catch-all would silently mask a future genuine bug (e.g. a
TypeError from a malformed schema) as a validation miss. Narrowed to
`if (e instanceof RangeError) return [...]; throw e;` so only the #181 deep-nesting
overflow becomes a refusal; any other throw surfaces at error level as before. +1
test proving a non-RangeError (required:42 → TypeError) re-throws. Dropped the now-
unused raw `validateJsonSchema` import from server.mjs. Merged current main (incl.
#183) so CI runs the true post-merge tree. Suite 433/0.
Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
* fix(server): assemble every assistant message so agentic turns return the final answer
`/v1/chat/completions` returned only the agent's opening preamble ("I'll find the
repo…") and silently dropped the post-tool-use final answer on every tool-using
(agentic) turn. The work still ran; OpenAI-compat clients (OpenClaw via ocp-connect,
OpenAI-SDK scripts) just received the preamble — the turn looked like it "did nothing."
Root cause: `parseStreamJsonEvent` extracted aggregate `assistant` text only when
`isFirstDelta` was true, and `isFirstDelta` flips false after the first text. Run
without `--include-partial-messages` the claude CLI emits NO content_block_delta
events — each assistant message arrives as its own aggregate `assistant` event — and
an agentic turn emits SEVERAL (preamble → one per tool round → final answer). So only
the first message's text survived; every later message, including the final answer,
was discarded.
Fix: guard on `sawTextDelta` (set only by a real content_block_delta) instead of
`isFirstDelta`. In aggregate mode (no deltas) accumulate the text of EVERY `assistant`
event, joined with a blank line; the delta+aggregate double-count case is still deduped
(a delta was seen ⇒ ignore the aggregate). Applied to both the buffered (`-p`) and
SSE-streaming paths, plus the mirrored parser + tests in test-features.mjs. 430 passed,
0 failed; added a multi-message agentic regression test.
Evidence — verified live, claude CLI 2.1.206 (`-p --output-format stream-json --verbose
--allowedTools Bash`): a preamble+tool+answer turn emits four `assistant` events, two
carrying text — #2 "Starting the task now." (preamble) and #4 "It printed
AGG_EVIDENCE_99." (final answer, after the Bash tool). Old code returned only #2; new
code returns both.
Endpoint class: B.1 (OpenAI-compatibility surface, `/v1/chat/completions`).
Specification: OpenAI chat/completions — the assistant `message.content` (and the
concatenation of streamed `choices[].delta.content`) carries the model's full response
text (https://platform.openai.com/docs/api-reference/chat/create).
Authorizing ADR: ADR 0006 — OpenAI shim scope. cli.js does NOT perform this operation
(it speaks Anthropic's protocol, not OpenAI's); scope is justified under ADR 0006 Class
B.1. No new endpoint, header, request field, or response field — this only fixes how
OCP assembles claude CLI stream-json output into the existing `content` field. No Class A
(cli.js-mirror) surface is touched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Merge origin/main into #183 + align streaming-path separator to the buffered guard (reviewer LOW-cosmetic parity)
---------
Co-authored-by: vvlasy-openclaw <vvlasy@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: dtzp555 <dtzp555@gmail.com>
* feat(server): honor OpenAI response_format for structured-output clients
`/v1/chat/completions` advertises OpenAI compatibility but ignored
`response_format`, so clients requiring machine-parseable JSON (Home Assistant
AI Tasks, Honcho, OpenAI-SDK scripts) received free-form assistant prose —
markdown tables, ```json fences, trailing commentary — that fails JSON.parse.
This honors the OpenAI `response_format` contract on the `-p` path:
- New `lib/structured-output.mjs` (pure, unit-tested): `detectStructuredOutput`
(json_schema / json_object), `structuredSystemInstruction` (strict JSON-only
steering, escalated on retry), `extractJsonPayload` (string-aware balanced
slice that unwraps fences/prose), and a minimal JSON-Schema `validateJsonSchema`
(types, required, enum, const, additionalProperties, nullability, items,
min/maxItems).
- `server.mjs`: `runStructuredCompletion` retries up to
`OCP_STRUCTURED_MAX_ATTEMPTS` (default 3), returns the canonical JSON string as
`message.content`, and yields HTTP 422 (`invalid_response_error`) if no valid
JSON can be produced. Structured requests take their own path (bypass the cache,
which does not key on response_format). Non-structured requests are byte-for-byte
unchanged, streaming included.
- Nullability precedence: a `null` value is accepted whenever the schema permits
null (`type:["x","null"]` / `nullable:true`), even if a bare `enum` omits null —
matches OpenAI behaviour and fixes real Home Assistant schemas
(`type:["string","null"], enum:["Loxone"]`) that otherwise 422 on null.
- README: Structured Outputs section + `OCP_STRUCTURED_MAX_ATTEMPTS` env row.
- 18 new unit tests (281 passed, 0 failed).
Endpoint class: B.1 (OpenAI-compatibility surface, `/v1/chat/completions`).
Specification: OpenAI chat/completions `response_format`
(https://platform.openai.com/docs/api-reference/chat/create#chat-create-response_format).
Authorizing ADR: ADR 0006 — OpenAI shim scope. cli.js does NOT perform this
operation (it speaks Anthropic's protocol, not OpenAI's); scope is justified under
ADR 0006 Class B.1 (OpenAI spec as protocol authority). Revives closed PR #99.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(server): structured-output caching + json_mode alias
Follow-up on the response_format path, closing the two gaps vs closed PR #99:
- **Validated caching (improves on #99).** Structured responses now use the OCP
cache when CLAUDE_CACHE_TTL>0, on a structured-keyed hash: cacheHash gains an
`structured` marker folding the detected response_format/schema into the key,
so a JSON reply never collides with the conversational answer to the same
prompt and different schemas never share a slot. Only a *validated* result is
written back — a 422 is never cached. (#99 cached the fence-stripped but
*unvalidated* output; this caches only schema-valid JSON.) The marker is absent
for normal requests, so existing cache hashes are byte-identical.
- **json_mode alias.** Honor the non-standard top-level `json_mode: true` flag as
a json_object alias, matching #99's activation set. Disclosed as non-spec.
- README: json_mode shape + caching note. +2 unit tests (283 passed, 0 failed).
Endpoint class: B.1 (/v1/chat/completions), ADR 0006. json_mode is a non-OpenAI
convenience alias (disclosed); everything else stays within OpenAI's response_format spec.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(server): address PR #153 review — $ref/strict, extraction safety, refusal, singleflight
Remediates the maintainer's merge-blocking findings on the structured-output PR, and
rebases onto current main (the one-hunk test-features.mjs conflict — both sides
appended tests — resolved by keeping both blocks). Class B.1 (OpenAI-compat): spec
authority is OpenAI chat/completions `response_format`
(https://platform.openai.com/docs/api-reference/chat/create#chat-create-response_format),
authorized by ADR 0006. No cli.js analogue (claude -p has no native response_format);
scope is the B.1 shim, not a Class A forward.
Finding 1 (correctness gate) — strict:true + $ref/$defs rejected valid objects 100%
of the time. `noExtra = addl === false || (strict && addl === undefined)` treated a
nested {$ref:"#/$defs/step"} as an empty-properties object and, under strict, rejected
every real key as "additional property not allowed" — exactly the shape the OpenAI SDK
emits (zodResponseFormat / client.beta.chat.completions.parse) and OpenAI's own docs
example. Fix: validateJsonSchema now resolves same-document $ref against the root
$defs/definitions, handles allOf/anyOf/oneOf composition, and only infers
additionalProperties:false from strict when the object actually declares its own
non-empty properties and is not a composite. Explicit additionalProperties:false is
always honoured, so validation is not weakened (tests prove an extra key and a missing
required key still fail under strict).
Finding 2 (correctness gate) — the extractor served JSON the model did not mean.
json_object mode had no validation at all: a refusal like `I can't. The schema is
{"type":"object"}` returned the embedded object as the answer. Now json_object requires
the WHOLE reply to parse as a single JSON value, and schema mode rejects a reply
carrying more than one top-level JSON value (Schema:{}/Answer:{}, Option A/Option B)
rather than silently picking the first. The schema-validated value is still returned;
nothing unvalidated is served.
Finding 3 — replaced the invented `invalid_response_error` 422 with OpenAI's assistant
`refusal` field (200, content:null, refusal:<reason>, finish_reason:"stop"), streaming
and non-streaming, so SDK clients take their refusal branch instead of throwing an
opaque UnprocessableEntityError.
Finding 5 — runStructuredCompletion no longer bypasses stampede protection. Identical
concurrent one-off structured requests now share one singleflight (independent of cache
enablement), so N callers no longer cost N × up-to-3 spawns. Cache read/write still
gated on CLAUDE_CACHE_TTL; refusals are never cached.
Docs: README structured-output § updated (refusal field, $ref/composition support,
whole-reply json_object rule, ambiguous-multi-value rejection) plus a Caching & cost
paragraph stating the post-2026-06-15 model, the up-to-N-spawn worst case, the
singleflight + validated-cache guards, and the OCP_STRUCTURED_MAX_ATTEMPTS=1 / per-key
quota levers.
Tests: +11 (all pure-module) — OpenAI's doc $ref/$defs schema under strict:true accepts
a conforming reply and still rejects extra/missing keys; anyOf/allOf; unresolvable $ref
skipped; json_object refusal-embedded-json rejected; >1-top-level-value rejected. 360
passed, 0 failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(server): address PR #153 review round 2 — cyclic-$ref guard + NaN attempts guard
Remediates the two remaining merge-blocking findings from the round-2 review. Class B.1
(OpenAI-compat): spec authority is OpenAI chat/completions `response_format`
(https://platform.openai.com/docs/api-reference/chat/create#chat-create-response_format),
authorized by ADR 0006. No cli.js analogue (claude -p has no native response_format, and
the retry cap OCP_STRUCTURED_MAX_ATTEMPTS is OCP's own coercion loop, not a cli.js
operation); scope stays the B.1 shim, not a Class A forward.
BLOCKER — cyclic $ref stack-overflowed the validator. resolveRef + the $ref branch of
validateJsonSchema had no cycle detection: a pure ref→ref cycle
({$defs:{a:{$ref:b},b:{$ref:a}},$ref:a}) recursed independent of the data and threw
RangeError for ANY reply value (even `5`), caught upstream as a 500 but only after 1–3
metered spawns — a request-controlled cost-amplification / grief vector on an authed
path. Fix: validateJsonSchema now threads a `refChain` of the $ref pointers resolved on
the current path WITHOUT consuming data (a $ref hop, or an allOf/anyOf/oneOf branch — all
re-validate the same value); a pointer reappearing on that chain fails closed with a
`cyclic $ref detected` error. Data-consuming recursion (properties/items/
additionalProperties) deliberately resets the chain, because a JSON value is a finite
tree so those always terminate — a legitimately recursive schema (Node→child:Node) must
NOT be flagged. A REF_DEPTH_CAP backstops any threading mistake.
MUST-FIX — OCP_STRUCTURED_MAX_ATTEMPTS NaN guard was broken. `Math.max(1, parseInt(env
||"3",10))` === `Math.max(1, NaN)` === NaN for a non-integer value, so the retry loop
`attempt < NaN` never ran → 0 spawns, every structured request silently refused (fails
closed on cost but bricks the feature and ignores the intended floor). Fix: extracted a
pure fail-closed resolveMaxAttempts() into lib/structured-output.mjs — rejects
NaN/non-finite/<1, keeps the documented default of 3, and warns at startup. server.mjs
now derives STRUCTURED_MAX_ATTEMPTS through it.
Tests: +8 (all pure-module) — a→b→a and self (a→a) cyclic $ref fail closed without
overflowing the stack; a cycle routed through anyOf; a legitimate recursive Node schema
is NOT flagged; resolveMaxAttempts honors valid integers, defaults on unset/empty/null,
and fails closed (not NaN, not 0) on abc/0/-1/NaN/Infinity/blank with a startup warn.
368 passed, 0 failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: vvlasy-openclaw <vvlasy@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: dtzp555 <dtzp555@gmail.com>
* feat(chat): forward OpenAI image_url parts to Claude (multimodal vision)
`POST /v1/chat/completions` previously flattened every message to plain text
via contentToText(), replacing image_url parts with "[non-text content
omitted]" — so images were silently dropped (issue #110). This adds real
multimodal support: OpenAI `image_url` content parts are translated to
Anthropic image blocks and fed to the Claude CLI over
`--input-format stream-json`, keeping subscription auth (the reason OCP routes
through the CLI rather than the API).
Class B.1 (OpenAI-compatibility surface), authorized by ADR 0006. Request
shape follows OpenAI's published vision / chat-completions spec
(https://platform.openai.com/docs/guides/vision and the chat/completions
`content` image_url part) — no field is introduced beyond OpenAI's shape. The
CLI's `--input-format stream-json` is the transport for this Class B endpoint,
not a forwarded cli.js operation, so there is no Class A cli.js citation to
make; scope is justified under the ALIGNMENT.md Class B mapping of Rule 2
(no invention beyond the cited OpenAI spec).
Mechanism (verified empirically against the installed CLI, v2.1.206): a user
message whose `content` is an Anthropic block array including
`{type:"image",source:{type:"base64",media_type,data}}` fed to
`claude -p --input-format stream-json` is correctly described by the model.
Confirmed live end-to-end through this endpoint (a base64 PNG returns the
correct color).
Design:
- New pure module `lib/multimodal.mjs` (mirrors the lib/*.mjs pattern; unit-
testable without a live server): hasImageContent, buildImageBlocks,
buildStreamJsonInput, MultimodalError.
- server.mjs: text path is byte-for-byte unchanged. Only when a request carries
an image_url part does spawnClaudeProcess switch stdin to a stream-json user
envelope and buildCliArgs add `--input-format stream-json`. Image parsing runs
before any stats mutation so a validation failure never leaks counters/slots.
- Images bypass the text char budget (CLAUDE_MAX_PROMPT_CHARS) and are bounded
by explicit byte/count caps with clear 4xx errors (413 for size/count, 400
for malformed/unsupported/disabled-remote), never a silent drop.
Scope decisions (v1):
- Base64 data URIs supported by default (image/jpeg,png,gif,webp).
- Remote http(s) image URLs OFF by default behind CLAUDE_IMAGE_ALLOW_URL; when
enabled they are passed through as an Anthropic url-source (OCP never fetches
the URL itself, so no OCP-side SSRF surface).
- Audio/file parts deferred: existing placeholder behavior preserved.
- Images anywhere in multi-turn history, not just the last message.
New env vars (documented in README Environment Variables table):
CLAUDE_IMAGE_ALLOW_URL, CLAUDE_MAX_IMAGE_BYTES, CLAUDE_MAX_IMAGES,
CLAUDE_MAX_IMAGE_TOTAL_BYTES, CLAUDE_MAX_BODY_SIZE (now configurable; default
5 MB unchanged).
Tests: 26 unit tests in test-features.mjs covering data-URI parse, multiple
images, text/image ordering, multi-turn history images, malformed/oversized/
too-many handling, remote-URL policy, and text-path parity. `npm test` green
(289 passed). `node --check` clean. No alignment-blacklist tokens added.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(chat): address PR #154 review blockers — TUI guard, fail-closed caps, text budget
Remediates the three merge-blocking findings from the maintainer's review of the
multimodal vision PR. Class B.1 (OpenAI-compat surface): request shape per OpenAI
vision spec (image_url content parts), authorized by ADR 0006. No new wire shape
and no cli.js surface change — the stream-json image contract is the CLI's native
input format (already cited in the base commit); these are correctness fixes on the
OCP-owned validation/dispatch layer.
F1 — TUI mode silently dropped images and returned 200. callClaudeTui() renders
every non-text part as "[non-text content omitted]", so a vision request in
CLAUDE_TUI_MODE=true was answered about an image the model never saw. Now
handleChatCompletions fails loudly with 400 images_unsupported_in_tui_mode instead
of a silent drop (ALIGNMENT.md forbids serving text the model did not mean).
Documented in README § Images / Multimodal.
F3 — NaN env parsing failed open. CLAUDE_MAX_BODY_SIZE=unlimited -> NaN ->
`body.length > NaN` always false -> body cap gone (OOM DoS); =5MB -> 5 bytes ->
proxy bricked; same on CLAUDE_MAX_IMAGES / _IMAGE_BYTES / _IMAGE_TOTAL_BYTES.
Added lib/env.mjs parsePositiveInt (pure, fail-closed) + a thin parseIntEnv warn
wrapper; a malformed cap now keeps the safe default and warns at startup.
F2 — images let unbounded text bypass MAX_PROMPT_CHARS. buildImageBlocks only
counted textChars and never truncated; the budget was never passed in. Threaded
maxTextChars (= MAX_PROMPT_CHARS) into the multimodal transform, which now
truncates text tail-first (mirroring messagesToPrompt) while preserving image
blocks, and logs prompt_truncated.
Tests: +11 in test-features.mjs (all pure-module, per the repo's no-server-import
pattern) covering the text-budget enforcement and the fail-closed cap parsing,
including the exact F2 (500k chars + 1 image) and F3 (unlimited/5MB/0/20.5)
scenarios. 300 passed, 0 failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(server): address PR #154 review round 2 — MAX_PROMPT_CHARS fail-closed + system-only image guard
Closes the two residual gaps from the round-2 review, both traced to the same root as the
already-fixed blockers. Class B.1 (OpenAI-compat vision): authorized by ADR 0006; request
shape is the OpenAI `image_url` content part. No new wire shape — the Anthropic image block
over `--input-format stream-json` is the CLI's native contract (cli.js buildStreamJsonInput
path, verified live in round 1). MAX_PROMPT_CHARS is OCP's own truncation guard, not a cli.js
operation.
Gap (a) — MAX_PROMPT_CHARS was left on the raw parseInt while every other cap moved to the
fail-closed helper. `let MAX_PROMPT_CHARS = parseInt(env||"150000",10)` sat five lines above
the parseIntEnv helper this PR added, so CLAUDE_MAX_PROMPT_CHARS=unlimited → NaN →
enforceTextBudget's `!(NaN > 0)` early-return → 500k chars passed unbounded, truncated:false,
silently defeating F2's text-budget guarantee under a plausible operator config. Fix: hoist
parseIntEnv above the declaration and derive MAX_PROMPT_CHARS through it (keeps `let` for the
settings API). A misconfigured value now keeps the 150k default and warns, like the other caps.
Gap (b) — an image present ONLY in a system message silently dropped in non-TUI mode.
Detection runs on the full message list, but extraction/spawn filter role==="system" out, so a
system-only image was detected as multimodal, survived no filter, fell to the text path, and
rendered as "[non-text content omitted]" → 200 with a hallucinated answer — the one silent-drop
outcome F1 exists to forbid. Fix: after filtering, if hasImageContent(full) is true but no image
survives, return `400 images_unsupported_in_system_messages`. Narrow (OpenAI disallows images in
the system role) so no legitimate request is rejected. Documented in README § Images.
Tests: +4 (all pure-module) — parsePositiveInt('unlimited') keeps the 150k default (gap a) and a
valid override is honored; hasImageContent proves the guard predicate fires for a system-only
image (true on full list, false after the system filter) and does NOT fire for a user-message
image. 304 passed, 0 failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: vvlasy-openclaw <vvlasy-openclaw@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: vvlasy-openclaw <vvlasy@gmail.com>
Co-authored-by: dtzp555 <dtzp555@gmail.com>
* feat(server): SPOT-derived prompt budget — MAX_PROMPT_CHARS default follows models.json (ADR 0009)
Maintainer directive (2026-07-18): the hand-set 150,000-char default (~37.5k English
tokens) is obsolete in the long-context era. Instead of a new constant that would rot
the same way, the default now derives from the SPOT:
MAX_PROMPT_CHARS (default) = max(models.json contextWindow) x 3 chars/token
= 200000 x 3 = 600,000 chars today (~150-200k tokens)
x3 is the CJK-safe multiplier: English runs ~4 chars/token, CJK ~1-1.5, so a
1M-token-derived char cap would let CJK text sail past the model's real window into
an upstream rejection; at x3 the cap fires at roughly the model's true window and OCP
truncates gracefully (tail-first) instead. Pure derivePromptCharBudget() in
lib/prompt.mjs with a 150k floor guarding degenerate SPOT states (empty models[],
absent contextWindow) - a zero budget would truncate every request to nothing.
CLAUDE_MAX_PROMPT_CHARS (env) and the runtime settings API remain ABSOLUTE overrides;
derivation applies only when neither is set. If models.json ever advertises a larger
window (e.g. 1M for the 1M-native models), the budget scales automatically - that
advertisement is a separate deliberate decision (quota burn, OpenClaw compaction, TUI
paste limits) explicitly NOT made here; see ADR 0009.
Behavior change (intended): requests between 150k and 600k chars previously truncated
now pass through whole - longer TTFT + higher quota use for those requests. Truncation
mechanism/logging unchanged; only the default's provenance changed.
ALIGNMENT.md Rule 2: no cli.js citation applies - the truncation guard is OCP-internal
prompt shaping; no endpoint, header, or wire field changes.
Tests: +4, doubly mutation-proven (max->min fails the largest-window test; dropping
the floor fails the floor test). Suite 347 passed / 0 failed. ADR 0009 + index row +
README env-table row included (release_kit: env var default change documented).
Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>
* fix(server): empty CLAUDE_MAX_PROMPT_CHARS falls back to derived default (PR #179 review)
Reviewer regression: `!= null` treated an EMPTY env value ("CLAUDE_MAX_PROMPT_CHARS="
in an EnvironmentFile/.env) as explicit -> parseInt("") = NaN -> guard disabled + a
false "[System] Note: 0 older messages were truncated" injected into every prompt.
Extracted resolvePromptCharBudget() (truthiness contract, matching the old
`parseInt(env || default)` behavior) into lib/prompt.mjs so the semantics are
mutation-tested: switching back to != null fails the empty-string test. +2 tests, 349/0.
Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
* chore(release): v3.23.0 — sonnet-5 default, upgrade reliability, CLAUDE_SYSTEM_PROMPT, README restructure
Consolidates #167/#168/#170-#177 (merged since the v3.22.1 tag). 3.22.1 → 3.23.0.
Minor because #168 changes the default model for every request that omits
`model` (sonnet alias 4-6 → 5) and #175 makes CLAUDE_SYSTEM_PROMPT functional —
behavior changes and a newly-working env var; not major because no API surface
breaks and pinning restores the old default.
Release-kit walk: CLAUDE_SYSTEM_PROMPT README row added in #175 (incl. cache
caveat); Available Models table updated in #168 (sonnet-5 = default); no new
endpoint; models.json alias change is the SPOT edit; new docs/ files indexed in
Repository Layout (#172); bootstrap quirks retained in README §Troubleshooting.
Version sourced from package.json only (no stale refs — grepped).
Tag push v3.23.0 at this squash commit triggers release.yml.
Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>
* docs(readme): CLAUDE_SYSTEM_PROMPT cache caveat updated — #177 made the flush obsolete (release reviewer F1)
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
The cache key hashed model + keyId + sampling params + raw messages, but the
ANSWER also depends on boot-time server config that shapes the composed prompt
and tool surface: CLAUDE_SYSTEM_PROMPT (newly load-bearing since #175),
OCP_SYSTEM_PROMPT_WRAPPER, CLAUDE_ALLOWED_TOOLS, and CLAUDE_NO_CONTEXT. The
cache store is SQLite-backed and survives restarts, so an operator who changed
any of these and restarted kept serving answers composed under the OLD config
until TTL expiry (found by the #175 independent reviewer).
Fix: server.mjs computes CONFIG_EPOCH once at boot — a 16-hex sha256 digest of
the four values — and passes it to cacheHash, which folds `ce:<epoch>|` into the
key. Any config change = instant whole-cache invalidation (the honest behavior).
Callers that omit configEpoch (tests, any older path) hash byte-identically to
before — asserted by test. Runtime-mutable settings (maxPromptChars via the
settings API) are deliberately excluded: a const epoch cannot track them, and
truncation drops context rather than changing the instruction set (noted in the
code comment).
One-time side effect on upgrade: existing cache entries no longer match (keys
now carry the epoch). Cache is off by default and TTL-bounded; a one-time miss
storm is the cost of never serving stale-config answers again.
ALIGNMENT.md Rule 2: no cli.js citation applies — cache-key composition is
OCP-internal (Class B); no endpoint, header, or wire field changes.
Tests: +2, mutation-proven (dropping the fold fails both). Suite 343/0.
Closes#176
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
* fix(server): wire CLAUDE_SYSTEM_PROMPT into the composed system prompt (was dead since APPEND_SYSTEM_PROMPT retirement)
The var was read (SYSTEM_PROMPT, server.mjs), documented in the file header as
"appended to all requests", and echoed on /health.systemPrompt — but nothing on
the request path consumed it: extractSystemPrompt() composed only the wrapper +
client system messages. The wiring was lost when APPEND_SYSTEM_PROMPT was
retired, leaving the header comment and the buildCliArgs comment describing
behavior that did not exist (caught by the PR #170 independent reviewer).
Wire, not delete, because:
- the /health `systemPrompt` field is part of the grandfathered B.2 contract
(ADR 0006, frozen at v3.16.4) — removal would need an ADR; wiring keeps the
shape and makes the field honest;
- fleet check: no deployment sets the var (Mac prod plist, Oracle systemd unit,
PI231 /etc/ocp/ocp.env all clean), so wiring changes behavior for NOBODY today;
- with the var unset, appendOperatorPrompt returns its input string unchanged —
the default path is byte-for-byte identical.
Mechanics: new pure lib/prompt.mjs `appendOperatorPrompt(base, operatorAppend)`
(trimmed; whitespace-only treated as unset so a stray space in a service unit
cannot inject "\n\n " into every request), applied as the LAST segment in both
extractSystemPrompt branches — an operator-wide directive reads as the final
instruction, after client system messages. TUI-mode is untouched (panes keep the
interactive CLI's own system prompt); documented in the README row.
ALIGNMENT.md Rule 2: no cli.js citation applies. No endpoint, header, or wire
field is added or altered; the change affects only the CONTENT OCP passes to the
already-established `--system-prompt` flag (file header § verified v2.1.104),
i.e. OCP-owned prompt composition. /health shape unchanged.
Tests: +3, doubly mutation-proven (unconditional-base revert → 2 failures;
trim removal → 2 failures). Suite 341 passed / 0 failed.
Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>
* docs(readme): cache-staleness caveat on CLAUDE_SYSTEM_PROMPT row (reviewer advisory)
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
Two fixes for the two halves of issue #173, both from live incidents during the
2026-07-17 fleet update:
1. scripts/doctor.mjs — `git show origin/main:package.json` reads the LOCALLY
CACHED remote ref; without a fetch first, any machine that hadn't pulled since
the last release saw latest == current and reported "Already at latest" (live
repro: Oracle VM at 3.21.1 with v3.22.1 released). The doctor now runs
`git fetch --tags --quiet` (15s timeout) before comparing, gated on
!opts.skipNetwork; on failure (offline/auth) it falls through to the cached
ref — the pre-existing behavior. All existing doctor tests pass mockLatest +
skipNetwork, so no test touches the network.
2. scripts/upgrade.mjs — post-flight accepted any healthy /health (auth.ok only),
so a stale process holding the port passed post-flight while still serving the
OLD version (live repro: a Jul-7 nohup-fallback orphan held :3456; upgrade
"succeeded", /health kept serving 3.21.1). New exported predicate
postFlightOk(body, target): auth.ok AND /health.version === target (leading-v
tolerant; empty target degrades to the auth-only check, never blocks). The
failure message now reports the last-seen version and points at the
stale-process diagnosis (`ss -ltnp` / `lsof -i`).
Tests: +4, mutation-proven — reverting the predicate to auth-only fails the
"orphan case" test (337/1). Full suite 338 passed / 0 failed.
No server.mjs change — scripts layer only; no cli.js operation involved, so no
citation applies.
Closes#173
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
* fix(upgrade): make snapshot paths Windows-safe
Use a filesystem-safe UTC timestamp for new upgrade snapshot directories while retaining legacy ISO timestamp parsing. Normalize test paths with node:path so the Windows suite exercises the same behavior.
Co-Authored-By: claude-flow <ruv@ruv.net>
* fix(upgrade): sort snapshots by parsed timestamp
Order legacy colon and Windows-safe dash snapshot names chronologically so rollback and retention keep the actual newest snapshot across the migration boundary.
Co-Authored-By: claude-flow <ruv@ruv.net>
---------
Co-authored-by: nyxst4ck <nyxst4ck@users.noreply.github.com>
Co-authored-by: claude-flow <ruv@ruv.net>
Co-authored-by: nyxst4ck <289980115+nyxst4ck@users.noreply.github.com>
Maintainer-approved P2 restructure. Principle: what a new user needs in the first
10 minutes stays in README; the operations manual moves to docs/. Content MOVED,
not rewritten — an independent verifier swept all 20 original sections, 60+
distinctive facts, and all table rows against the new corpus: zero content loss.
New files (verbatim moves + two mandated dedup merges):
- docs/lan-mode.md (396) — LAN setup, key management, quotas, anonymous access,
deployment/security model + honest limits, client connect
- docs/tui-mode.md (196) — full TUI section + the four giant env-cell essays as
prose subsections; opens with the single-user SECURITY
warning + the PAUSED billing-split status banner
- docs/troubleshooting.md (136) — full troubleshooting; canonical 401/credential-
isolation explanation (union of the 4 prior copies)
- docs/upgrading.md (79) — upgrade paths, snapshots, rollback, auto-sync
README keeps: pitch (byte-identical), new TOC, 62-line Quickstart, How It Works
verbatim (incl. #171 billing-status note + workload fit), the three release_kit-
pinned tables (Available Models / API Endpoints / all 37 Environment Variables
rows — 4 giant TUI cells now one-line pointers), All Commands, slim Troubleshooting
(bootstrap quirks retained per release_kit bootstrap_quirk_policy), summary stubs
linking each moved doc, Repository Layout (+4 doc rows), Governance, Support.
Dedup (canonical copies): sdk-cli vs subscription-pool table → docs/tui-mode.md;
credential-isolated-home / permanent-401 → docs/troubleshooting.md#tui-401.
Link retargets: docs/runbooks/615-canary.md, docs/runbooks/tui-flip-rollback.md,
setup.mjs (one banner string; node --check clean). 126 links across the touched
files verified resolving; repo-wide grep shows no reference to a removed anchor.
npm test: 332 passed / 0 failed.
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
P0 from the README audit, direction set by the maintainer's history lesson: the
2026-06-15 -p billing split was announced (2026-05-14) but PAUSED by Anthropic on
the effective date — officially: "For now, nothing has changed: Claude Agent SDK,
claude -p, and third-party app usage still draw from your subscription's usage
limits" (support.claude.com article 15036540). The README asserted the split as
in-force in five places while the top-of-README "$0 / no API billing" pitch said
the opposite — a self-contradiction where the PITCH was the currently-true half.
- How It Works: added the dated billing-policy status note (official quote + link);
the "$0" claims stand, now anchored to it.
- TUI § "What it is and why": status banner; the cc_entrypoint table relabeled
"announced regime, currently paused"; TUI-mode reframed as the ready-made HEDGE
for if/when a reworked change lands (advance notice promised).
- "2026-06-15 operator checklist" -> "Operator checklist for the (paused) billing
split": nothing needs flipping while the pause holds; checklist retained verbatim
as the re-landing runbook.
- OCP_SKIP_AUTH_TEST env row + /health tui-block prose: conditioned on the paused
regime instead of asserting it.
- Client-tools boundary: new "workload fit" paragraph (maintainer-approved
positioning): LAN/multi-device OCP = chat-class workloads; client-machine
coding agents are architecturally out of scope (tools execute on the OCP host)
— run claude/OCP on the machine where the code lives.
Lesson encoded: policy-dependent claims carry dates and the official source, so
the next policy swing is an edit, not an archaeology dig.
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
* docs(readme): sweep stale content — 6 models, drop phantom `ocp stop`, document 2 live env vars, fix ocp-connect claim
P1 findings from a full README-vs-code staleness audit (each verified against the
tree at 0c3e42b):
- "5 models" x4 (L124/151/174/323) -> 6; the /v1/models curl example (L244) and
ocp-connect sample output (L353) now include claude-sonnet-5 (added #152).
- Troubleshooting told users to run `ocp stop`, which has never existed (the ocp
case table has no stop) -> replaced with the real launchctl/systemctl commands
+ a note that stopping goes through the service manager.
- CLAUDE_SYSTEM_PROMPT (server.mjs:326, applied at :1084) and CLAUDE_MCP_CONFIG
(server.mjs:1124 -p path; lib/tui/session.mjs:447 FULL_TOOLS panes) are live
config with no env-table row -> added both rows.
- "ocp-connect detects and configures Claude Code, Cursor, ..." over-claimed:
it auto-configures OpenClaw only, prints hints for Cursor/Cline/Continue/opencode,
and has no Claude Code logic at all (OCP exposes an OpenAI-compat surface;
Claude Code speaks the Anthropic protocol) -> reworded L28 + removed Claude Code
from the client-connect prompt template (L160).
- Upgrade examples presented v3.14 as the frontier -> refreshed to current-era
numbers (v3.21.0->v3.21.1 patch, v3.18->v3.22 cross-minor). Historical feature
attributions ("as of v3.14.0", bootstrap-quirk notes) kept — they are facts.
Docs only; no code change. P0 (billing status note) and P2 (structure) are
tracked separately.
Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>
* docs(readme): drop the CLAUDE_SYSTEM_PROMPT row (dead env var) + fix systemd stop to --user
Reviewer traced consumers: SYSTEM_PROMPT (server.mjs:326) is only echoed on
/health (:2906) and the startup log (:3270) — extractSystemPrompt() never reads
it, so the row documented behavior that does not exist. The dead var + the two
stale in-code comments (server.mjs:19, :1084) go on the backlog: wire it or
remove it (a server.mjs change, out of this docs PR's scope).
Also: setup.mjs installs the systemd unit under --user (:514), so the
Troubleshooting stop line loses the sudo and gains --user.
Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
* feat(models): add Claude Sonnet 5 to models.json SPOT
Adds `claude-sonnet-5` (the latest Sonnet, supported by claude CLI >= 2.1.206)
to models.json — the single source of truth (ADR 0003). Both the /v1/models
endpoint and setup.mjs OpenClaw registration derive from it automatically.
- New model entry `claude-sonnet-5` (reasoning, 200k ctx, 16k max tokens),
mirroring the existing Sonnet entry shape.
- Point the `sonnet` alias at `claude-sonnet-5` (newest Sonnet), consistent
with `opus` -> `claude-opus-4-8`. Previous `claude-sonnet-4-6` is retained
for pinning.
- README "Available Models" table updated (release-kit 5.3).
- Update the aliases.sonnet SPOT test to the new default.
Endpoint class: B.1 (/v1/models), data-only via the models.json SPOT.
Authorized by ADR 0006 (OpenAI shim scope) + ADR 0003 (models.json SPOT).
Verified: `claude --model claude-sonnet-5 -p` returns a valid response on a
current subscription CLI (2.1.206); npm test green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(models): make PR #152 purely additive + close ocp-connect drift + real SPOT test
Addresses the maintainer's review. Rescopes this PR to the additive change only —
adding claude-sonnet-5 to models.json — and defers the `sonnet` alias repoint to
its own PR per Iron Rule 11 (the alias is the default for every request that omits
`model`; repointing it is a behavior change that deserves separate review + a
CHANGELOG entry). No server.mjs change, so no cli.js citation required.
Metadata confirmed unchanged: contextWindow 200000 / maxTokens 16384 stay, per the
maintainer's correction (OCP truncates at MAX_PROMPT_CHARS, and contextWindow feeds
OpenClaw's compaction budget — advertising a larger window than OCP delivers just
makes OpenClaw overshoot).
Fixes vs review:
1. Reverted `aliases.sonnet` back to claude-sonnet-4-6 — this PR only *adds* the
model; the repoint ships separately. README updated to match (5 is available by
full ID; 4-6 remains the alias default).
2. Replaced the tautological SPOT test. The old assertion read a literal out of
models.json and asserted it equalled the same literal — it passed even with a
dangling alias. Added referential-integrity tests: every aliases/legacyAliases
value must resolve to a real models[].id, plus an explicit assertion that
claude-sonnet-5 exists in models[]. This is the guard that actually catches an
alias pointing at a non-existent model (VALID_MODELS keys on alias names, never
targets, so nothing else checks this).
3. Fixed ocp-connect classification drift. Its prefix table pinned "claude-sonnet-4",
which misses "claude-sonnet-5" and falls through to the non-reasoning / 8k-output
default. Broadened both the model_meta and alias_prefixes tables to family
prefixes (claude-opus / claude-sonnet / claude-haiku) so any future versioned ID
classifies correctly with no per-model edit. /v1/models does not expose
reasoning/maxTokens (OpenAI /v1/models schema has no such fields — adding them
would be a Rule 2 invention), so family classification stays in ocp-connect.
primary_model stays claude-sonnet-4-6, matching the (unchanged) sonnet alias — it
moves with the alias in the repoint PR.
Tests: 266 passed, 0 failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(models): repoint default `sonnet` alias to claude-sonnet-5
Split out from #152 per Iron Rule 11: the additive model entry (#152) lands the
claude-sonnet-5 metadata; this PR makes the behavior change — moving the default
`sonnet` alias from claude-sonnet-4-6 to claude-sonnet-5.
`aliases.sonnet` is the model used for every /v1/chat/completions request that omits
`model` (server.mjs default) and, via ocp-connect, OpenClaw's OCP primary. Repointing
it changes behavior for every such client, so it gets its own PR + CHANGELOG entry
separate from the additive entry.
- models.json: aliases.sonnet -> claude-sonnet-5 (claude-sonnet-4-6 kept by full ID
for pinning). Both are pricing tier_3_15 — no cost regression.
- ocp-connect: primary_model now prefers claude-sonnet-5 (falls back to 4-6, then
first model), tracking the alias default so OpenClaw's primary matches.
- README: swap the "default for sonnet alias" annotation onto claude-sonnet-5.
- CHANGELOG: Unreleased § Changed entry documenting the default change + how to pin.
- test: SPOT assertion updated to claude-sonnet-5; referential-integrity tests from
#152 continue to guard that the alias target actually exists in models[].
No server.mjs change, so no cli.js citation required.
Depends on #152 (needs the claude-sonnet-5 models[] entry to exist, else the
referential-integrity test fails). Rebase/merge after #152 lands.
Tests: 266 passed, 0 failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: vvlasy-openclaw <vvlasy@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: dtzp555 <dtzp555@gmail.com>
v3.22.0 (#166) was merged but never tagged; #161 and #152 then landed on main,
so the prepared release no longer matched HEAD. Owner opted to renumber: the
v3.22.0 CHANGELOG section becomes v3.22.1 (with an explicit version note),
gains entries for #161 and #152, and the tag will be cut at this release
commit — no tagging of historical commits needed. package.json 3.22.0 → 3.22.1.
Semver note: still a minor-family bump from 3.21.1 (features: #156/#158/#159,
plus #152's new model entry); 3.22.0 is simply skipped — semver requires
increasing versions, not contiguous ones.
Release-kit walk (delta vs #166's walk): #152 → README "Available Models"
table row already present (added in #152 itself) + models.json is the SPOT;
#161 → no new env var/endpoint; README §Windows guidance unchanged (Windows
support deliberately NOT advertised until #167 lands + real-Windows E2E).
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
* feat(models): add Claude Sonnet 5 to models.json SPOT
Adds `claude-sonnet-5` (the latest Sonnet, supported by claude CLI >= 2.1.206)
to models.json — the single source of truth (ADR 0003). Both the /v1/models
endpoint and setup.mjs OpenClaw registration derive from it automatically.
- New model entry `claude-sonnet-5` (reasoning, 200k ctx, 16k max tokens),
mirroring the existing Sonnet entry shape.
- Point the `sonnet` alias at `claude-sonnet-5` (newest Sonnet), consistent
with `opus` -> `claude-opus-4-8`. Previous `claude-sonnet-4-6` is retained
for pinning.
- README "Available Models" table updated (release-kit 5.3).
- Update the aliases.sonnet SPOT test to the new default.
Endpoint class: B.1 (/v1/models), data-only via the models.json SPOT.
Authorized by ADR 0006 (OpenAI shim scope) + ADR 0003 (models.json SPOT).
Verified: `claude --model claude-sonnet-5 -p` returns a valid response on a
current subscription CLI (2.1.206); npm test green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(models): make PR #152 purely additive + close ocp-connect drift + real SPOT test
Addresses the maintainer's review. Rescopes this PR to the additive change only —
adding claude-sonnet-5 to models.json — and defers the `sonnet` alias repoint to
its own PR per Iron Rule 11 (the alias is the default for every request that omits
`model`; repointing it is a behavior change that deserves separate review + a
CHANGELOG entry). No server.mjs change, so no cli.js citation required.
Metadata confirmed unchanged: contextWindow 200000 / maxTokens 16384 stay, per the
maintainer's correction (OCP truncates at MAX_PROMPT_CHARS, and contextWindow feeds
OpenClaw's compaction budget — advertising a larger window than OCP delivers just
makes OpenClaw overshoot).
Fixes vs review:
1. Reverted `aliases.sonnet` back to claude-sonnet-4-6 — this PR only *adds* the
model; the repoint ships separately. README updated to match (5 is available by
full ID; 4-6 remains the alias default).
2. Replaced the tautological SPOT test. The old assertion read a literal out of
models.json and asserted it equalled the same literal — it passed even with a
dangling alias. Added referential-integrity tests: every aliases/legacyAliases
value must resolve to a real models[].id, plus an explicit assertion that
claude-sonnet-5 exists in models[]. This is the guard that actually catches an
alias pointing at a non-existent model (VALID_MODELS keys on alias names, never
targets, so nothing else checks this).
3. Fixed ocp-connect classification drift. Its prefix table pinned "claude-sonnet-4",
which misses "claude-sonnet-5" and falls through to the non-reasoning / 8k-output
default. Broadened both the model_meta and alias_prefixes tables to family
prefixes (claude-opus / claude-sonnet / claude-haiku) so any future versioned ID
classifies correctly with no per-model edit. /v1/models does not expose
reasoning/maxTokens (OpenAI /v1/models schema has no such fields — adding them
would be a Rule 2 invention), so family classification stays in ocp-connect.
primary_model stays claude-sonnet-4-6, matching the (unchanged) sonnet alias — it
moves with the alias in the repoint PR.
Tests: 266 passed, 0 failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: vvlasy-openclaw <vvlasy@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(init): resolve Windows claude.exe only
Windows startup can resolve npm or Git Bash shims from PATH and then pass that path into shell-less child_process calls. Those .cmd, .bat, .ps1, or extensionless shim matches are not spawnable as the CLAUDE binary, so fail fast with a clear hint instead of returning an unusable path.
No cli.js citation: this only changes local startup binary discovery and fatal diagnostics. It does not change any Class A/Class B endpoint, header, request field, response field, or wire behavior.
Co-Authored-By: Codex <codex@openai.com>
* fix(init): simplify Windows claude lookup
Remove the redundant where.exe claude.exe probe and explain the intentional native .exe allow-list for shell-less Windows spawning.
Alignment: no cli.js citation applies; this changes only local executable discovery and fatal diagnostics, not a Class A or Class B wire operation.
Co-Authored-By: claude-flow <ruv@ruv.net>
---------
Co-authored-by: nyxst4ck <289980115+nyxst4ck@users.noreply.github.com>
Co-authored-by: Codex <codex@openai.com>
Co-authored-by: nyxst4ck <nyxst4ck@users.noreply.github.com>
Co-authored-by: claude-flow <ruv@ruv.net>
Consolidates #155–#165 into a minor release. Version 3.21.1 → 3.22.0.
Minor (not patch) because it adds user-facing opt-in features and new env vars
(OCP_TUI_EFFORT #156, OCP_TUI_POOL_SIZE #158, OCP_TUI_STREAM + OCP_TUI_STREAM_HOLDBACK/
_DIR/_POLL_MS #159/#160). All default OFF, so the default request path (-p /
--output-format stream-json) is byte-for-byte unchanged — no breaking change.
Release-kit walk (CLAUDE.md 5.5): all four new env vars already carry README
§ "Environment Variables" rows + dedicated § "How It Works" coverage (added by the
feature PRs); no new endpoint (TUI streaming reuses /v1/chat/completions); no
models.json change (Available Models table unchanged — #152 not merged). Version is
sourced from package.json (server.mjs VERSION = _pkg.version), so no other file needs
editing. The README:899 "pre-3.21.1" note is historical (the #148 boot-reap migration)
and stays.
Tag push (v3.22.0) triggers .github/workflows/release.yml to create the GitHub Release.
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
* fix(setup): never carry test-only key-store redirection vars into a server OCP launches (A4)
Defense-in-depth for the key-store isolation shipped in #163, plus a correction to the
overstated claim that fix's comments made. Surfaced by an independent (Codex) re-review.
Background: keys.mjs honors OCP_DIR_OVERRIDE only when NODE_ENV === "test", so the key store
can be pointed at a scratch dir for the test suite. If BOTH vars reached a production daemon's
environment, it would open a scratch/empty key store instead of ~/.ocp/ocp.db — in AUTH_MODE=multi
a silent total auth outage. #163's comments claimed a production server "runs without NODE_ENV, so
it CANNOT honor the override no matter how the variable got in." That is not something keys.mjs can
enforce — it is only true while the daemon's env happens to lack NODE_ENV=test. This PR makes it
true for every server OCP itself launches, and softens the docs to stop overclaiming.
Three parts (all in OCP's own launch/installer paths — no server.mjs change, no cli.js analogue):
1. scripts/lib/plist-merge.mjs — new exported NEVER_PRESERVE = {NODE_ENV, OCP_DIR_OVERRIDE},
stripped from the preserved set in BOTH mergePlistEnv and mergeSystemdEnv. The preservation
rule ("keys only in the EXISTING unit are kept verbatim") was the vector: a unit that once
carried these test-only vars would otherwise survive every setup re-run. setup.mjs's template
never injects them, so preservation was the only entry path, and this closes it.
2. ocp (cmd_restart manual fallback) — the one direct `node server.mjs` launch OCP controls now
runs under `env -u NODE_ENV -u OCP_DIR_OVERRIDE`, so a maintainer who exported both while
debugging and then restarted can't silently boot the daemon onto a scratch store.
3. keys.mjs + test-env.mjs — softened the overstated comments to state what is actually enforced
(the two-key gate makes neither var alone do anything; OCP's launchers strip both) and to name
the one residual path honestly: a hand-rolled `node server.mjs` with both vars explicitly
exported, bypassing every launcher — for which the loud getDb() "NOT the default" log is the
backstop. No library-level gate can catch an operator who both sets a test flag and bypasses
the launchers; the honest fix is a non-silent wrong-store, which #163 already provides.
Severity: LOW (defense-in-depth; the default/shipped path was already safe). No behavior change on
any correctly-configured install.
ALIGNMENT.md: this PR does not touch server.mjs, so the cli.js-citation hard requirement does not
apply; and no cli.js operation is involved — key-store isolation and installer env hygiene are
entirely OCP-owned (no Class A / cli.js-mirror surface).
Tests: +4 mutation-proven (3 behavioral: drop the `!NEVER_PRESERVE.has(k)` guard in either merge
fn and they fail — verified 326 passed / 3 failed under mutation; restored). The `ocp` bash
`env -u` line is verified by `bash -n` + inspection (the suite does not exec the installer/daemon).
Full suite: 329 passed / 0 failed (was 325).
Version bump + CHANGELOG deferred to the later chore(release) PR, per the repo's #148/#149/#150 ->
#151 convention (matching PR #164).
Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>
* test(setup): assert NEVER_PRESERVE.size === 2 so the "exactly two" test matches its name
Reviewer nit (LOW): the membership assertion let a future spurious third entry slip past a
test whose name promises "exactly the two". Behavior stays guarded by the 3 mutation-proof
tests; this just makes the contract test honest.
Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
Two OCP-internal correctness fixes on the TUI streaming/observation path, surfaced
by an independent (Codex) re-review. Neither touches the cli.js wire.
A1 — OCP_TUI_STREAM_HOLDBACK now has an enforced floor (DEFAULT_HOLDBACK_CHARS=100).
The C-1 auth-banner gate's first-message guarantee rests on the holdback being at
least the default banner detector's 100-char reach. The env var's own doc said
"Only raise it", but the code trusted the operator: a sub-floor value (e.g. 50) or a
NaN typo ("unlimited") let the first chars of a real auth banner stream to the client
before the end-of-turn detector could classify the whole message and reject the turn.
resolveStreamHoldback() clamps UP to the floor and returns {value, clamped}; server.mjs
emits a boot WARNING when it had to clamp. Default (unset) is unchanged and unflagged.
A3 — recordTuiEntrypoint() now runs the moment runTuiTurn() returns, BEFORE the honesty
gates (wall-clock truncation / auth banner / stream divergence) that throw. The entrypoint
(cli vs sdk-cli) is which billing pool the turn consumed; a turn that then fails a gate
STILL spent that pool, and those failed turns are exactly the ones most likely to signal a
silent degrade to the metered Agent SDK pool. The old placement recorded only on the success
path, so /health's lastEntrypoint and entrypointMismatches were blind to every failed turn —
the billing-drift signal missed the cases it most needed to catch. recordModelSuccess stays
on the success path. The catch block does not record the entrypoint, so there is no double
count; a client-disconnect (TuiAbortError) throws from inside runTuiTurn before the destructure,
so no phantom entrypoint is recorded.
ALIGNMENT.md Rule 2 (No Invention): no cli.js citation applies. cli.js does not perform either
operation — both are proxy-internal. A1 hardens OCP's own SSE holdback (a safety mechanism on
the Class B.1 OpenAI-compat streaming surface; wire format authority is the OpenAI spec via
ADR 0006). A3 reorders when OCP records its own /health observability counters (Class B.2,
grandfathered under ADR 0006; the TUI spawn authority is ADR 0007). No endpoint, header,
request field, or response field is added or altered; the bytes to and from cli.js are
byte-identical. This is observation/safety-layer hardening, not extension.
Tests: +5 mutation-proven unit tests for resolveStreamHoldback (deleting the floor clamp
fails 3 of them). Full suite 325 passed / 0 failed (was 320). A3 is a server.mjs control-flow
reorder; server.mjs is not imported by the test suite, so A3 is verified by reviewer inspection
of the diff, stated honestly here rather than vouched for by a test.
Version bump + CHANGELOG deliberately omitted: this is a fix PR, consolidated into a later
chore(release) PR per the repo's #148/#149/#150 -> #151 (v3.21.1) convention.
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
* fix(test): stop the suite writing live API keys into the operator's real key store
`npm test` wrote real, UNREVOKED rows into ~/.ocp/ocp.db — the SAME sqlite database the
running server reads — two per run, unbounded. On the maintainer's host that had accumulated
**737 test-suite keys against 12 real operator keys** (749 rows total), all revoked=0.
Not a credential leak: createKey() mints `randomBytes(24)` and the suite discards the
plaintext, so nobody holds a usable token. But it is real damage:
- the operator's key store grows by 2 rows on every test run, forever
- `ocp keys list` is unusable (749 rows, 12 of them real)
- the suite is RACY: two concurrent runs (e.g. two review worktrees) share one file, so
listKeys() can miss "test-user-1" and `"quota_daily" in undefined` throws a TypeError
rather than failing cleanly. That is the ~1-in-6 flake in `listKeys includes quota
fields`, reported by a reviewer and initially not reproducible serially — it needs a
concurrent run to surface, which is exactly what four parallel reviewers produced.
Root cause: keys.mjs resolved `OCP_DIR`/`DB_PATH` at MODULE TOP-LEVEL and read no env var.
test-features.mjs carried a comment claiming it could "set env before the first getDb() call"
— it could not, on two counts: nothing in keys.mjs read an env var, and ESM hoists imports, so
the assignment would have run after keys.mjs was already evaluated anyway. The isolation was
never real; it just LOOKED real, which is why it survived.
Fix:
- keys.mjs resolves the dir lazily, inside getDb(), honoring OCP_DIR_OVERRIDE. Deliberately
NOT a generic `OCP_DIR`: pointing a RUNNING server at a different key store silently
changes which credentials authenticate, so this must be awkward to set by accident.
- new test-env.mjs, imported BEFORE keys.mjs, mints a per-run scratch dir. A separate module
is required — ESM hoisting means a statement in the test's own body is too late.
- export getDbPath() so the store's location can be asserted.
- as a side effect, importing keys.mjs no longer creates directories in the operator's home.
Two guards added, both MUTATION-TESTED (revert the override → both fail, 317/2):
- the key store under test is a scratch db, NOT the operator's real ~/.ocp/ocp.db
- listKeys does not depend on rows left behind by an earlier or concurrent run
Proven, not asserted: the real ~/.ocp/ocp.db held at 749 rows across two full test runs
(it previously grew by 2 each run). The 737 existing junk rows are NOT cleaned up here —
that is a destructive change to the maintainer's live database and is his call, not a
side effect of a test fix.
npm test: 319 passed, 0 failed (was 317).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR
* fix(keys): gate the test override behind NODE_ENV so a prod server can never honor it
Review fold-in. The reviewer landed the sharpest possible critique: the first cut closed a
test-hygiene hole by opening a quieter AUTH-CORRECTNESS one, and its only guard against that
was "the variable has an awkward name" — a naming convention plus a comment. That is precisely
the failure mode this whole PR exists to indict (a comment describing an intention that nothing
enforces). It was demonstrated live:
OCP_DIR_OVERRIDE=/tmp/evil-store -> server opens /tmp/evil-store/ocp.db, 0 keys visible
server.mjs imports keys.mjs, and `ocp start`'s nohup fallback inherits the invoking shell's env
— so a maintainer who exported the var while debugging THIS issue and then started the server
would get a server silently authenticating against an empty key store. In AUTH_MODE=multi that
is a total auth outage: every real key 401s, nothing logged, nothing on /health.
F1 — the gate is now the actual guard: OCP_DIR_OVERRIDE is honored ONLY when
NODE_ENV === "test". A production server runs without NODE_ENV and therefore CANNOT be
redirected, however the variable reached its environment. Proven both directions:
no NODE_ENV + OCP_DIR_OVERRIDE=/tmp/evil-store -> /Users/<op>/.ocp/ocp.db (ignored)
NODE_ENV=test + OCP_DIR_OVERRIDE=/tmp/scratch -> /tmp/scratch/ocp.db (honored)
Plus: getDb() now LOGS the store whenever it is not the default. Silence was the other half
of the bug — a server on the wrong key store looks exactly like one on the right store until
every request 401s.
F2 — restore the 0700 guarantee on ~/.ocp. Removing keys.mjs's top-level mkdirSync (a good
change on its own) silently dropped it: prepareSpawnHome (server.mjs:477) does
mkdirSync(recursive) with NO mode, so on a fresh install it can create ~/.ocp as a
world-listable 0755 parent. Verified: 755 via the spawn-home path vs 700 via resolveOcpDir.
The invariant used to be inherited by luck; it is now stated.
F3 — test-env.mjs removes its scratch dir on exit. Otherwise the fix traded unbounded growth in
~/.ocp/ocp.db for unbounded growth in $TMPDIR. Verified: 2 runs, delta 0 dirs.
F4 — closeDb() clears dbPath; getDbPath() no longer hands back a path to a closed db.
F5 — dropped the dead unlinkSync import and explained the leftover HOME normalization.
New test, and it is the one that matters: "a PRODUCTION process (no NODE_ENV) must IGNORE
OCP_DIR_OVERRIDE" — so nothing can re-widen the gate without a red test.
server.mjs IS touched (one mkdirSync mode). Not endpoint-touching: no request handler, endpoint,
header, or wire field — so no cli.js citation applies (ALIGNMENT.md Rule 2 / CLAUDE.md hard-req #1).
Note memory/constitution.md § II lists keys.mjs as a protected file requiring maintainer approval.
npm test: 320 passed, 0 failed. Real ~/.ocp/ocp.db unchanged at 751 rows throughout.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR
* fix(test): make the F1 gate test REAL — it was theatre, and proved it
The reviewer deleted the entire NODE_ENV gate from keys.mjs and the suite still reported
320 passed, 0 failed. The one test written to stop this bug recurring was the one thing in
the PR that would have let it recur — and it would have merged green, with a false sense of
coverage.
Why it was worthless: it re-implemented the predicate INSIDE THE TEST BODY —
const resolve = (nodeEnv, override) =>
(nodeEnv === "test" ? override : null) || join(homedir(), ".ocp");
— and never called resolveOcpDir(), getDb(), or getDbPath(). It asserted that a closure
defined three lines above behaved as written. A copy of the predicate is not the predicate.
Its own comment said "exercising the same predicate keys.mjs uses" — that phrase was the tell.
This is the same failure class the PR exists to indict (an assertion of an intention that
nothing enforces), reproduced one layer up, in the fix for it. Fourth time in this repo that
a correctly-named test has vouched for nothing.
The real test must run OUT OF PROCESS: the parent is irreversibly NODE_ENV=test by the time
any test runs (test-env.mjs sets it before keys.mjs is imported), so the production path is
simply unreachable in-process. It now spawns a child with no NODE_ENV, the override set, and
HOME redirected to a temp dir (so the real key store is never opened), and asserts what the
REAL keys.mjs actually did.
MUTATION-PROVEN, against the exact revert that used to pass:
delete the whole NODE_ENV gate -> 319 passed, 1 failed
✗ a PRODUCTION process (no NODE_ENV) must IGNORE OCP_DIR_OVERRIDE
restore -> 320 passed, 0 failed
Also folded in: setup.mjs created ~/.ocp at the umask default (755, world-listable) on a fresh
install via the logs dir — pre-existing, self-healing on first server start, now stated
explicitly (mode 0700) rather than left to luck. Same class as the F2 fix.
npm test: 320 passed, 0 failed. Real ~/.ocp/ocp.db unchanged at 751 rows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR
* fix(setup): rescue the semicolon from the comment; assert the child SAW the override
Two review nits on the way in.
setup.mjs:393 — the statement's semicolon had been swallowed INTO the trailing comment, so
the line parsed only because ASI rescued it (the next token is `if` on a new line). The repo
has no linter, so nothing would have caught it. Comment moved above the statement.
test-features.mjs — negative control on the prod-gate probe. The reviewer noticed the test's
robustness was INCIDENTAL: because the child env is spread from process.env, it inherits the
parent's own OCP_DIR_OVERRIDE, so a future refactor that renamed the var and missed this test's
explicit `env` object would still have gone red — but by luck, not by assertion. The child now
prints the override it SAW as well as the store it opened, and the test asserts both. The claim
is now 'a prod process saw the override and ignored it', not merely 'a prod process opened the
right store' (which could pass for the wrong reason).
Mutation re-proven after both edits: delete the NODE_ENV gate -> 319 passed, 1 failed; restore
-> 320 passed, 0 failed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* docs(readme): stop the feature bullet promising what § honest limits forbids (#136)
Issue #136: an external user reported that Claude Code REFUSES to run OCP's own
copy-paste install prompt, on the grounds that the premise — pooling one Pro/Max
subscription across a family — violates Anthropic's Usage Policy.
The install prompts themselves were already fixed since that report ('my own devices
on the network', plus a ToS warning on the LAN section). What remained was a
self-contradiction in the README:
line 27 (feature bullet): 'share one Claude Pro/Max subscription with family,
friends, or your own devices'
line 427 (§ honest limits): 'The defensible framing is "one person, your own
devices" — sharing with friends or a team is not.'
The top-of-funnel bullet was promoting exactly what the project's own ToS section
calls indefensible. That is a defect on its own terms, independent of anyone's view
on the underlying policy: a reader who trusts the bullet is walked straight into the
thing the same document later tells them not to do.
Aligned the bullet to the position the project ALREADY took, and linked the honest-limits
section from it. No change to the auth modes, the LAN feature, or the maintainer's own
account of how they use it — this only stops the doc arguing with itself.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR
* docs(readme): fix the misdirected anchor + the same defect at line 52 (review fold-in)
Independent reviewer caught two things in the first cut:
1. The new link pointed at #auth-modes — which resolves to the '### Auth Modes' mode
table (line 408), NOT to the 'Sharing with family / a team — honest limits'
paragraph (line 422), which sits under '### Deployment model & security (read
this)' (line 418). A bullet that says 'see the honest limits' and then sends you
somewhere else is worse than no link. Correct anchor verified two ways (github-slugger
+ the live rendered page): #deployment-model--security-read-this (double hyphen — the
'&' is stripped but both surrounding spaces survive).
2. Line 52 carried the SAME defect the PR was written to fix, a few lines below it:
'share one Claude Pro/Max subscription across IDEs, devices, and people'. So the
original claim — 'this only stops the document arguing with itself' — was not yet
true: it stopped one instance and left an adjacent one standing, in the same
top-of-README section an install-time reviewer reads first.
Left alone deliberately: the maintainer's own account of their household's use (lines 7
and 1178). That is theirs to make, and a docs PR should not quietly rewrite it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR
* docs(readme): carry the ToS caveat into the MANUAL install path too (README:218)
Reviewer found a third instance, and it is the one that most directly reproduces #136.
README has two forms of the same LAN-mode install: the copy-paste AI prompt (line 132) and
the handbook form (line 218). Line 180 explicitly asserts they are 'the same steps in handbook
form'. They were not:
line 132 (prompt) 'install OCP as a server so YOUR OWN DEVICES on the LAN can reach it
(Claude Pro/Max are per-user accounts — review Anthropic's Usage Policy
before extending access to other people)' + example keys: laptop, tablet
line 218 (handbook) 'share with other devices on your network:' + 'create API keys for each
PERSON/device' + no ToS pointer at all
So a reader who takes the manual route instead of the copy-paste route is still walked into
per-person key creation with zero ToS mention — reproducing #136's trigger through the door
the first commit did not close. The caveat now matches its twin.
Deliberately NOT scrubbing the wife-laptop / son-ipad example key names: they recur in seven
further places, it is a bigger diff at a different severity, and it edges into scrubbing the
maintainer's household out of their own documentation. With the pointer restored at 218 those
examples inherit the caveat — which is exactly the posture § honest limits takes. It never
forbids family sharing; it says it is the account holder's call and their risk, and refuses to
hide that.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Residual found by the independent reviewer's second pass, by PROBING the fixed code rather
than reading it — the F1 fix was correct but its ARMING could be skipped entirely.
TuiDeltaAssembler.messageId was initialized to `null`. A first MessageDisplay payload carrying
message_id:null therefore compared EQUAL to the sentinel, registered no boundary, and left
`messages` at 0. When the real boundary then arrived, `else if (this.messages > 1)` evaluated
1 > 1 === false, so restartedAfterEmit never armed, and the `released` branch forwarded the
auth banner to the client — the exact leak F1 closed, reachable again through a single null
field. parseDeltaChunk does not validate message_id, so such a payload does reach push().
Two changes, both narrowing:
- messageId now initializes to a Symbol sentinel, which is === to nothing a JSON payload can
produce, so the first fire ALWAYS registers as message 1 whatever its message_id is.
- the boundary branch drops the `this.messages > 1` sub-condition. It bought nothing and was
the sole cause. The real invariant is "a boundary occurred while emitted !== ''" — which is
unrecoverable regardless of how many messages have been seen — and that is now what the
code says.
Whether claude ever emits message_id:null is unverified (the observed contract has it present),
so this is defense-in-depth, not a live bug. But the guard is the mechanism this feature
nominates as its primary safety property; it should not be disarmable by a field's absence.
Mutation-tested: restore the null sentinel + the messages>1 condition and the new test fails;
with the fix, 317 passed / 0 failed (was 316).
Class B (ADR 0007, OCP-owned TUI spawn) — cli.js does NOT perform this operation.
Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(tui): real SSE streaming via claude's MessageDisplay hook (OCP_TUI_STREAM, default off)
Backlog #2. TUI-mode `stream:true` turns can now emit real SSE `delta.content` chunks as
`claude` generates them, instead of buffering the turn and replaying it with
streamStringAsSSE. Opt-in: with OCP_TUI_STREAM unset/0 the spawn argv, the SSE bytes and
the cache behaviour are byte-for-byte unchanged (asserted by test).
This PR does NOT mirror any cli.js function, so no `cli.js:NNNN` citation applies, and per
CLAUDE.md's hard requirement #1 that is stated explicitly here rather than left implicit:
- We consume claude's OWN `MessageDisplay` hook surface AS EMITTED — forwarding, not
inventing. No new endpoint, no fabricated protocol, no new field.
- The TUI spawn is OCP-owned surface: ADR 0007 owns it, not cli.js.
- The SSE wire shapes are the OpenAI chat/completions streaming spec, adopted by ADR 0006.
Every frame emitted here (role chunk, content-delta chunk, stop chunk, `[DONE]`, and the
post-header {error:{message,type}} frame) is COPIED from callClaudeStreaming, the -p path.
/health gains additive fields only (streamEnabled + 4 counters) — same grandfathered B.2
rationale as the existing tui block (ADR 0006). Existing keys are untouched.
`claude` fires MessageDisplay per rendered block, handing the hook the RAW MARKDOWN SOURCE
of an incremental delta on stdin. The hook is registered with `--settings` on the ordinary
interactive spawn (no -p, no --bare) — verified to leave the billing pool alone.
Sink: a static sh hook script appends each payload to `<streamDir>/<session_id>.jsonl`; OCP
polls that file and forwards deltas as SSE. The per-session-id keying is MANDATORY, not an
optimization — OCP_TUI_MAX_CONCURRENT defaults to 2, so two claude panes already run at
once and a shared sink would splice one client's deltas into another's stream.
Warm-pool compatible (a separate in-flight PR depends on this): the hook script AND the
settings file are static — nothing request-specific is baked in at spawn time. The sink path
reaches the pane through its own env (OCP_TUI_STREAM_FILE) and derives from the session-id,
which a pre-booted pane fixes at boot.
The hook is SYNCHRONOUS (forceSyncExecution: claude blocks on it), so the script writes and
exits: one `cat` append, nothing else. Measured p50 7.2ms / p90 14.7ms per fire, ~50ms across
a whole turn — noise against a 6-10s turn.
It remains the terminal-turn signal, the source of the returned/cached text T, and the input
to the honesty gates. The delta stream is a low-latency MIRROR, never a replacement:
- the truncation gate (C-2) and auth-banner gate (C-1, issue #133) run BEFORE anything is
committed or flushed, unchanged;
- at end of turn the streamed bytes are asserted against T. Equal -> serve. A strict PREFIX
of T -> top up from the transcript so the client still receives exactly T (counted).
NOT a prefix -> REFUSE the turn: SSE error frame, no cache, no success, streamDivergences++.
Serving text the transcript disagrees with is the failure class ALIGNMENT.md exists to
prevent, so this fails loud rather than degrading quietly;
- only T is ever cached — never the concatenated deltas.
The auth banner needs prevention, not just detection (SSE deltas cannot be un-sent), so the
first OCP_TUI_STREAM_HOLDBACK (100) chars are withheld: the default banner detector cannot
match a message longer than 100 chars, so releasing past that provably cannot leak a banner.
A custom CLAUDE_TUI_ERROR_PATTERNS has no such bound — OCP warns at boot.
- BANNER, before/after the spawn change: `Sonnet 4.6 with low effort · Claude Max` both,
including on the pane the server itself spawns. Never `API Usage Billing`. Transcript
entrypoint stays "cli". --settings is not a --bare-class flag.
- --settings MERGES with <HOME>/.claude/settings.json rather than clobbering it (the
user-level settings' `env` block still reached the hook), so the isolated-HOME settings
story (permissions / additionalDirectories) survives.
- EXACTNESS: 8/8 varied prompts (short, long, markdown, code fence, multilingual, JSON,
table, unicode) byte-exact vs transcript T, streamed AND buffered. 0 top-ups,
0 divergences over 15 streamed turns.
- TTFT: buffered delivers NOTHING until the turn ends (TTFB == total, 7.5-15.8s). Streamed
sends headers at ~25ms (heartbeat covers the pre-first-delta silence) and first content
mid-generation, e.g. markdown 7.9s first chunk / 12.8s total; long 9.7s / 17.4s.
- CONCURRENCY DEMUX: two concurrent streamed turns (ALPHA/BRAVO), tui.inflight peaked at 2,
each read its own session-keyed transcript, ZERO cross-contamination.
- AUTH-BANNER GATE under streaming, both layers: a short banner-like turn reached the client
as 0 content chunks + an SSE error frame (never emitted); a long one was streamed but still
ended on an error frame, not finish_reason:"stop", and was not cached.
- DISCONNECT mid-turn: pane torn down and semaphore slot released within 1s (info-logged,
not booked as a model error).
- THINKING: not leaked. Opus 4.8 + xhigh turns carry a thinking block with a signature but
`thinking:""` (the reasoning text is not persisted in interactive mode), both
MessageDisplay text-extraction sites in the 2.1.207 bundle filter type==="text", and no
reasoning prose appeared in any delta; concat===T held exactly on the single-message turn.
- npm test: 282 passed, 0 failed (was 267 on main; +15).
The transcript keeps only the model's LAST assistant message. A turn where the model narrates
before calling a tool therefore has two messages, and T is only the second. If the narration
exceeds the holdback it has already been streamed and cannot be retracted -> the turn is
REFUSED. Reproduced live: Opus narrated 475 chars before a Bash call. The assembler discards
a prior message's text when nothing has been emitted yet (so short narration is handled
correctly and stays exact), and raising OCP_TUI_STREAM_HOLDBACK above the narration length
rescues the turn — verified on that exact transcript: holdback>=500 -> served, exact=true.
Documented in README and ADR 0007; this is why streaming is opt-in and off by default.
ADR 0007 line 59 ("no real token streaming — deliberate") is amended, not silently
contradicted.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(tui): re-integrate streaming onto the warm-pane pool (#158) — install the hook at BOOT
Rebasing backlog #2 (streaming) onto #158 (warm pane pool) is not a textual merge: #158
split the monolithic runTuiTurn into bootTuiPane + runTuiTurn, and streaming had patched
the monolith. Re-integrating it in the OLD shape would have compiled, passed every existing
test, and been WRONG.
The bug that shape would have shipped: the sink was derived at TURN time from a streamDir
argument. But a POOLED pane is pre-booted long before any request exists — so on a pool HIT
runTuiTurn never cold-boots, no hook was ever registered on that pane, and the turn would
silently serve BUFFERED. Every miss streams, every hit does not; no error, no failing test.
The operator sees "streaming does nothing in production" and has nothing to grep for.
Fix — install the hook where the pane is born:
- bootTuiPane({ streamDir }) registers the MessageDisplay hook at spawn and returns the
pane's own sink (pane.streamFile), keyed by the pane's own --session-id. The hook script
and settings file are STATIC (one pair per streamDir); the only per-turn thing is the
sink path, and it is fixed at boot. So nothing request-specific is baked into a spawn.
- runTuiTurn reads pane.streamFile — never recomputes it — so a warm pane and a cold pane
stream through byte-for-byte the same path.
- server.mjs threads the same streamDir into the pool's bootPane closure, so pre-booted
panes carry the hook too. TUI_STREAM/TUI_STREAM_DIR now declare before the pool needs them.
Three regression guards added (test-features.mjs), and the third was MUTATION-TESTED: with
the fix reverted to the turn-time shape it fails ("the pooled pane's deltas must reach the
client"), with the fix in place it passes. A guard nobody has watched fail is not a guard.
/health: pool + stream* fields are now a union — the shape assertion asserts CONTAINMENT of
the seven grandfathered keys plus an exact added-set, so a future field that silently
REPLACED an original key cannot pass.
Class B (ADR 0007, OCP-owned TUI spawn) — cli.js does NOT perform this operation.
npm test: 313 passed, 0 failed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR
* fix(tui): close the streaming auth-banner leak + 6 further review findings (PR #159)
Independent review (Iron Rule 10) found a HIGH bug by EXECUTING the code, not reading it.
All seven findings fixed. F1 and F3 were merge-blocking.
F1 (HIGH) — the auth-banner holdback was bypassed after the first release.
TuiDeltaAssembler.released was set once and never reset at a message_id boundary, so the
holdback + detectError predicate guarded only the FIRST message of a turn. In production's
own configuration (OCP_TUI_FULL_TOOLS=1, where multi-message tool-using turns are the norm):
the model narrates past the holdback before a tool call -> released; credentials expire
mid-turn -> claude renders the 401 as ordinary assistant TEXT as a NEW message -> push()
took the `if (this.released)` branch and handed the banner verbatim to the client. That is
precisely the silent-error case the C-1 gate exists to prevent. Detection survived (the
turn was still refused at finalize) but PREVENTION did not.
Fix: once a message boundary follows an emit, the turn is already unrecoverable — finalize()
will refuse it — so push() now emits NOTHING further for the rest of the turn.
Second hole in the same predicate: detectTuiUpstreamError() trims before applying its
<=100-char rule, so 101 whitespace chars trimmed to "" -> detector had nothing to classify
-> returned null -> release fired having screened nothing. Release now gates on the TRIMMED
length, so both sides of the check talk about the same string.
F2 — the "provably safe" claim in stream.mjs, ADR 0007 and README was unsound as written.
Restated with both required halves: (i) nothing is emitted until the trimmed accumulation
exceeds the detector's max banner length, AND (ii) no emission at all once a message
boundary follows an emit. Half (i) alone only ever covered a turn's first message.
F3 (blocker) — prepareStreamHook was write-if-missing, so md-hook.sh could never be updated
OR repaired: a host that booted once under an older version was stuck on that HOOK_SCRIPT
forever, and a non-atomic write interrupted mid-flight left a TRUNCATED script that
existsSync() called fine — on a hook claude BLOCKS on synchronously. Now written
unconditionally via tmp+renameSync (the pattern already used by ensureTuiCwdTrusted).
F4 — the two spawn paths differed for non-streaming requests: the pool installed the hook
whenever OCP_TUI_STREAM was on (correct — a pre-booted pane cannot know what request it will
serve), but the cold path gated it on this turn's onDelta. So one stream:false request got
--settings on a pool HIT and not on a MISS: two spawn argvs for the identical request, on
this project's billing-classification surface. Both paths now gate on TUI_STREAM alone;
whether the sink is POLLED remains correctly gated on onDelta.
F5 — pool._drop() killed the pane but orphaned its sink file; the reap tick drains the whole
pool, so sinks accumulated with no GC path. Now removed best-effort on every drop path.
F6 — /health counters did not measure what they documented: streamTurns was incremented only
AFTER the honesty gates, hiding exactly the turns an operator most wants to see (and making
streamDivergences/streamTurns a meaningless ratio); streamDeltas counted every fire while
claiming to count forwarded ones. Counters and docs now agree.
F7 — total hook failure was silent: zero fires per turn still yields ok:true/exact:false and
a normal, fully-buffered answer. Only streamTopUps moved, which the code itself calls
benign. Added streamZeroDeltaTurns (+ a tui_stream_zero_deltas warning) to separate "the
hook is dead" from "one fire was dropped".
Tests: 316 passed, 0 failed (was 313). Every new guard MUTATION-TESTED — with each fix
reverted the guard named for it fails, and passes with the fix restored:
- drop the restartedAfterEmit guard -> 2 failed (incl. the strengthened old test)
- revert trim() in the release gate -> 1 failed
- revert F3 to write-if-missing -> 1 failed
The pre-existing test "new message_id AFTER an emit" asserted finalize().ok === false but
never checked what push() RETURNED — so it passed while F1 was live, documenting the leak
instead of catching it. Strengthened to assert the emission, not just the verdict.
Class B (ADR 0007, OCP-owned TUI spawn) — cli.js does NOT perform this operation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(tui): warm pane pool — single-use pre-booted panes, opt-in via OCP_TUI_POOL_SIZE
Backlog item #3 of docs/plans/2026-07-13-tui-latency/README.md. Every TUI request
currently cold-boots a tmux+claude pane. This adds an OPT-IN pool of pre-booted panes.
Recorded as ADR 0008 (docs/adr/0008-tui-warm-pane-pool.md), which extends ADR 0007.
MEASURED (this host, Sonnet 4.6, --effort low, through a real OCP instance; a sample
counts only if HTTP 200 AND the body carries the demanded marker):
pool off (main code) n= 6 p50 10.17s [9164 9499 9760 10572 10774 11281]
pool on, warm hits n=12 p50 6.00s [5286 5289 5520 5584 5621 5969
6040 6098 6280 7846 8036 11053]
pool on, warm hits (post- n= 6 p50 5.62s [4729 4753 5236 6004 7548 9548]
review-fix re-run)
-> -4.17s / -41%. 12 hits / 1 miss / 0 bootFailures over 13 requests (and 6/1/0 on
the post-fix re-run). Robust to counting the miss: n=13 p50 -> -40.6%.
The plan doc predicted only -1.0s (the boot). It is ~4.2s because the cold path also
pays ~2.9s INSIDE the first turn beyond claude's own reported turn_duration — post-
input-bar init that an idle pane has already finished. Phase decomposition of the cold
path (n=6 medians): prep 2ms | tmux spawn 27ms | boot->input-ready 1232ms | paste 8ms |
paste-verify 426ms | submit->terminal 8458ms | teardown 8ms = 10162ms total, vs native
turn_duration 5539ms => 4490ms of OCP-side overhead, of which the pool recovers ~1.26s
of boot and ~2.9s of in-claude cold start. (The 426ms paste-verify is one 400ms poll
tick; a real paste lands in ~80ms. Not addressed here — separate item.)
DESIGN
- SINGLE-USE panes. A pooled pane serves exactly ONE turn, then is killed and replaced
in the background. Each carries its OWN fresh --session-id fixed at boot, so one
session still holds one exchange. This is what keeps transcript.mjs's
extractLatestAssistantText correct; its warning about a future warm pool reusing a
session is answered in-place (comment updated) and left standing for anyone who later
wants a second turn on a pane — that would be a cross-request TEXT LEAK and needs
user-line scoping in the transcript reader first.
- Pool keyed by model; --model is fixed at spawn. A miss falls back to the cold path
with zero behaviour change. The pool warms the most recently requested model, so the
first request after start (and after a model switch) is always a cold miss.
- REAPER COEXISTENCE (the crux). An idle warm pane IS ours, and the periodic sweep runs
precisely when we are idle. reapStaleTuiSessions() takes a `spare` set of EXACT live
session names, and server.mjs DRAINS the pool immediately before the sweep:
1. a live pooled pane is never reaped — INCLUDING one still BOOTING (see below);
2. an orphaned pooled pane IS still reaped — membership is by exact name from a live
in-memory registry, never by name shape, so a pane from a dead process generation
has nothing claiming it. Omitting `spare` reaps MORE, never less (fail-safe);
3. kill-server is suppressed while any pane is spared — hence the drain, so the sweep
still flushes <defunct> claude zombies (the only mechanism that can).
- THE POOL TRACKS ITS IN-FLIGHT BOOT BY NAME, NOT AS A COUNT. bootTuiPane creates the
tmux session SYNCHRONOUSLY and only then waits up to POOL_BOOT_MS (20s) for the input
bar, so a pooled session can be LIVE for ~20s before its boot resolves. Tracking boots
as a count meant the pool could not name that session, which caused two real bugs
(found in review, reproduced, fixed, and now regression-tested):
* the reap sweep KILLED the booting pane (it could not be spared), left the pool
empty with nothing scheduled, and logged the exact tui_pool_boot_failed WARN
operators are told to alert on — for a completely healthy drain;
* graceful shutdown ORPHANED a live authenticated idle `claude`: gracefulShutdown
calls process.exit(0) in the SAME TICK as the drain (TUI panes are tmux children,
so activeProcesses is empty and the wait-for-children path exits immediately), so
cleanup deferred to a .then() never ran.
Fix: the pool mints each pane's identity up front ({sessionId, name}) and holds it in
_bootingPane. liveNames() includes it; drain() kills it SYNCHRONOUSLY. A generation
counter distinguishes "cancelled by us" from "genuinely failed", so a drain never
inflates bootFailures and resume() reliably starts a fresh boot. Deriving the name from
the session-id also makes `tmux ls` correlate to the transcript file.
- SLOT ACCOUNTING. Refill boots take NO TuiSemaphore slot (those bound real turns and
would be starved); they cannot leak one either, since they never hold one. Refills are
SERIALIZED, one boot at a time — live at size=2, two cold boots racing an in-flight
turn overran the readiness cap and a refill was discarded. A genuinely failed boot does
not re-kick the chain (backoff; a broken claude must not respawn forever). Background
boots get a more generous readiness cap (POOL_BOOT_MS = 5x BOOT_MS): BOOT_MS is tight
because a client is blocked on it, which is not true of a pre-boot.
- BOUNDED COST. A warm pane is a LIVE idle claude process held whether or not a request
arrives. Peak processes = pool size + OCP_TUI_MAX_CONCURRENT + 1 booting replacement.
Size clamped to POOL_MAX_SIZE=4; garbage values disable rather than guess. Panes have
a 10-min TTL and a health check at hand-out (dead/degraded pane => miss, never a hang).
Missing collaborators throw at CONSTRUCTION, not on a live request (refill() is called
synchronously from the request path).
DEFAULT OFF (OCP_TUI_POOL_SIZE=0). This is a stable production path and the pool holds
standing processes, so the operator opts in. With the pool off, runTuiTurn takes the
IDENTICAL code path as before (the `pool ? pool.acquire() : null` branch yields null, and
tuiPool is null so no observer is attached and no new log line is emitted) — that is what
establishes the default path is unchanged. A pool-off control run (n=6, p50 9.40s) is
consistent with the 10.17s baseline but had 2/6 samples >12s, so it is corroboration, NOT
proof: n=6 cannot establish "unregressed" on its own. The code-path equivalence can.
BANNER: NO SPAWN ARGUMENT CHANGED. buildTuiCmd is byte-identical to main (verified by
extracting the function body from both revisions and comparing). Live banner captured
from two real POOLED panes anyway: "Sonnet 4.6 with low effort · Claude Max" — the
subscription pool, never "API Usage Billing".
/health: `tui.pool` added (null when off), incl. `cancelled` (boots WE killed — not a
fault; do not alert on it). The tui block is ADR-0007-owned and post-dates ADR 0006's
v3.16.4 grandfather snapshot; the addition is purely additive — every pre-existing key
keeps a byte-identical value. Authorization recorded in ADR 0008.
ALIGNMENT: Class B / ADR 0007 + ADR 0008 (OCP-owned TUI spawn machinery). cli.js does NOT
perform this operation — there is no cli.js citation and none is required: this is not an
Anthropic API surface, it is OCP's own process management around the claude CLI, exactly
as the existing tmux session lifecycle and reaper already are (ALIGNMENT.md Rule 2).
TESTS: 294 passed / 0 failed (was 267). +27 covering acquire/hit/miss, single-use (a pane
is never handed out twice), bounded + serialized refill, TTL + health-check drops, model
retarget, drain/resume, boot-failure backoff, identity linkage, all three reaper
invariants incl. post-drain kill-server restoration, and — the coverage gap that let both
bugs ship — FIVE mid-boot tests: the booting pane is nameable/spareable, the sweep's drain
kills it and resume starts a fresh boot with no bogus WARN, shutdown kills it
synchronously (asserted WITHOUT awaiting, since process.exit runs in the same tick), a
stale settle cannot clear a newer boot's slot, and a model switch cancels an in-flight
boot for the old model.
Live verification (temporary 20s reap interval, reverted): sweep drained both panes ->
reaped -> refilled with NEW panes; a foreign tmux session survived untouched; with no
foreign session kill-server fired and the pool still recovered and served the next
request. Both review bugs reproduced against a PRIVATE tmux server (-L pr3repro, so the
reaper's internal kill-server could not touch the host) before and after the fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(tui): kill a cancelled boot's pane when it settles + make async tests actually count
Folds in the independent review's remaining nit — and, in proving the nit's fix, uncovers
two defects in the test suite itself.
## The nit (latent M1b, second costume)
`_cancelBooting` kills BY NAME, but the tmux session only EXISTS once `bootPane` has run —
and `bootPane` is queued on a microtask. So a caller doing `refill()` then `drain()` in the
SAME synchronous block leaves `_cancelBooting` with nothing to kill (a no-op); it bumps the
generation, and the boot microtask then CREATES the session, succeeds, and — under the old
bare `return` on a stale generation — walked away from a LIVE authenticated `claude` that
nothing owns. Reproduced:
reverted: drain() kills nothing (no session yet) -> boot creates it -> ORPHAN: ['p1']
fixed : drain() kills nothing (no session yet) -> boot creates it -> boot kills it -> []
Not reachable from any current call site, so this is defense-in-depth — but ADR 0008 and the
reap-tick comment in server.mjs BOTH explicitly contemplate a boot-time pre-warm, which is
exactly the shape that reaches it. Killing an already-dead session is a harmless no-op, so
the fix is idempotent whichever way the race lands.
## Defect 1 in the suite: async tests were never awaited (44 of them)
Writing the regression guard exposed this. `test()` called `fn()`, got a promise back, and
IMMEDIATELY printed ✓ and incremented `passed` — without awaiting it. For all 44 tests written
as `test("...", async () => {...})`:
- ✓ meant "did not throw SYNCHRONOUSLY", not "passed";
- a failed assertion escaped as an unhandled rejection, crashing the process (CI stays red on
the non-zero exit) but never being COUNTED — so the summary could print "0 failed" and be wrong.
The suite's headline number was therefore not evidence for ANY async test, including this PR's own
M1a/M1b guards. `test()` now settles an async body before counting it, and the summary awaits them.
## Defect 2, exposed the instant defect 1 was fixed: a false guard
`"a boot that resolves AFTER a drain kills its own pane ... no orphan process left behind"` asserted
`killed.length === 1` — i.e. that kill was CALLED once. But `_cancelBooting`'s kill-by-name on a
not-yet-existent session is a NO-OP that still increments that counter. So "kill was called once" and
"a live session is orphaned" were both true at the same time: a test named for the absence of an
orphan was passing while the orphan was present. Now asserts LIVENESS (`live.size === 0`) — the only
honest question.
## Evidence
fix present : 295 passed, 0 failed, exit 0
fix reverted: 293 passed, 2 failed <- BOTH liveness guards fire (the old kill-count guard did not)
Also: `dropped`'s doc comment now lists `cancelled` (a cancelled in-flight boot lands there via _drop).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* docs(plans): TUI streaming is not achievable — prereq spike result + honest README constraints
Backlog #2 of docs/plans/2026-07-13-tui-latency demanded a prereq spike before any
streaming design: does the transcript JSONL grow during a turn, or only at the end?
The spike was run. All three candidate sources are dead:
(a) transcript JSONL — grows at EVENT granularity; the assistant's text event is
written as ONE complete line, ~0.3s before the terminal turn_duration event
(observed: turn_duration 7319ms; text event at t+7.0s, terminal at t+7.3s).
(b) tmux capture-pane — the pane is a RENDERED view, not the text. Same turn,
transcript T = '## Semaphore\n\nA **semaphore** is a synchronization…'
pane = '⏺ Semaphore' / ' A semaphore is a synchronization…'
'## ', '**' and ```-fences are absent from the pane entirely (rendered to ANSI,
then stripped by capture-pane -p). T.startsWith(paneText) is FALSE both raw and
indent-stripped — not on redraw, but on essentially every markdown answer.
capture-pane -e recovers styling, never source spelling: no unique inverse.
(c) --debug-file — byte-exact ('last_assistant_message':'## Title\n\n**alpha…'),
but only inside end-of-turn Stop-hook payloads; zero content_block_delta /
text_delta events; ~2.7MB per turn.
--output-format stream-json, the only interface emitting token deltas, requires -p —
the metered-billing path TUI mode exists to avoid (cc_entrypoint=sdk-cli). The
constraint is structural. OCP's TUI SSE is, and remains, replay-only.
Also corrects this plan's own "~20s waiting for the whole turn" decomposition, which
was inferred from an external 30-32s report and never measured through OCP. Measured
through a real OCP instance (TUI, claude-sonnet-4-6, n=5): median 11.30s before #156,
9.55s after, vs a native turn_duration of ~7.3s → OCP's own overhead is ~2-4s, not
~20s. The remainder is generation time, which streaming would not shorten (it moves
the first byte, not the last) — so a consumer needing the COMPLETE answer, which is
the JSON-card case that motivated this work, would have gained nothing from streaming.
Backlog #4 measured while here: --exclude-dynamic-system-prompt-sections gives ZERO
marginal benefit (TTFT median 6.39s vs 6.17s for --effort low alone, n=5, one worse
outlier). Do not adopt. Banner stayed on Claude Max.
README: documents the ~6s TTFT floor plainly (TUI mode cannot serve interactive-latency
consumers) and states that no-token-streaming is structural rather than a missing feature.
No code change. No version bump (docs-only). Not endpoint-touching: no server.mjs diff,
so no cli.js citation applies.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR
* docs: fold in adversarial review — correct the debug-log reasoning + the overhead number
Independent adversarial reviewer (tasked with REFUTING this doc) confirmed the central
claim — no byte-faithful incremental source exists on the TUI path — but found four
factual defects in the prose. A negative claim that will be cited for years has to be
right in its reasoning, not just its conclusion.
1. --debug-file: the "written at end-of-turn" reasoning was WRONG. The default log level
is `debug`, which suppresses every `verbose` site; the original probe therefore ran
with the stream logging OFF. At CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose there ARE 16
mid-turn `[shoji-engine] yield stream_event/-` lines spread over ~3.9s of generation.
The conclusion survives because those lines carry TIMING ONLY, no text payload
(content_block_delta / text_delta / content_block_start / message_start = 0 at any
verbosity or category filter). Reasoning rewritten: "logs when tokens arrive, never
what they are" — as written before, the doc was falsifiable in 30 seconds.
2. The 7.319s `turn_duration` is NOT a "native" (non-OCP) baseline: it comes from an
OCP-driven turn (cwd .ocp-tui/work, same 7451-char prompt, same 204-char answer as
pr1 baseline row i=5, elapsed 11563ms). Reframed as what it actually is — a SAME-TURN
decomposition, 11.563s wall - 7.319s CLI-internal = ~4.2s OCP overhead (n=1), which is
a cleaner comparison than the doc originally claimed.
3. Dropped the "~2-4s" range: its low end mixed an effort-HIGH turn_duration with the
effort-LOW wall-clock median, which understates overhead (a low-effort turn generates
faster, so its own turn_duration would be lower). No turn_duration sample exists for
the effort-low config. Now stated as ~4s (n=1, baseline config), with both caveats.
4. Softened "ZERO marginal benefit" (backlog #4) to "no benefit detectable at n=5" — n=5
cannot prove zero — and added the mechanistic reason the reviewer supplied, which is
far stronger than the empirical null: `--help` says the flag improves cross-user
prompt-cache REUSE, and OCP is single-user, so there is no cross-user cache to share.
Also folded in the reviewer's independent sweep, which closes the search space rather
than sampling it: the hook registry was enumerated from the shipped binary (no per-chunk
/ streaming hook exists among the 21 events); `capture-pane -e` was tested and shown to
be a provably non-unique inverse (an H2 and a bold span emit IDENTICAL SGR 1); and
sessions/<pid>.json, history.jsonl, CLAUDE_CODE_INCLUDE_PARTIAL_MESSAGES (undocumented),
sessionMirror, --sdk-url and --input-format stream-json were each checked and each dies
(contentless, or gated behind --output-format stream-json -> --print -> the metered
sdk-cli pool). Prompt-mutation (asking the model for plain text) is named and rejected
on ALIGNMENT grounds so it is not re-litigated later.
Docs-only. No code change, no version bump.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR
* docs: REVERSE the streaming verdict — MessageDisplay hook makes it achievable
The previous commit on this branch concluded TUI streaming was impossible. That was
WRONG, and this corrects it before it could be merged.
The adversarial reviewer commissioned to refute the claim found, on a second pass while
verifying the fold-in, that its OWN first-pass hook enumeration had been truncated by a
400-char grep cap: it reported 21 hook events; the shipped 2.1.207 bundle has 30.
Event #30 is MessageDisplay.
Independently reproduced before acting on it (30 events confirmed via `strings` on the
binary; payload shape `hook_event_name:"MessageDisplay",turn_id,message_id,index,final,
delta`), then live-tested with a MessageDisplay command hook registered via --settings on
a PLAIN INTERACTIVE TUI spawn (no -p, no --bare), claude-sonnet-4-6, --effort low:
banner: "Sonnet 4.6 with low effort · Claude Max" ← subscription pool, verified
7 fires, mid-turn, spread across generation:
index=0 final=false '## Mutex\n\n'
index=1 final=false 'A **mutual exclusion lock** prevents concurrent access to a shar…'
index=4 final=false 'let counter = 0;\n\nasync function increment() {\n const release =…'
index=6 final=true '```'
concat(deltas) === T (transcript-authoritative) -> TRUE (579 == 579 bytes)
T.startsWith(S) at EVERY step -> TRUE (prefix-stable)
'## ' / '**' / '```javascript' present in deltas -> raw markdown SOURCE, not rendered
This satisfies every invariant the previous version declared unobtainable: byte-faithful,
incremental, prefix-stable, no -p, subscription pool. Granularity is block-level (~5-7
chunks/answer), not token-level — which is all an SSE delta.content needs.
Backlog #2 REOPENS and should be built. Implementer caveat recorded: the hook's source
sets forceSyncExecution -> claude BLOCKS on it, so the hook must write and exit
immediately (FIFO/socket), never work inline. Only text blocks fire it (thinking excluded).
ALIGNMENT: consumes claude's OWN hook surface as emitted — forwarding, not inventing
(Class B / ADR 0007; no cli.js citation applies).
Everything still true is kept, and the dead ends are kept as dead ends (they document what
NOT to build): the pane is a rendered view whose source markers are irrecoverable
(capture-pane -e emits IDENTICAL SGR 1 for an H2 and a bold span — a provably non-unique
inverse); the transcript is event-granular; --debug-file carries timing but no payload;
--output-format stream-json requires -p (the metered pool). Also kept: the ~4s (n=1
same-turn) overhead correction, backlog #4's null result with its mechanistic single-user
reason, and the honest value framing — streaming moves the FIRST byte, not the last, so
the complete-answer consumer that motivated this work gains nothing from it.
The wrong conclusion and its refutation are both preserved in the doc. "We checked, it's
impossible" is the most expensive claim to get wrong: it closes a door nobody re-opens.
Docs-only. No code change, no version bump.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR
* docs: fold in re-review — the two caveats that would have bitten the implementer
Re-review of the reversed doc came back APPROVE_WITH_MINOR. The reversal itself was
verified complete (worktree-wide grep: no surviving impossibility claim) and NOT
over-claimed in the other direction (the reviewer recomputed every headline number from
the committed messagedisplay-deltas.jsonl and re-ran the invariant on two further turns:
4 independent turns total, 5/6/4/18 fires, 609/696/239/1973 bytes, concat(deltas) === T
TRUE in all four). But two additive caveats were missing, and both are load-bearing for
the streaming PR now in flight:
1. CONCURRENCY DEMUX (severe, and live TODAY — not a warm-pool future problem).
OCP_TUI_MAX_CONCURRENT defaults to 2, so two `claude` processes already run
concurrently. One MessageDisplay hook writing to one shared sink would INTERLEAVE
deltas from two different turns into a single stream — request A's client receiving
request B's text. A single-request test never surfaces it. The payload carries
session_id, so the sink must be keyed by it (which also keeps the design warm-pool
compatible: a pre-booted pane's session-id is fixed at boot, so one static hook script
serves every pane). Documented, with the required ≥2-concurrent-request test.
2. THINKING-EXCLUSION IS NOT STRESS-TESTED (severe if wrong). The exclusion was inferred
from a code snippet that turns out to be the final:true call site, not the incremental
one. Four live turns showed no thinking in any delta — but every transcript's thinking
block was EMPTY (thinking:"", 0 chars), so it was never actually stressed. If thinking
deltas do fire on Opus/xhigh, concat(deltas) !== T AND OCP streams the model's private
reasoning to the caller; the concat === T assertion detects that but cannot un-send an
SSE delta. Flagged as a must-verify-before-shipping item.
Also: the "5-7 chunks per answer" figure is size-dependent (18 fires on a ~2 KB answer) —
rescoped to "once per rendered block, scales with answer length" in both the doc and the
README, so no implementer hard-codes a chunk-count assumption.
Both caveats were relayed to the streaming implementation immediately rather than waiting
for this merge.
Docs-only. No code change, no version bump.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(tui): pin spawn effort via OCP_TUI_EFFORT (default low)
buildTuiCmd never passed --effort, so the pane's claude inherited a
HOME-dependent effortLevel: real-home mode inherits the operator's
~/.claude/settings.json (high/xhigh on typical operator hosts),
env-token scratch mode inherits claude's built-in default — proxied-turn
latency silently depended on which HOME mode resolveTuiHome() picked and
on an unrelated operator setting.
Pass --effort explicitly, from new env var OCP_TUI_EFFORT (default
"low"; allowlist low|medium|high|xhigh|max per `claude --help` 2.1.207;
"inherit" restores the pre-flag argv byte-for-byte; an invalid value
warns and falls back to "low" so a typo can never reach the pane argv).
Not endpoint-touching: no server.mjs change, no wire-level change — the
flag rides the existing interactive spawn (ADR 0007). Billing-pool
safety verified per the docs/plans/2026-07-13-tui-latency banner
protocol: startup banner stays "Claude Max" with "low effort".
Measured through a test OCP instance (:3979, TUI mode, real-home,
claude-sonnet-4-6, n=5+5, same ~1850-token prompt as floor.sh):
before: median 11.30s range 9.05-12.38s (spread 3.32s) banner: high effort - Claude Max
after: median 9.55s range 9.27-9.77s (spread 0.50s) banner: low effort - Claude Max
OCP_TUI_EFFORT=inherit: banner back to "high effort" (pre-flag behavior restored)
README: new row in the Environment Variables table (release_kit
new_feature_doc_expectations: new env var -> README table). Tests: 4 new
buildTuiCmd cases (default, explicit level, inherit, invalid fallback);
suite 267 passed / 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR
* docs(readme): review nit — 'pre-v3.22' → 'pre-flag' (next version not fixed yet)
Reviewer nit from the Iron Rule 10 independent review of PR #156.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* docs(plans): TUI-mode latency floor — measured decomposition + backlog
An external consumer measured OCP's prompt path at TTFT p50 30-32s and excluded
OCP on that basis. This documents where those 30 seconds actually go, with a
reproducible harness (n=15) that bypasses OCP and measures the underlying
subscription path's true first-token time.
Findings:
- boot -> input-ready is only ~1.0s; it is NOT the bottleneck
- true TTFT is 6-10s; the remaining ~20s is runTuiTurn polling the transcript
until turn_duration (ADR 0007 step 4) — i.e. waiting for the WHOLE turn.
There is no streaming.
- buildTuiCmd never passes --effort, so the spawned claude inherits the
operator's global effortLevel (xhigh on this host) — every request runs
extended thinking. Passing --effort low: TTFT p50 9.70s -> 6.17s (-36%),
spread 7.85-13.07s -> 5.87-6.44s. Stays on Claude Max.
- ⚠️ --bare SILENTLY drops off the subscription pool (banner flips
'Claude Max' -> 'API Usage Billing'). It does cut boot to ~0.5s, but defeats
the entire purpose of ADR 0007. Failure is silent: all 5 --bare samples
produced no answer at all (no error, no crash, just never a token).
Anyone optimizing boot MUST diff the banner line.
- Floor after all fixes is ~6s (claude always injects the full CC system prompt
+ tool definitions). TUI mode therefore cannot serve real-time consumers —
a constraint worth stating in the README.
Backlog ranked by value/effort: (1) OCP_TUI_EFFORT env var, default low;
(2) real streaming instead of turn_duration polling (~20s, the big one);
(3) warm pane pool (~1s); (4) prefill trim (probably not worth it).
Docs-only; no version bump (matches repo convention — bump lands in the
chore(release) commit).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dx5Ncq6wWBrF27vJKHZ9Hr
* docs(plans): address review — restore --bare evidence, qualify effort claim, add banner captures
Reviewer (fresh-context, Iron Rule 10) returned REQUEST_CHANGES. All four technical
conclusions survived independent verification (source-read + live repro); the defects
were in the evidence file, and they were real:
- H-1: measurements.jsonl claimed n=15 but held 10 rows, and the --bare group — the
basis of this PR's headline warning — had ZERO rows. The author had stripped them
as 'invalid samples' (ttft_ms:-1) when they were in fact the evidence. Regenerated:
n=15, three groups × 5, all with tag/extra_args. --bare reproduces exactly (5/5 no
answer, boot 0.43-0.45s).
- M-1: the effort-inheritance claim was written unconditionally, but it depends on
resolveTuiHome()'s mode. Real-home (current service config) inherits the operator's
effortLevel: xhigh; env-token scratch home (~/.ocp-tui/home) has no effortLevel in
its settings.json and prepareTuiHome() never writes one, so the pane gets claude's
built-in default. Now documented as a table — and the mode split makes passing
--effort explicitly MORE valuable, not less.
- M-2: baseline rows were produced by a pre-parameterized script and lacked
tag/extra_args. Re-run with the committed script. Recomputed effect: -40% (was -36%).
- L-1: documented that the harness suppresses OCP's periodic kill-server tick via the
othersRemain coexistence guard (by design, resumes next tick).
- L-2: documented that floor.sh's readiness marker differs from OCP's tuiInputReady(),
so the ~1.0s boot figure is not apples-to-apples with BOOT_MS.
- Direct-API reference figure now explicitly labeled as external (not in this dataset).
- New: billing-banner.txt captures all three configs live, including confirmation that
--effort low stays on Claude Max (reviewer noted this was asserted but unevidenced).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dx5Ncq6wWBrF27vJKHZ9Hr
* docs(plans): scope the effort claim to TUI mode (currently off), drop nonexistent bin/
Re-review (APPROVE_WITH_MINOR) caught two accuracy defects:
- MIN-1: 'every OCP request runs extended thinking' over-extrapolated. TUI mode is
currently OFF on this host (CLAUDE_TUI_MODE=false; /health tui.enabled=false), so
live traffic takes the -p path. The claim is about what happens WHEN TUI mode is
enabled — now scoped, and the same qualifier applied to the kill-server interaction
note (that reap tick is itself gated on TUI_MODE).
- NIT-2: the quoted grep included bin/, which does not exist in the repo (exit 2).
Dropped; the zero-hit result over lib/ + server.mjs is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dx5Ncq6wWBrF27vJKHZ9Hr
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Patch release bundling three merged bug fixes (no new cli.js wire behavior,
no new endpoint/header/env var):
- #148 fix(tui): session prefix + reap/kill-server scoped per-instance by port
- #150 fix(server): serialize -p real-HOME token fallback behind a mutex +
30s TTL keychain read cache + de-staled isolation decision (new lib/spawn-auth.mjs)
- #149 fix: semaphore honors runtime-lowered maxConcurrent, queued requests
cancelled on client disconnect, singleflight follower retry on leader
disconnect, exact queued accounting, quiet disconnect handling
Release-kit walk (CLAUDE.md Iron Rule 5.5): package.json version bump,
CHANGELOG.md entry, one-sentence Troubleshooting note for the genuinely
operator-visible upgrade-overlap caveat from #148. No changes to
models.json / Available Models / API Endpoints / Environment Variables
tables — verified by diffing all three merged commits (2922d68..d96da46);
none add an endpoint, header, or env var.
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix: honor runtime-lowered concurrency limit + cancel queued waiters on disconnect
Fixes three findings from an independent concurrency audit of the -p/stream-json
wait-queue (lib/tui/semaphore.mjs, reused by server.mjs as `claudeSemaphore`) and
its acquireClaudeSlot()/callClaudeTui() callers in server.mjs:
F1 (MEDIUM) — release() handed a freed slot straight to the next queued waiter
without re-checking `this.limit`, so a PATCH /settings maxConcurrent decrease was
silently ignored until every already-inflight task happened to finish on its own.
release() now only re-grants when post-decrement inflight is still under the
current limit, and a new setLimit() wakes queued waiters immediately when the
limit is raised instead of only on the next incidental release().
F2 (MEDIUM) — a request queued behind the concurrency limit had no link to its
HTTP connection, so a client that disconnected while still queued would still
get a claude process spawned for it once a slot freed — burning subscription
quota for a dead socket. acquire() now accepts an optional AbortSignal; server.mjs
derives one from the client's res "close" event (closeSignalFor) and passes it
into claudeSemaphore.acquire() / tuiSemaphore.acquire() while queued. On abort the
waiter is spliced out of the queue (not just flagged), so `queued` accounting
stays exact; the same "close" signal is wired into acquireClaudeSlot() (-p path,
non-streaming + streaming + singleflight-wrapped) and callClaudeTui() (TUI path).
If the response is already destroyed by the time we try to queue, we reject
immediately without ever entering the queue.
F8 (cosmetic) — acquireClaudeSlot() set `stats.queued = claudeSemaphore.queued + 1`
BEFORE calling acquire(), over-reporting /health's queued count by 1 whenever a
slot was granted immediately (the common, non-queued case). acquire() already
updates its internal queue synchronously before returning a Promise, so reading
claudeSemaphore.queued right AFTER calling it (instead of guessing "+1" before)
is exact. No /health field was added, removed, or renamed.
ALIGNMENT.md: this PR touches request-handler code (callClaude, callClaudeStreaming,
callClaudeTui, acquireClaudeSlot) but is local concurrency-control/queue-accounting
infrastructure with no cli.js wire analogue — it does not add, rename, or change any
endpoint, header, request field, or response field, and does not touch the /v1/messages
forwarding path or the OAuth bearer machinery (the two Class A surfaces this repo
governs). The /health response shape is unchanged (same field set, same nesting;
only the *value* of the pre-existing `stats.queued` field is corrected). Per
CLAUDE.md hard-requirement #1, a cli.js citation is therefore declared ABSENT:
there is no corresponding cli.js operation to cite because this is not a
cli.js-mirror (Class A) change and not a Class B endpoint-contract change either.
Tests: added 6 unit tests to test-features.mjs against the shared TuiSemaphore
(lowering the limit mid-load does not over-admit; raising the limit wakes queued
waiters up to the new headroom, FIFO; a queued waiter cancelled via AbortSignal
is spliced out and never later acquires; an already-aborted signal never touches
the queue; cancelling one of several queued waiters preserves FIFO for the rest).
238 pre-existing tests remain green; suite is now 244/244.
Verification: `node --check server.mjs && node --check lib/tui/semaphore.mjs && npm test` — 244 passed, 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: singleflight follower retry on leader disconnect + quiet disconnect handling (review M1/L1/L2)
Addresses the independent reviewer's APPROVE-WITH-CHANGES findings on PR #149:
M1 (MEDIUM, F2 regression) — when a singleflight LEADER disconnected while queued,
its RequestDisconnectedError rejected the SHARED promise, so live followers fell
into respondUpstreamError's generic branch and got a spurious 500 on a healthy
socket. Fix: keys.mjs singleflight() gains an optional follower-side `retryIf`
predicate. When a follower joins an existing flight and the shared promise rejects
with an error retryIf() accepts, the follower does NOT inherit the rejection — it
re-enters singleflight with its OWN fn (the map entry is guaranteed already deleted:
the delete-finally is attached upstream of the promise followers await), becoming
the new leader or joining a retrying sibling's fresh flight. The leader's own
rejection is never retried (it IS that client's disconnect). server.mjs passes
retryIf = (err) => err instanceof RequestDisconnectedError && !res.destroyed, so a
follower whose own client is also gone still propagates quietly. Callers without
retryIf keep byte-for-byte pre-existing share-everything semantics (pinned by the
existing failure-fan-out test).
L1 (LOW) — a disconnect-while-queued on the non-streaming paths was recorded as a
usage FAILURE row and logged as a [proxy] error: metric noise for a non-error.
Both non-streaming catch blocks now early-return on RequestDisconnectedError
without recordUsage(success:false) and without console.error — mirroring the
streaming path, which returns silently. The disconnect remains observable at info
level (concurrency_wait_cancelled, now also emitted with path:"tui" from
callClaudeTui for parity with acquireClaudeSlot's -p log).
L2 (LOW, test gap) — added a unit test for the abort-after-grant race: a waiter
granted its slot whose signal aborts afterward must see no rejection, no queue
corruption, and its slot released exactly once via the normal path (the semaphore
detaches the abort listener at grant; the onAbort idx===-1 guard is the in-dispatch
backstop).
Tests: +3 (2× M1 in the singleflight section, 1× L2 in the F2 section) — suite is
now 247/247 green.
ALIGNMENT.md: unchanged declaration — still local concurrency/dedup infrastructure
with no cli.js wire analogue; no endpoint, header, request field, or response field
added or changed; /health shape untouched. cli.js citation declared ABSENT per
CLAUDE.md hard-requirement #1 (not a Class A mirror change, not a Class B
contract change).
Verification: node --check server.mjs lib/tui/semaphore.mjs keys.mjs && npm test
— 247 passed, 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(server): serialize -p real-HOME token fallback + TTL-cache keychain + de-stale isolation decision (F3/F5/F6)
Three audit findings in the -p spawn-token resolution + HOME-isolation layer. All are infra/
process changes to how OCP READS and GATES an OAuth token it already holds; none touch the OAuth
wire machinery.
F3 (MEDIUM) — expiry-window fallback herds concurrent -p spawns into real HOME.
When the keychain token is within 5 min of expiry, resolveSpawnToken() returns null and every
concurrent spawn simultaneously falls back to the real HOME; each spawned claude then races a
refresh_token grant against the SAME single-use refresh token — rotating it out from under the
others and the operator's real claude (the credential-fork hazard, #112/#146 class). Fix: a
promise-chain mutex (createSerialMutex) serializes ONLY the real-HOME fallback — one such spawn at
a time. When a serialized waiter is admitted (prior holder torn down → its claude has refreshed the
keychain), it re-runs resolveSpawnToken(): a now-fresh token means it proceeds ISOLATED instead of
real-HOME, so the queue drains to the fast path. Isolated spawns never touch the mutex.
F5 (LOW-MED) — per-spawn double keychain exec on the hot path.
getOAuthCredentials() sync-exec'd `security find-generic-password` up to twice (wrong label first),
worst case 5s×2, blocking the event loop and stalling in-flight SSE streams. Fix: (a) memoize the
last-good keychain label and try it first (orderLabelsLastGoodFirst); (b) a 30s TTL cache of the
read (createTtlCache). This does NOT reintroduce the #146 forever-memoized regression: the TTL
bounds only how often we re-READ the keychain; resolveSpawnToken() still applies the 5-min expiry
gate (isTokenExpiring) to the CACHED creds on EVERY use, so a token expiring within the window is
still rejected → real-HOME fallback. Call sites stay synchronous (no async conversion).
F6 (LOW) — memoized isolation decision goes stale; /health could misreport.
getSpawnHomeMode() memoized the isolated/real-home decision forever: credentials appearing after
startup never enabled isolation; deleting ~/.ocp/spawn-home at runtime ENOENT'd every isolated
spawn until restart; during an expiry stint /health reported isolated:true while spawns ran real-
HOME. Fix: re-evaluate the decision per spawn (cheap now that F5 caches the keychain read);
ensureSpawnHome() re-verifies + re-prepares the scratch dir per isolated spawn; and /health now
reports the EFFECTIVE decision (token presence AND expiry gate). The /health field set is
UNCHANGED — no field added/removed/renamed — only the values are made truthful.
Alignment:
- Class: Not a wire/endpoint change for the spawn-token layer + Class B (B.2) for /health.
- cli.js citation: DECLARED ABSENT. cli.js does NOT perform OCP's spawn-token resolution, HOME
isolation, keychain caching, or fallback serialization — these are proxy-internal process
concerns with no cli.js analogue, so no Class A cli.js:NNNN citation exists or is required
(ALIGNMENT.md Rule 2: this is proxy infra, not an invented forwarded endpoint/header/body).
- The /health change is authorized by ADR 0006 (grandfathered B.2 as of v3.16.4) and is a
behaviour-preserving contract change: same fields, truthful values.
- OCP still NEVER performs a refresh_token grant itself — that property is preserved; the fix only
serializes/gates reads of a token refreshed by the spawned or real claude (#112).
- No new endpoint, header, or request/response field. alignment.yml blacklist unaffected.
Tests: extracted the pure primitives to lib/spawn-auth.mjs and added 11 unit tests (mutex
serialization order + idempotent release; TTL cache freshness + null-miss; expiry gate; label
ordering; and the combined invariant that the TTL cache respects the expiry gate). node --check
clean; 249 tests pass (238 + 11).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(server): drain F3 fallback queue immediately — invalidate F5 keychain cache before the serialized re-check
Follow-up to the F3/F5/F6 fix (independent-review observation). F5's 30s keychain TTL cache could
make F3's post-refresh re-check see the stale (expiring) cached creds for up to ~30s, so a waiter
admitted right after the prior real-HOME holder's claude refreshed the keychain would needlessly
fall back to real HOME again instead of proceeding ISOLATED. Serialization safety was never at risk
(still one real-HOME spawn at a time, no double-refresh); only the drain-to-fast-path optimization
lagged.
Fix: invalidateKeychainReadCache() clears the F5 TTL cache; resolveSpawnDecision() calls it under
the fallback mutex, immediately before the re-check, so the admitted waiter reads FRESH keychain
state and drains to the isolated fast path at once. The extra keychain read happens only on the rare
real-HOME fallback path and only under the mutex (serialized, one at a time).
Alignment: unchanged from the parent commit — proxy-internal keychain/HOME-isolation process logic,
no cli.js analogue (cli.js citation DECLARED ABSENT, ALIGNMENT.md Rule 2), no endpoint/header/body,
/health shape unchanged, OCP still never performs a refresh_token grant itself (#112). 249 tests pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Audit finding F7 (LOW): lib/tui/session.mjs hardcoded SESSION_PREFIX =
"ocp-tui-" as a bare, host-wide constant. The boot-reap and periodic
idle-reap in server.mjs used it to decide which tmux sessions to
kill-session and whether to kill-server (which flushes defunct <claude>
zombies but tears down the WHOLE tmux server, including any live pane).
The coexistence guard only ever spared foreign product prefixes
(olp-tui-*); a SECOND OCP instance on the same host — e.g. a temporary
verification instance stood up alongside production, a real pattern
used during PR #144/#146 verification — was indistinguishable from
"ours" and could have its LIVE sessions reaped/kill-server'd by the
other instance's boot or periodic sweep.
Fix: scope the session-name prefix to this instance's own listen port
(the natural stable per-instance discriminator on one host — two OCP
instances cannot share a port): `ocp-tui-<port>-`. A sibling instance's
`ocp-tui-<otherPort>-*` sessions now fail the own-prefix startsWith
check and fall into the same "othersRemain" bucket as olp-tui-*,
so they are never touched and never used to justify kill-server.
lib/tui/session.mjs:
- sessionPrefixForPort(port) replaces the bare SESSION_PREFIX export.
- reapStaleTuiSessions({ tmux, port, includeLegacy }) now requires
port and computes its own prefix from it.
- runTuiTurn({ ..., port }) builds the tmux session name from
sessionPrefixForPort(port) instead of the old bare constant.
- LEGACY_SESSION_PREFIX / LEGACY_SESSION_NAME_RE (exact
"ocp-tui-<8-hex>" shape, no port segment) describe the OLD
pre-fix session-name shape, retained only for the migration below.
Legacy migration rule (chosen + reasoning): a bare-prefix legacy
session cannot be created by any post-fix OCP process, so if one is
seen it is presumed to be an orphaned zombie from THIS instance's own
PRE-fix process generation (left behind across an in-place upgrade),
not a stranger's. reapStaleTuiSessions() therefore accepts an
includeLegacy flag: server.mjs's one-time BOOT reap passes
includeLegacy: true (claims exact-legacy-shape sessions as its own,
enabling cleanup right after an upgrade); the periodic 15-min idle
sweep does NOT set it, so a lingering legacy-shaped session during
steady-state is conservatively treated as foreign and cannot trigger
kill-server on a routine tick. Residual (documented, accepted): a
genuinely-still-running PRE-fix OCP instance coexisting on the host at
the exact moment a new instance boots could have its live legacy
session reaped — the same class of residual risk the audit finding
itself accepts ("no live instance of the new version creates them");
this PR does not regress that scenario, it only removes the far more
common same-version collision that is the actual F7 finding.
LEGACY_SESSION_NAME_RE (`^ocp-tui-[0-9a-f]{8}$`) can never match the
new shape: the new shape always inserts a literal "-" between the
port digits and the 8-hex suffix, which the anchored 8-hex-only legacy
regex cannot satisfy.
server.mjs changes are local TUI session-lifecycle infrastructure
(tmux session naming, boot/periodic reap, kill-server) with no cli.js
wire analogue — verified via `strings` against the compiled claude
CLI 2.1.198 binary (this machine ships cli.js as a Mach-O binary per
ALIGNMENT.md's "OAuth token-host verification" precedent): cli.js
contains only `env.TMUX` detection (whether IT is running inside a
tmux pane) and an unrelated `--remote-control-session-name-prefix`
flag for its own remote-control feature — no session-prefix/reap/
kill-server mechanism of any kind. Per ALIGNMENT.md Rule 2, this is
declared absent: no endpoint, header, request, or response shape
changed; only OCP's own local process-lifecycle bookkeeping. No PORT
literal was hardcoded (CI port-SPOT check) — PORT is threaded through
from the existing server.mjs SPOT (lib/constants.mjs DEFAULT_PORT via
CLAUDE_PROXY_PORT).
test-features.mjs: rewrote the reaper suite's fixture session names to
the new port-scoped shape, added tests for sessionPrefixForPort(),
LEGACY_SESSION_NAME_RE's non-collision with the new shape, a sibling
same-host OCP instance being treated as foreign (F7 regression test),
and the includeLegacy boot-migration behavior (claims legacy zombies,
still spares a sibling instance's port-scoped session).
Verified: node --check server.mjs && node --check lib/tui/session.mjs
&& npm test → 243 passed, 0 failed.
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The FIX-③ spawn-home isolation memoized the OAuth token at startup. The macOS
keychain access token rotates (~hourly, refreshed by the operator's real claude),
so the startup snapshot went stale and every isolated -p spawn returned upstream
401 'Invalid authentication credentials' — a ~31h Mac-mini outage (PI231/oracle use
static long-lived env tokens, unaffected).
Fix: getSpawnHomeMode() now caches only the isolation DECISION; the token is
re-resolved FRESH per spawn via resolveSpawnToken(), which also returns null when a
known expiry has passed (5-min buffer) so the caller falls back to real HOME — where
the spawned claude refreshes the credential natively and self-heals. OCP still never
refreshes the token itself (a refresh-token grant would consume the single-use token
and log out the operator's real claude — issue #112). Env-token hosts carry no
expiresAt and are never expiry-gated. Infra/process change; no cli.js surface, no new
endpoint/header. 238 tests pass; live-verified sonnet 200 on a temp instance.
Co-authored-by: dtzp555 <dtzp555@gmail.com>
2026-06-26 20:35:21 +10:00
46 changed files with 8993 additions and 1248 deletions
@@ -30,6 +30,7 @@ Runtime: Node.js (ESM, `.mjs` throughout). No build step. No bundler. `server.mj
-`server.mjs` — the proxy itself; every request path lives here. Governed by `ALIGNMENT.md`.
-`models.json` — single source of truth for model IDs, aliases, and context windows. See ADR 0003.
-`models.schema.json` — the schema `models.json` declares in its `$schema`. CI validates the SPOT against it (`test-features.mjs`) using the repo's own `validateJsonSchema`, so a malformed entry fails the build instead of surfacing downstream in OpenClaw.
-`setup.mjs` — first-time installer; reads `models.json` to derive bootstrap config.
-`scripts/sync-openclaw.mjs` — idempotent OpenClaw registry sync invoked by `ocp update`. See ADR 0004.
@@ -52,6 +53,22 @@ Runtime: Node.js (ESM, `.mjs` throughout). No build step. No bundler. `server.mj
---
## Testing: reaching faults inside `server.mjs`
`test-features.mjs` cannot `import``server.mjs` (it calls `server.listen()` at top level), and that has twice led to the wrong conclusion that a class of bug is untestable. It isn't. Read this before writing "no regression test is possible here".
**There is a real live-server fixture.**`ltBoot(env, dir, nodeArgs)` (around `test-features.mjs:990`) spawns the actual `server.mjs` as a child with a **fake `claude` binary**, so integration tests cost no quota. `ltPost` / `ltPostStatus` / `ltWait` / `ltFreePort` round it out. It already covers boot gates, cache-epoch invalidation across two boots sharing one SQLite store, and system-prompt capture.
**`--stack-size` is a fault lever.** `ltBoot`'s third argument passes V8 flags to the child, which puts recursion- and argument-count-limited failures in reach at a much smaller input. `#193` needed a *synchronous throw* deep inside `spawnClaudeProcess`; `buildCliArgs` does `args.push("--allowedTools", ...ALLOWED_TOOLS)`, and under `--stack-size=200` that spread throws at ~24k elements instead of ~124k — which is what brings the trigger under Linux's `MAX_ARG_STRLEN` (131072 bytes for a single env string) so the test runs in CI rather than skipping. **No production fault hook was needed.**
Three rules that made it hold up, all learned the hard way:
- **Discover the threshold in a child under the same flags**, never in the test process — the parent's stack is not the one that matters.
- **Assert that the fault actually fired**, not just that the outcome looks right. `#193` asserts HTTP 500 *and* that the body carries `call stack size exceeded`; a control mutation (trigger neutered, bug still present) proves the test fails rather than passing vacuously.
- **Wait for the thing you are about to assert**, not a proxy for it. Waiting on `listening on` and then asserting a different line is a race (`#199`); waiting for the process to *exit* and then reading its `stderr` is another, because a terminated child's pipes may still hold unread data (`#203` — wait for the stdio to close, not for the exit).
Allocate ports with `ltFreePort()`. Fixed ports have caused at least one flake here.
## Release protocol
OCP follows the machine-readable `release_kit:` overlay in `CLAUDE.md` (Iron Rule 5.5). Before any version bump or tag push, re-read that YAML block and walk every item in `new_feature_doc_expectations` and `bootstrap_quirk_policy`. Tag push triggers `.github/workflows/release.yml`, which creates the GitHub Release automatically — do not create the release manually.
- **`maxTokens` now matches the CLI registry instead of a uniform 16384 (#195).** Every Opus entry and `claude-sonnet-5` go to **64000**, `claude-sonnet-4-6` and `claude-haiku-4-5` to **32000** — the `max_output_tokens.default` each model declares in the compiled CLI 2.1.220 registry. This corrects **advertised metadata only**: `models.json` is the SPOT (ADR 0003) and every value in it should be the truth about the model. **It changes nothing about how OCP behaves.** OCP never enforces `maxTokens` — `buildCliArgs` passes no output-token flag to the CLI at all — and OpenClaw addresses a local OCP over `openai-completions`, whose request field (`max_completion_tokens`) appears nowhere in this repo. The value is consumed only by clients that choose to honour it, via `setup.mjs` / `scripts/sync-openclaw.mjs` / `ocp-connect`. **Expect no change in answer length or quota burn.**`ocp-connect`'s independent family table moves to the floor over each family's current `models.json` members (opus 64000, sonnet 32000, haiku 32000), since prefixes cannot distinguish versions. Its unknown-id fallback stays at **8192** — the registry's global minimum, held by `claude-3-5-haiku` and `claude-3-5-sonnet`, which are the only real ids that reach it (`claude-3-5-*` matches no family prefix).
## v3.25.0 — 2026-07-27
Minor release. Headline: **Claude Opus 5** joins the model list and the `opus` alias now resolves to it. Alongside it, two `server.mjs` correctness fixes found by review rather than by users — a monotonic in-flight-counter leak, and cache keys that were hashing the alias string instead of the model it resolves to. The three TUI/prompt fixes that landed after v3.24.0 are included here too.
Every code PR carried an independent fresh-context reviewer (Iron Rule 10); #192 additionally went through the external codex gate, which is what surfaced the cache-key defect. No new endpoint, no new env var, no new `cli.js` wire behavior.
### Added
- **Claude Opus 5 (`claude-opus-5`) (#192).** New `models.json` entry — `/v1/models` goes 6 → 7 and OpenClaw picks it up on the next `ocp update` (via `scripts/sync-openclaw.mjs`). Verified against the installed CLI rather than assumed: the compiled `claude` 2.1.220 bundle carries `latest_per_family:{fable:"claude-fable-5",opus:"claude-opus-5",sonnet:"claude-sonnet-5",haiku:"claude-haiku-4-5"}`. Availability confirmed with a live `claude -p --model claude-opus-5` completion on the subscription pool. `claude-opus-4-8` is retained for pinning.
-`contextWindow` is deliberately **200000**, not Opus 5's native 1M. Two reasons, both verified: (1) `MAX_PROMPT_CHARS` is a **single global** budget — `derivePromptCharBudget` takes `max(contextWindow) × 3` across *all* entries (`lib/prompt.mjs`), so a 1M entry would raise the truncation ceiling to 3,000,000 chars for `claude-haiku-4-5` too, which is genuinely 200k native, converting clean OCP-side truncation into an upstream API rejection; (2) OpenClaw scales its history budget linearly off this value (`contextWindow × maxHistoryShare × SAFETY_MARGIN` = `× 0.6`, plus an oversized-message threshold at `× 0.5`, per `compaction-planning` in OpenClaw 2026.7.1), and its own bundled registry hardcodes 200000 for Claude — the upstream request to raise it to 1M ([openclaw#22979](https://github.com/openclaw/openclaw/issues/22979)) was closed *not planned*. A new regression test pins the invariant so a future 1M entry has to be a deliberate, reviewed change. Raising it for real needs per-model budgets — tracked separately, ADR-level.
### Changed
- **The `opus` alias now resolves to `claude-opus-5` instead of `claude-opus-4-8` (#192).** Every request that names `opus` — and OpenClaw's opus entry — moves to Opus 5 on upgrade. This mirrors what the CLI itself defaults to (`latest_per_family.opus` above), the same reasoning as #168's `sonnet` → `claude-sonnet-5` repoint in v3.23.0. **Pricing is unchanged** ($5/$25 per MTok; CLI registry `pricing:"tier_5_25"` for both Opus 4.8 and Opus 5), so this carries no cost change. Pin `claude-opus-4-8` explicitly to stay on the previous model.
### Fixed
- **Cache keys hashed the alias, not the model it resolves to (#194).** `model` enters `cacheHash` exactly as the client sent it, so a request for `"opus"` was cached under the literal `"opus"` — and since `models.json` is read once at boot while the SQLite `response_cache` outlives a restart, repointing an alias kept serving the **old model's** answers under it until TTL expiry. That would have silently defeated this release's own `opus` repoint for anyone running with the cache on. All three call sites now hash `Object.hasOwn(MODEL_MAP, model) ? MODEL_MAP[model] : model`, which fixes the normal, structured **and** single-flight keys at once and covers `legacyAliases` for free, with precise invalidation (only the repointed alias's entries change key) rather than a whole-cache flush. (`hasOwn` rather than a bare lookup: `MODEL_MAP` is a plain object, so `MODEL_MAP["__proto__"]` would return a truthy *object* and not even fall through to the `|| model` guard.) **Also closes a latent gap from #177:** the structured and dedup keys never passed `configEpoch` at all, so a `CLAUDE_SYSTEM_PROMPT` change — the original #176 scenario — kept serving structured answers composed under the old config, live since #153. Found by the external codex review of #192.
- **In-flight request counter leaked permanently on a pre-spawn throw (#193, reported by @konceptnet in #180).** `stats.activeRequests` was incremented ~40 lines before the child spawn, while its only decrement is reached through that process's events — so any synchronous throw in between (`buildCliArgs`, env assembly, the spawn decision, or `spawn()` itself) leaked `+1` forever, and `/health` and `/status` over-reported in-flight work monotonically. The increment now sits immediately after `activeProcesses.add(proc)`, structurally pairing it with the decrement; no reconciliation pass and no try/catch. Observability-only field, so no admission decision changes.
- **TUI: the host `CLAUDE.md` could leak into proxied turns (#187, contributed by @sumlin).** The TUI pane now spawns with `--safe-mode`.
- **TUI: `shift+tab to cycle` is accepted as an input-ready marker (#188, contributed by @sumlin).** Claude renders one of two ready-state footers depending on the build — the classic `? for shortcuts` hint, or `shift+tab to cycle` (as part of `⏵⏵ bypass permissions on (shift+tab to cycle)`) on newer 2.1.x. The matcher only knew the classic string, so on those builds it silently reported "never ready": every boot timed out with `tui_pane_not_ready`, and with the warm pool on, every pre-boot failed.
- **`OCP_LOCAL_TOOLS` no longer hard-codes a tool list in its wrapper (#191, closes #185).** The positive wrapper claimed a fixed set of tools regardless of `CLAUDE_ALLOWED_TOOLS`, so a narrowed tool surface was described inaccurately to the model.
### Testing
- The response-cache and counter fixes ship with **mutation-proven integration tests** built on the existing `ltBoot` child-process fixture (real `server.mjs`, fake `claude` binary, so no quota cost). The counter test reaches a synchronous fault *inside*`spawnClaudeProcess` from environment alone — no production fault hook — by running the child under `--stack-size=200` to lower the spread-throw threshold enough to fit Linux's `MAX_ARG_STRLEN`. Suite: **447 → 457** across this release (per PR: #188 +1, #187 +1, #191 +0, #194 +4, #192 +3, #193 +1).
## v3.24.0 — 2026-07-21
Minor release. Headline: two long-requested **OpenAI-compat features** land — **multimodal vision** (`image_url` parts) and **structured outputs** (`response_format` / JSON schema). Also: the prompt-char budget now derives from the model SPOT instead of a hand-set constant, an agentic-turn bug that dropped the model's final answer is fixed, and `OCP_LOCAL_TOOLS` supports the OpenClaw-backend use case. Four of the six landed from external contributors (@vvlasy-openclaw). Every code PR carried a fresh-context reviewer (Iron Rule 10); no new endpoint, no new `cli.js` wire behavior.
### Added
- **Multimodal vision — OpenAI `image_url` parts (#154, contributed by @vvlasy-openclaw).** `/v1/chat/completions` forwards OpenAI `image_url` content parts to `claude` as native Anthropic image blocks via `--input-format stream-json` (the CLI's own contract — no invented wire shape, verified live). Base64 `data:` URIs by default; remote `http(s)` URLs are off unless `CLAUDE_IMAGE_ALLOW_URL=1` (and even then OCP never fetches them — no SSRF surface). Byte/count caps (`CLAUDE_MAX_IMAGE_BYTES`, `CLAUDE_MAX_IMAGES`, `CLAUDE_MAX_IMAGE_TOTAL_BYTES`), all fail-closed on a misconfigured value. TUI mode returns `400 images_unsupported_in_tui_mode` (it can't carry image blocks) and an image present only in a `system` message returns `400` rather than being silently dropped. README § "Images / Multimodal".
- **Structured outputs — OpenAI `response_format` (#153, contributed by @vvlasy-openclaw).** `/v1/chat/completions` honors `response_format: { type: "json_schema" | "json_object" }` so OpenAI-SDK clients (Home Assistant AI Tasks, Honcho, scripts) get machine-parseable JSON in `content`. Validates against the schema (incl. `$ref`/`$defs` + `allOf`/`anyOf`/`oneOf` — the shapes the OpenAI SDK emits), retries with a stronger instruction up to `OCP_STRUCTURED_MAX_ATTEMPTS` (default 3, fail-closed), and on exhaustion returns OpenAI's own `refusal` field (200/`content:null`) rather than an invented error. Cyclic-`$ref` schemas fail closed (no stack overflow); a pathologically deep model reply returns a refusal, not a 500 (#181). Single-flight dedup + structured-keyed cache bound the cost. Class B.1 (ADR 0006). README § "Structured Outputs".
- **SPOT-derived prompt-char budget (#179, ADR 0009).** `MAX_PROMPT_CHARS` default now derives from `max(models.json contextWindow) × 3 chars/token` (600,000 today) instead of the hand-set 150,000 (~37.5k tokens) that silently under-delivered the advertised window ~5×. `CLAUDE_MAX_PROMPT_CHARS` and the settings API remain absolute overrides; a garbage value fails closed to the derived default.
- **`OCP_LOCAL_TOOLS` — positive local-tools system-prompt wrapper (single-user, loopback only; default off) (#182, contributed by @vvlasy-openclaw).** The `-p` path prepends a wrapper telling the model it has no local filesystem/shell access — correct for a shared gateway, but it makes a personal instance's model (e.g. an OpenClaw agent on its own local OCP) refuse to use the server-side `claude` tools it legitimately has. `=1` swaps in a positive wrapper. Changes **only the prompt**, never the tool surface (`--allowedTools`/`--disallowedTools` untouched; multi-tenant still disallows the FS surface); it does **not** enable client-side `tool_calls` (still unsupported by design). Fail-closed boot gate mirroring `OCP_TUI_FULL_TOOLS` (ADR 0007): refuses to start under `CLAUDE_AUTH_MODE=multi`, a non-loopback bind, or `PROXY_ANONYMOUS_KEY`. Inert (and logged as such) in TUI mode. The active wrapper is folded into the config epoch so toggling it invalidates the standard response cache. No new `cli.js` wire behavior (reuses the already-cited `--system-prompt` flag).
### Fixed
- **Agentic turns dropped the model's final answer (#183, contributed by @vvlasy-openclaw).** On a tool-using turn, `/v1/chat/completions` returned only the opening preamble ("I'll find the repo…") and silently discarded the post-tool-use final answer: aggregate-`assistant` extraction was gated on `isFirstDelta` (which flips false after the first text), and OCP runs pure-aggregate mode (no `--include-partial-messages`), so each of an agentic turn's several assistant messages after the first was lost. Now guards on `sawTextDelta` and accumulates every assistant message (streaming and buffered paths assemble byte-identically).
- **Deep structured reply returned a 500 instead of a refusal (#181 / #184).** `validateJsonSchema` recurses on the model reply's nesting depth; a ~2000-level-deep reply overflowed the stack → caught `RangeError` → generic 500. A crash-safe façade converts that (only) into a validation miss → refusal; any other throw still surfaces.
## v3.23.0 — 2026-07-17
Minor release. Headline: **the default `sonnet` alias now resolves to Claude Sonnet 5** — a behavior change for every request that omits `model` (pin `claude-sonnet-4-6` by full ID to keep the previous default). Also: Windows-safe upgrade snapshots, two upgrade-system reliability fixes from a live fleet update, the `CLAUDE_SYSTEM_PROMPT` env var made functional, cache-key honesty for config changes, a billing-policy status correction (the 2026-06-15 `-p` split is PAUSED by Anthropic), and a major README restructure. No new endpoint; no new `cli.js` wire behavior. Every code PR carried a fresh-context reviewer (Iron Rule 10).
### Changed
- **Default `sonnet` alias → `claude-sonnet-5` (#168, contributed by @vvlasy-openclaw).** The `sonnet` alias (the model used for every `/v1/chat/completions` request that omits `model`, and OpenClaw's OCP primary via `ocp-connect`) now resolves to `claude-sonnet-5` instead of `claude-sonnet-4-6`. `claude-sonnet-4-6` remains available by full ID for pinning. Mirrors the shipped Claude CLI's own `latest_per_family` mapping (`sonnet → claude-sonnet-5`, verified from binary 2.1.211). Split out from the additive model entry (#152) per Iron Rule 11.
- **`CLAUDE_SYSTEM_PROMPT` is now functional (#175).** The var was read, documented, and echoed on `/health.systemPrompt` but never reached a request (dead since the `APPEND_SYSTEM_PROMPT` retirement). It is now appended (last, trimmed) to the composed system prompt on the default `-p` path via the new pure `lib/prompt.mjs`; TUI-mode panes are unaffected. Unset ⇒ byte-identical composition to before. README § Environment Variables documents it, including the cache caveat below.
### Fixed
- **Windows-safe upgrade snapshot paths (#167, contributed by @nyxst4ck).** Snapshot directory timestamps now use `-` instead of `:` (Windows forbids `:` in names); legacy colon-named snapshots keep parsing, and `listSnapshots` now orders by **parsed timestamp** (with a deterministic name tie-breaker) so mixed legacy/new names sort chronologically — the initial revision's raw-string sort could delete the newest recovery snapshot at the format boundary and was caught in review; regression tests pin the same-hour mixed-format case.
- **`ocp update` reliability — two live-incident fixes (#174, closes #173).** (1) The doctor now runs `git fetch --tags` (offline-tolerant) before computing `latest_version` — previously it compared against the locally cached `origin/main`, so machines that hadn't pulled since a release reported "Already at latest" forever. (2) Post-flight now asserts `/health.version` equals the upgrade target (new `postFlightOk` predicate) instead of accepting any `auth.ok` — a stale orphan process holding the port used to pass post-flight while still serving the old version; the failure message now reports the last-seen version and points at `ss -ltnp`/`lsof -i`.
- **Response-cache key now carries a boot-config epoch (#177, closes #176).** The persistent cache keyed on model+key+params+messages but not on server config that shapes answers (`CLAUDE_SYSTEM_PROMPT`, wrapper text, `CLAUDE_ALLOWED_TOOLS`, `CLAUDE_NO_CONTEXT`) — changing any of these and restarting could serve stale-config answers until TTL expiry. A sha256 config-epoch is folded into every key; any config change is an instant whole-cache invalidation. One-time side effect: existing cache entries miss once after this upgrade.
### Docs
- **Billing-policy status corrected (#171).** Anthropic **paused** the announced 2026-06-15 `claude -p` billing split on its effective date (official help-article citation in README § How It Works): the default `-p` path currently bills the subscription, and TUI-mode is reframed as the ready-made **hedge** for if/when a reworked change lands. All in-force assertions of the split are now date-stamped and conditioned.
- **LAN mode scoped to chat-class workloads (#171).** New "workload fit" paragraph: multi-device OCP is for text-in/text-out workloads; client-machine coding agents are architecturally out of scope (tools execute on the OCP host).
- **README restructured, 1205 → ~500 lines (#172).** Operations-manual content moved to `docs/lan-mode.md`, `docs/tui-mode.md`, `docs/troubleshooting.md`, `docs/upgrading.md` (verbatim moves + two canonical dedups; zero content loss verified section-by-section). README keeps the quickstart, the release-kit-pinned reference tables, and summary stubs with links. Plus a staleness sweep (#170): 6-model examples, removal of the never-existed `ocp stop` command, `ocp-connect` claims corrected, current version examples.
## v3.22.1 — 2026-07-17
Minor release: TUI-mode latency and streaming features — **all opt-in and off by default**, so the default request path (`-p` / `--output-format stream-json`) is byte-for-byte unchanged — plus hardening from an independent (Codex) re-review of the streaming work, Windows `claude.exe` startup resolution, and the Claude Sonnet 5 model entry. No new `cli.js` wire behavior and no new endpoint; the new surface is entirely OCP-owned TUI-mode configuration (env vars), startup binary discovery, model metadata, and `/health` observation. Every code PR carried a fresh-context reviewer (Iron Rule 10). (Version note: v3.22.0 was prepared but never tagged; its contents ship here as v3.22.1 together with the additions below.)
### Added
- **Claude Sonnet 5 in the model SPOT (#152, contributed by @vvlasy-openclaw)** — `claude-sonnet-5` added to `models.json` (`contextWindow` 200000 / `maxTokens` 16384 / `reasoning` true, consistent with existing entries), exposed via `/v1/models` and the OpenClaw sync. Purely additive: the `sonnet` alias still resolves to `claude-sonnet-4-6` (the repoint is tracked separately in #168). `ocp-connect`'s model classifier now matches on the model *family* prefix (`claude-sonnet`/`claude-opus`/`claude-haiku`) instead of version-pinned prefixes, so current and future versioned IDs register with correct `reasoning`/`maxTokens` metadata. New referential-integrity tests guard that every alias target exists in `models[]`.
- **Windows `claude.exe` startup resolution (#161, contributed by @nyxst4ck, diagnosis credit #147@Justinsato)** — on Windows, `resolveClaude()` now discovers a native `claude.exe` (`%USERPROFILE%\.local\bin`, WinGet Links, WindowsApps, then `where.exe`) and rejects npm `.cmd`/`.bat`/`.ps1` shims, which cannot be spawned without a shell — previously startup resolved a shim and failed. A non-`.exe``CLAUDE_BIN` on Windows is a fatal error with an actionable hint. The macOS/Linux path is byte-for-byte unchanged. Note: this is startup binary resolution only — full Windows support is not yet claimed (snapshot-path portability is tracked in #167).
### Added — TUI mode (all opt-in, default off)
- **Spawn effort control — `OCP_TUI_EFFORT` (default `low`) (#156)** — the interactive `claude` is now spawned with an explicit `--effort` flag. `low` cuts measured TTFT p50 by ~40% and collapses run-to-run variance ~15× versus an inherited `xhigh`; proxied requests rarely benefit from extended thinking. Set `inherit` to omit the flag and restore the pre-flag HOME-dependent behaviour. Banner-verified to stay on the subscription pool (`· Claude Max`); an invalid value warns and falls back to `low`. README § "Environment Variables".
- **Warm pane pool — `OCP_TUI_POOL_SIZE` (default `0` / off) (#158)** — pre-boots up to 4 single-use `claude` panes so a request skips the cold boot: measured end-to-end p50 `10.17s` → `6.00s` (−41%) on a Mac mini (Sonnet 4.6, `--effort low`). Opt-in because each warm pane is a live idle process held whether or not a request ever arrives. Panes are single-use (one turn, then killed and replaced in the background), port-scoped (`ocp-tui-<port>-p<hex>`), and coexist with the zombie reaper by a synchronous drain→reap→resume sweep. README §§ "Environment Variables" + "How It Works".
- **Real SSE streaming — `OCP_TUI_STREAM` (default `0` / off) (#159, #160)** — `stream:true` turns emit real `delta.content` chunks as `claude` generates them, sourced from `claude`'s own `MessageDisplay` hook (registered via `--settings` on the ordinary interactive spawn — banner-verified on the subscription pool). Granularity is block-level, and it moves the *first* byte, not the last. The transcript stays authoritative: streamed text is asserted equal to it at end-of-turn, the auth-banner and truncation gates still run before anything is committed, and a turn whose stream cannot be reconciled is **refused** (SSE error frame, not cached) and counted on `/health` (`tui.streamDivergences`; a silent total-hook-failure is counted separately as `tui.streamZeroDeltaTurns`). Tunables: `OCP_TUI_STREAM_HOLDBACK` (default `100`), `OCP_TUI_STREAM_DIR`, `OCP_TUI_STREAM_POLL_MS`. See ADR 0007 (2026-07-13 amendment). README §§ "Environment Variables" + "How It Works".
### Fixed
- **Streaming auth-banner guard: a null `message_id` on the first hook fire (#160)** — a first `MessageDisplay` fire with a null `message_id` could disarm the auth-banner guard; re-landed after a #159 squash dropped it (`lib/tui/stream.mjs`).
- **Test suite wrote live, unrevoked API keys into the operator's real key store (#163)** — `npm test` had been opening `~/.ocp/ocp.db` (the running server's DB) and writing two junk `api_keys` rows per run (737 accumulated on the maintainer's host), because the isolation the comments claimed was never wired (ESM import hoisting). `keys.mjs` now honors `OCP_DIR_OVERRIDE` under `NODE_ENV=test` and the suite points at a scratch dir; a child-process probe verifies a production process (no `NODE_ENV`) cannot be redirected.
- **Streaming holdback floor + billing-pool observation on failed turns (#164)** — (A1) `OCP_TUI_STREAM_HOLDBACK` now clamps up to the safe floor (`100`) with a boot warning, closing a latent auth-banner leak when an operator set a sub-floor value. (A3) the `cc_entrypoint` (billing-pool) observation is now recorded before the honesty gates that throw, so `/health` no longer goes blind to exactly the failed turns most likely to signal a silent degrade to the metered Agent SDK pool.
- **Test-only key-store redirection vars can no longer reach a server OCP launches (#165)** — (A4) `NODE_ENV`/`OCP_DIR_OVERRIDE` are stripped from every service unit `setup.mjs` writes (`plist-merge`'s `NEVER_PRESERVE`) and from the `ocp restart` manual nohup fallback (`env -u`); #163's overstated "a prod server can NEVER be redirected" comments were softened to name the one residual hand-launch path and the loud `getDb()` "NOT the default" backstop.
### Docs
- **README billing honesty (#162, closes #136)** — removed a feature bullet that promised what the § "honest limits" section forbids.
- **TUI latency plans + streaming-achievability spike (#155, #157)** — measured latency decomposition, backlog, and the `MessageDisplay`-hook streaming prereq spike under `docs/plans/2026-07-13-tui-latency/`.
## v3.21.1 — 2026-07-07
Patch release: three bug fixes from an independent concurrency/session-lifecycle audit, each its own PR with a fresh-context reviewer (Iron Rule 10). No new `cli.js` wire behavior, no new endpoint, header, or env var; the `/health` field set is unchanged (only value truthfulness improved).
### Fixed
- **TUI session-scope / boot-reap (#148)** — `lib/tui/session.mjs`'s tmux session prefix is now scoped per-instance by listen port (`ocp-tui-<port>-`) instead of a bare host-wide `ocp-tui-` constant, so a second OCP instance on the same host (e.g. a temporary verification instance) can no longer have its live TUI sessions reaped or `kill-server`'d by another instance's boot/periodic sweep. The one-time boot reap also claims exact-shape legacy `ocp-tui-<8hex>` sessions (pre-fix naming) once, to clean up zombies left behind across an in-place upgrade.
- **`-p` spawn-token mutex + keychain caching (#150)** — the real-HOME token fallback used when the keychain token is within its 5-minute expiry window is now serialized behind a mutex, so concurrent `-p` spawns no longer race the same single-use refresh token against each other (the credential-fork hazard). Added a 30s TTL cache + last-good-label memoization for the keychain read, cutting per-spawn event-loop blocking. The isolation decision (`/health` isolated/real-home reporting) is now re-evaluated per spawn instead of memoized forever, so `/health` no longer misreports a stale decision. New module `lib/spawn-auth.mjs` extracts the pure, unit-testable primitives (mutex, TTL cache, expiry gate, label ordering).
- **Concurrency queue / disconnect handling (#149)** — the shared semaphore now honors a runtime-lowered `maxConcurrent` immediately (previously a decrease was silently ignored until in-flight tasks finished on their own) and wakes queued waiters right away when the limit is raised. Queued `-p`/TUI requests are now linked to the client's HTTP connection via `AbortSignal`; a client that disconnects while queued is spliced out of the queue instead of still spawning `claude` once a slot frees. A singleflight follower whose leader disconnected now retries instead of inheriting a spurious 500, and a queued-then-disconnected request is no longer recorded as a usage failure or logged as an error (quiet disconnect handling).
## v3.21.0 — 2026-06-25
Cleanup + docs release: TUI dead-code removal, docs honesty, and release prep. No new `cli.js` wire behavior; the default path (`CLAUDE_TUI_MODE` unset) is byte-for-byte unchanged.
@@ -56,7 +56,7 @@ Add `CLAUDE_TUI_MODE=true` as an opt-in flag in `server.mjs`.
3. The serialized prompt (from `messagesToPrompt`) is pasted via `tmux send-keys … "$(cat file)"` + a separate `Enter` key event.
4. The answer is read from claude's native JSONL transcript at `<HOME>/.claude/projects/<encoded-cwd>/<session-id>.jsonl`, polling until a `turn_duration` system event or the wall-clock cap (`CLAUDE_TUI_WALLCLOCK_MS`, default 120 s).
5. The string answer is returned to OCP's existing downstream (singleflight → cache write-back → `completionResponse` / `streamStringAsSSE`) — **same contract as `callClaude`**.
6. Streaming requests are buffered then replayed as chunked SSE (no real token streaming — deliberate; "don't build fragile features").
6. Streaming requests are buffered then replayed as chunked SSE (no real token streaming — deliberate; "don't build fragile features").**Superseded for `stream:true` when `OCP_TUI_STREAM=1` — see the 2026-07-13 amendment below. The buffered path remains the default and is unchanged.**
@@ -333,6 +333,61 @@ The original "Home strategy" section and PR-C's `prepareTuiHome` comment warned
---
## Amendment (2026-07-13) — real SSE streaming via the `MessageDisplay` hook (`OCP_TUI_STREAM`)
**Supersedes**: Request-flow step 6 above ("no real token streaming — deliberate"), for `stream:true`
requests when `OCP_TUI_STREAM=1`. The buffered path stays the default and is byte-for-byte unchanged.
**Context.** Step 6 was written when the interactive CLI appeared to expose no byte-faithful
incremental source. A prereq spike (`docs/plans/2026-07-13-tui-latency/streaming-spike.md`) confirmed
three obvious sources are dead ends — the transcript JSONL grows one *whole event* at a time (the
answer lands as a single line ~0.3 s before the terminal marker); `tmux capture-pane` yields a
*rendered* view whose markdown source is unrecoverable (an H2 and a bold span produce identical ANSI);
`--debug-file` logs stream *timing*, never stream *content*. Every interface that does emit
`text_delta` (`--output-format stream-json`) requires `-p`, which moves the request to the **metered**
`sdk-cli` pool — precisely what TUI-mode exists to avoid.
**Decision.** Consume `claude`'s own **`MessageDisplay`** hook, registered via `--settings` on the
ordinary interactive spawn (no `-p`, no `--bare`). Each fire delivers the **raw markdown source** of an
incremental `delta` on the hook's stdin. Verified live (claude 2.1.207, sonnet-4-6): banner stays
`· Claude Max` and the transcript `entrypoint` stays `cli` (subscription pool); `concat(deltas) === T`
byte-exactly; `T.startsWith(concat(deltas[0..n]))` at every *n*. This is **forwarding, not inventing**
— ALIGNMENT.md **Class B**. No `cli.js` citation applies: the TUI spawn is OCP-owned surface (this
ADR), the hook payload is claude's own published contract, and the SSE wire shapes are the OpenAI
chat/completions streaming spec adopted by **ADR 0006** (the emitters are literally the `-p` path's).
**The transcript remains authoritative.** It is still the terminal-turn signal, still the source of the
returned/cached text `T`, and still the input to the honesty gates (auth-banner detection C-1,
`truncated` C-2). The delta stream is a low-latency **mirror**, never a replacement. At end of turn OCP
asserts the streamed bytes against `T`: equal → serve; a strict *prefix* of `T` → top up from the
transcript (client still receives exactly `T`); **not** a prefix → **refuse the turn** (SSE error frame,
no cache, `tui.streamDivergences++`). Serving text the transcript disagrees with is the failure class
ALIGNMENT.md exists to prevent, so streaming fails loud rather than degrading quietly.
**Consequences / constraints recorded for future authors:**
- **Opt-in, default OFF.** The buffered path is stable production; streaming does not change it.
- **Per-`session_id` sink is mandatory, not an optimization.** `OCP_TUI_MAX_CONCURRENT` defaults to
**2** — two `claude` panes already run concurrently. A single shared sink would interleave one
client's deltas into another's stream. The hook writes to `<dir>/<session_id>.jsonl`, the path
delivered through the *pane's own env* (`OCP_TUI_STREAM_FILE`); OCP reads only its own turn's file.
Verified with two concurrent streamed turns (ALPHA/BRAVO): zero cross-contamination.
- **Warm-pool compatible (a separate in-flight PR depends on this).** The hook script and the settings
file are **static** — nothing request-specific is baked in at spawn time. The sink path derives from
the session-id, which for a pre-booted pane is fixed at boot.
- **The hook is synchronous** (`forceSyncExecution: true` — `claude`*blocks* on it). The hook script
must write and exit; it does one `cat` append and nothing else. Measured: p50 **7.2 ms** per fire,
~50 ms across a whole turn — noise against a 6–10 s turn. Do not add work to it.
- **Thinking blocks do not fire the hook** — verified on a substantive Opus/`xhigh` reasoning turn (see
the PR evidence), not merely inferred from the `final:true` call site. This must be **re-verified** if
the hook is ever pointed at a new model/effort tier: a thinking delta reaching a client would be
unretractable, and the `concat === T` assertion can only *detect* that after the fact, never prevent
it. The first-bytes **holdback** (`OCP_TUI_STREAM_HOLDBACK`, default 100 chars) is the same
prevention-not-detection reasoning applied to the auth-banner gate.
- **Block-level granularity**, scaling with answer length — not token-level. Do not promise otherwise.
- **It moves the first byte, not the last.** Only a progressively-rendering consumer benefits; it does
not move TUI-mode's ~6 s TTFT floor.
## Provenance
TUI-mode originated in a prototype contributed via PR #101 (see the PR for author attribution). The productionization design is in `docs/superpowers/specs/2026-05-30-tui-mode-production-design.md`. Spikes S1–S6 / T1–T6 were validated live on the test host against `claude v2.1.158`.
@@ -23,6 +23,9 @@ New ADRs increment from the highest existing number. Filenames are
| [0004](0004-openclaw-auto-sync.md) | OpenClaw Auto-Sync | Why `scripts/sync-openclaw.mjs` runs on `ocp update`, what its scope boundary is (writes only `models.providers["claude-local"].models` and `agents.defaults.models["claude-local/*"]`), and the idempotency contract. |
| [0005](0005-no-multi-provider.md) | No Multi-Provider | Why OCP stays single-provider (Anthropic-via-cli.js) and does not extend to OpenAI / Gemini / OpenRouter. Cost estimate: ~7 weeks for a v1 that buys neither moat nor commercial readiness. Separate commercial work starts in a separate repo. |
| [0006](0006-openai-shim-scope.md) | OpenAI Shim Scope | The Class A / Class B taxonomy. Class A endpoints (`cli.js`-mirror) keep Rules 1–5 verbatim; Class B endpoints (OCP-owned compatibility surface — `/v1/chat/completions`, `/v1/models`, admin endpoints) are anchored to OpenAI's spec (B.1) or to an authorizing ADR (B.2). Triggered by PR #99 (external `response_format` honoring). Grandfathers the existing B.2 inventory at v3.16.4. |
| [0007](0007-tui-interactive-mode.md) | TUI Interactive Mode | Why TUI-mode spawns an interactive `claude` in a tmux pane (no `-p`) to reach the **subscription** billing pool (`cc_entrypoint=cli`) rather than the metered Agent SDK pool. Owns the TUI spawn machinery: entrypoint labeling, credential-isolated home, MCP hard-disable, session namespace + defunct-session reaping, the independent concurrency bound, and the `/health``tui` block. **Single-user only** — hard FATAL on multi-user configs. |
| [0008](0008-tui-warm-pane-pool.md) | TUI Warm Pane Pool | Why `OCP_TUI_POOL_SIZE` pre-boots **single-use**`claude` panes (one turn each, own `--session-id`) — and why reuse is forbidden (`transcript.mjs` returns the last assistant entry in the file, so a reused session leaks the earlier turn's text). Measured −41% end-to-end. Defines the pool↔reaper invariant (exemption by exact name from a live registry; drain before every sweep so `kill-server` zombie reaping survives) and the standing idle-process cost. Extends ADR 0007. |
| [0009](0009-spot-derived-prompt-budget.md) | SPOT-Derived Prompt Budget | Why `MAX_PROMPT_CHARS`'s default is `max(models.json contextWindow) × 3 chars/token` (600k chars today) instead of a hand-set constant — the old 150k silently under-delivered the advertised window ~5×. ×3 is the CJK-safe multiplier; env/settings stay absolute overrides; whether to advertise 1M windows is explicitly a separate decision. |
Part of [OCP](../README.md) — LAN & multi-user: server setup, client connect, API-key management, per-key quotas, anonymous access, and the deployment/security model (including the honest limits of sharing).
# LAN & multi-user
OCP has two roles: **Server** (runs the proxy, needs Claude CLI) and **Client** (connects to a server, zero dependencies).
```
┌─ Server (always-on device) ─────────────────────────────┐
│ Mac mini / NAS / Raspberry Pi / Desktop │
│ Claude CLI + OCP server → bound to 0.0.0.0:3456 │
> **Recommended:** Install OCP on a device that stays powered on — Mac mini, NAS, Raspberry Pi, or a desktop that doesn't sleep. This ensures all clients always have access.
**Prerequisites:**
- macOS or Linux (Windows is not supported — `setup.mjs` installs launchd / systemd auto-start)
- Node.js 22.5+ (Node 23+ recommended — `node:sqlite` is fully stable without flags from 23.0; on 22.5–22.x it works behind `--experimental-sqlite`)
- `git`
- [Claude CLI](https://docs.anthropic.com/en/docs/claude-cli) — install and authenticate:
```bash
npm install -g @anthropic-ai/claude-code
claude auth login # prints a URL + code — open URL on any browser, sign in, paste code back
```
Headless servers (Pi / NAS / VPS without a desktop browser): see [Headless install notes](#headless-install-notes) below.
```bash
# 1. Clone and run setup
git clone https://github.com/dtzp555-max/ocp.git
cd ocp
node setup.mjs
```
The setup script will:
1. Verify Claude CLI is installed and authenticated
2. Start the proxy on port 3456
3. Install auto-start (launchd on macOS, systemd on Linux)
After install the `ocp` CLI lives at `~/ocp/ocp`. To put it on your PATH, either symlink it manually (`ln -sf ~/ocp/ocp ~/.local/bin/ocp` if `~/.local/bin` is on your PATH, or `sudo ln -sf ~/ocp/ocp /usr/local/bin/ocp` for a system-wide symlink) or add an alias (`alias ocp=~/ocp/ocp`). Otherwise invoke it as `~/ocp/ocp <subcommand>`. The rest of this document assumes `ocp` is on your PATH.
> **Cloud/Linux servers:** If `ocp: command not found` after a cloud install, the binary isn't in PATH. Full path in that layout: `~/.openclaw/projects/ocp/ocp`
**Single-machine use** — just set your IDE to use the proxy:
```bash
export OPENAI_BASE_URL=http://127.0.0.1:3456/v1
```
**LAN mode** — reach OCP from your own devices on the network (Claude Pro/Max are per-user accounts — see [Sharing with family / a team — honest limits](#deployment-model--security-read-this) before extending access to other people):
```bash
# Enable LAN access with per-user auth (recommended)
node setup.mjs --bind 0.0.0.0 --auth-mode multi
```
Then create API keys for each person/device:
```bash
# Generate a strong admin key (one-time — save it for later key management):
export OCP_ADMIN_KEY=$(openssl rand -base64 32)
# Add the same export line to ~/.zshrc or ~/.bashrc so it persists.
ocp keys add wife-laptop
# ✓ Key created for "wife-laptop"
# API Key: ocp_example12345abcde...
# Copy this key now — you won't see it again.
ocp keys add son-ipad
ocp keys add pi-server
```
Run `ocp lan` to see your IP and ready-to-share instructions.
OCP is designed for always-on devices that often don't have a desktop browser — Mac mini, NAS, Raspberry Pi, cloud VPS. The Claude CLI auth flow still works headless:
**Option 1 — interactive OAuth over SSH (one-shot).** `claude auth login` prints a URL + 8-digit code. Open the URL on **any** device with a browser (your laptop, phone), sign in to your Anthropic account, and paste the code back into the SSH session. No browser needed on the server itself.
**Option 2 — long-lived token (auth once, no re-prompts).**
```bash
claude setup-token # subscription-backed long-lived token
```
Same Claude subscription as Option 1; the token is stored in Claude CLI's normal config location. Useful when you'd rather not redo the OAuth flow when sessions expire.
If `claude auth login` errors out with something like `cannot open browser`, you've hit the same case — fall back to either option above.
## AI-assisted install prompts
If you've got Claude Code, Cursor, or any other AI coding assistant on this machine, you can copy-paste one of these prompts and let the AI walk through the install for you. Each prompt pins the AI to the right README section, names the verification step, and forbids silent retries — so you stay in the loop.
**Single-machine use** — install OCP for IDEs on this same machine only:
```text
I want to install OCP on this machine to use my Claude Pro/Max subscription
1. Verify prerequisites: macOS or Linux, Node.js 22.5+, git, Claude CLI
installed and logged in (`claude auth status`). Install missing pieces
using my system's package manager.
2. git clone the repo, cd in, and run `node setup.mjs`.
3. Verify with `curl http://127.0.0.1:3456/v1/models` (should list 7 models).
4. Add `export OPENAI_BASE_URL=http://127.0.0.1:3456/v1` to my shell rc.
5. Tell me to reload my shell and try a tool like Cline / Continue / Cursor.
Before each step, tell me what you'll run and wait for confirmation.
On any error, diagnose first — don't auto-retry.
```
**LAN mode (server)** — install OCP as a server so your own devices on the LAN can reach it (Claude Pro/Max are per-user accounts — review Anthropic's Usage Policy before extending access to other people):
```text
I want to install OCP on this device as a LAN server so my own devices on the
network can reach my Claude Pro/Max subscription through a local
5. Tell me to reload my shell + restart any IDE that was already running.
Don't auto-retry on error. Tell me the failure mode first.
```
## Client Setup
> Clients do **not** need to install Node.js, Claude CLI, or the OCP repo. Only `curl` and `python3` are required (pre-installed on most Linux/Mac systems).
>
> **Find the server's LAN IP** by running `ocp lan` on the server machine — it prints both the IP and a ready-to-share connect command.
**One-command setup** — download the lightweight `ocp-connect` script:
**Zero-config** — when the server admin has set `PROXY_ANONYMOUS_KEY`*and* opted in with `PROXY_ADVERTISE_ANON_KEY=1` (see [Anonymous Access](#anonymous-access-optional) below), just pass the server IP and nothing else. `ocp-connect` reads the anonymous key from `/health` and uses it automatically. Without the opt-in, `/health` does not expose the key (issue #109); pass `--key` or rely on anonymous access instead:
```bash
./ocp-connect <server-ip>
```
If the server requires a key, pass it with `--key`:
Restart OpenClaw to apply: openclaw gateway restart
Running smoke test...
✓ Smoke test passed: OK
Note: smoke test only verifies OCP is reachable and the key is valid.
It does not verify your IDE/agent end-to-end. To verify OpenClaw works,
restart it (`openclaw gateway restart`) and send a test message to your bot.
Done. Reload your shell to apply:
source ~/.zshrc
```
The script automatically:
- Writes env vars to all relevant shell rc files (`.bashrc`, `.zshrc`)
- Sets system-level env vars (`launchctl setenv` on macOS, `environment.d` on Linux)
- **Auto-discovers anonymous key** from `/health.anonymousKey` when no `--key` given (v1.3.0+, requires server v3.10.0+; server must also set `PROXY_ADVERTISE_ANON_KEY=1` — see [Anonymous Access](#anonymous-access-optional))
- Configures OpenClaw automatically (including per-agent `auth-profiles.json` for multi-agent setups)
- Detects Cline, Continue.dev, Cursor, and opencode, and prints setup hints (manual configuration required for these IDEs)
On macOS, `launchctl setenv` vars reset on reboot — re-run `ocp-connect` after restart.
**Manual setup** — if you prefer not to use the script:
```bash
export OPENAI_BASE_URL=http://<server-ip>:3456/v1
export OPENAI_API_KEY=ocp_<your-key>
```
Add these lines to `~/.bashrc` or `~/.zshrc` to persist across sessions.
## Monitoring (Server-side)
```bash
# Per-key usage stats
ocp usage --by-key
# Key Reqs OK Err Avg Time
# wife-laptop 5 5 0 8.0s
# son-ipad 3 3 0 6.2s
# Manage keys
ocp keys # List all keys
ocp keys revoke son-ipad # Revoke a key
```
**Web Dashboard:** Open `http://<server-ip>:3456/dashboard` in any browser for real-time monitoring — per-key usage, request history, plan utilization, and system health.

## Auth Modes
| Mode | Env | Use Case |
|------|-----|----------|
| `none` | `CLAUDE_AUTH_MODE=none` | Trusted home network, no auth needed |
| `multi` | `CLAUDE_AUTH_MODE=multi` + `OCP_ADMIN_KEY=xxx` | Per-person keys for usage tracking + quotas (trusted users only — see Deployment model below) |
> **Usage scope (v3.14.0+):**`/api/usage` returns the caller's own rows by default. Admin callers must pass `?all=true` to retrieve data for all keys; doing so emits an audit log line.
## Deployment model & security (read this)
**What OCP is built for today: single-user, multi-IDE.** Run OCP as a server on one machine and point all of *your own* IDEs/devices at it — one Claude Pro/Max subscription, used everywhere. This is the primary, solid use case.
**Sharing with family / a team — honest limits.** You *can* share OCP on a LAN, but be clear about what the auth modes do and don't give you:
- The per-key modes (`shared` / `multi`) give per-key **usage tracking, quotas, and cache separation** — useful for seeing who used what and capping budgets.
- They do **not** give a **security isolation boundary**. The spawned `claude` runs with the **operator's filesystem access** and is *not* sandboxed per key. **Only share with people you fully trust, on a trusted network.**
- For simple trusted family sharing, the easiest setup is a single shared **anonymous key** (see [Anonymous Access](#anonymous-access-optional)) — no per-person separation, same trust assumption.
- **Account terms and ToS — read before sharing with others.** Claude Pro/Max are *per-user* accounts. Pooling a single subscription across **multiple distinct people** may violate Anthropic's Consumer Terms of Service and risk account suspension by the abuse classifier. The defensible framing is **"one person, your own devices"** — sharing with friends or a team is not. OCP does not change your account terms, and whether any particular sharing setup complies with the ToS is the account holder's responsibility. Review Anthropic's Usage Policy before extending access to other people.
**Real per-user isolation (sandboxed, multi-tenant-safe) is planned for after 2026-06-15** — per-key ephemeral home + tool lockdown + an OS sandbox. Until then, treat a multi-user OCP as a *trusted-group convenience*, not a security boundary. (This is also why `CLAUDE_TUI_MODE` is single-user-only — see [Subscription-pool (TUI) mode](tui-mode.md#subscription-pool-tui-mode).)
## Anonymous Access (optional)
In `multi` mode, the admin can designate a single well-known "anonymous" key that bypasses `validateKey()` and grants public read/write access. This is useful for letting LAN users (or clients like OpenClaw multi-agent setups) connect without individual per-user keys.
**Enable**:
The anonymous key is wired into the service unit (launchd plist on macOS, systemd unit on Linux) at install time. Export `PROXY_ANONYMOUS_KEY` in your shell before running `setup.mjs`, and `setup.mjs` will write it into the service unit env so the auto-started proxy picks it up:
```bash
export PROXY_ANONYMOUS_KEY=ocp_public_anon # or any string of your choice
node setup.mjs --bind 0.0.0.0 --auth-mode multi
```
If OCP is already installed without it, re-export the env var and re-run `node setup.mjs` (the installer is idempotent — it refreshes the service unit). Then `ocp restart` so the running proxy picks up the new env. Setting `PROXY_ANONYMOUS_KEY` only in your interactive shell **does not** affect the auto-started proxy — the service unit is the source of truth for its environment.
**Client side**: the anonymous key value is exposed via `GET /health` as the field `anonymousKey` (null when not set) **only to localhost callers** or when the admin has also set `PROXY_ADVERTISE_ANON_KEY=1` (default off — see issue #109). With that opt-in, clients like `ocp-connect` can auto-discover and use it, so the end user doesn't need to get a personal key from the admin.
**Security note**: setting this env var is an **opt-in** to public access — anyone who can reach your OCP endpoint can use it, up to any rate limits you configure. Don't enable this on internet-exposed OCP instances without additional protection.
**Not a secret**: because `/health` is an unauthenticated endpoint, the anonymous key is **publicly readable** by anyone who can reach the server. That is intentional — the key exists so clients can self-configure without out-of-band coordination. Treat it as a convenience handle, not as an access credential.
## Per-Key Quota (Budget Control)
Prevent any single user from exhausting your subscription. Set daily, weekly, or monthly request limits per API key:
- Admin and anonymous users are never subject to quotas
- PATCH is a partial update — omitted fields are left unchanged
> **Note:** quotas are best-effort. Under concurrent bursts a key can exceed its cap by up to the server's max-concurrency (default 8), and cache hits are not counted toward quota. They cap budgets for cooperative family use, not adversarial abuse.
## Important Notes
- All users share your Claude Pro/Max **rate limits** (5h session + 7d weekly)
- `ocp usage` shows how much quota remains
- Keys are stored in `~/.ocp/ocp.db` (SQLite, zero external dependencies)
- Admin key is required for key management API endpoints
- The dashboard (`/dashboard`) and health check (`/health`) are always public
- File modes for `~/.ocp` (0700), `admin-key` + `ocp.db` (0600) are auto-tightened at server startup as of v3.14.0
**Measured on**: Mac mini / macOS 26.5.2 / Claude Code **v2.1.207** / Sonnet 5 / Claude Max subscription / **real-home mode** (no `CLAUDE_CODE_OAUTH_TOKEN`, no `OCP_TUI_HOME` in the service env)
An external consumer (the 知音 AI project) benchmarked OCP's prompt path and measured
**TTFT p50 ≈ 30–32 s**, and excluded OCP as a backend on that basis. That number is real,
but it is *not* the model being slow — this document decomposes where the 30 seconds
actually go, and what OCP can do about it.
**The harness deliberately does not go through OCP.** It spawns `tmux` + `claude` directly
(session prefix `zhiyin-floor-`, never `ocp-tui-*`) and polls `tmux capture-pane` for
incremental render, so it measures the **true first-token time** of the underlying
subscription path — the floor OCP could reach if it were perfect.
---
## Measurements
All rows in [`measurements.jsonl`](measurements.jsonl); every number below is recomputable from it.
| Config | n | boot→input-ready (median) | **TTFT (median)** | TTFT range | full answer (median) |
|---|---|---|---|---|---|
| baseline (inherits global `effortLevel: xhigh`) | 5 | 1.07 s | **10.35 s** | 8.32 – 17.19 s | 11.32 s |
| **`--effort low`** | 5 | 1.03 s | **6.17 s** | **5.87 – 6.44 s** | 9.98 s |
| `--bare` | 5 | 0.44 s | **no answer at all** (5/5 `ttft_ms: -1`) | — | — |
> **Not from this harness**: the direct Anthropic API reference figure (TTFT 0.84–1.64 s, n=2)
> comes from the 知音 AI project's own smoke test, not from `measurements.jsonl`. It is quoted
> only to size the gap; do not look for it in the evidence file.
### Where the 30 seconds go
```
~1.0 s spawn → claude's input bar is ready ← NOT the bottleneck
~6-10 s true TTFT (first token rendered in the pane)
~20 s ████ waiting for the whole turn to finish ████ ← this is the 30s
```
`runTuiTurn` blocks on the native transcript until a terminal event (`lib/tui/session.mjs`
"Block on the native transcript … until terminal"; `readTuiTranscript` in
`lib/tui/transcript.mjs`; ADR 0007 step 4) — i.e. it waits for the **entire turn** to complete
before returning anything. There is no streaming path. The ~20 s delta between this harness's
real TTFT and OCP's reported 30–32 s is exactly that.
> **⚠️ 2026-07-13 correction — this decomposition attributes the ~20 s to the wrong thing.** It was
> inferred from the external 30–32 s report, never measured *through* OCP. It has since been measured
> through a real OCP instance (TUI mode, `claude-sonnet-4-6`, the same ~1850-token prompt, n=5):
> **median 11.30 s** before [#156](https://github.com/dtzp555-max/ocp/pull/156), **9.55 s** after.
> Same-turn decomposition (baseline row `i=5`): **11.563 s** wall through OCP vs `turn_duration:
> 7.319 s` of CLI-internal time on that same turn → **OCP's own overhead ≈ 4.2 s** (n=1), **not
> ~20 s**. The rest of any larger number is the model *generating a long answer*,
> which the blocking wait does not cause and streaming would not shorten — it would only move the
> first byte earlier. The 30–32 s figure therefore reflects a much longer output (and/or the
> then-inherited `xhigh` effort), not 20 s of OCP dead time. See
> [`streaming-spike.md`](streaming-spike.md) § "What streaming would have bought".
---
## ⚠️ Blocking constraint: `--bare` silently drops you off the subscription pool
Captured live ([`billing-banner.txt`](billing-banner.txt)) — the startup banner is the **only**
reliable indicator:
```
[] | Sonnet 5 with xhigh effort · Claude Max
[--effort low] | Sonnet 5 with low effort · Claude Max
[--bare] | Sonnet 5 with xhigh effort · API Usage Billing ← ❌
```
`--bare` ("skip hooks, LSP, plugin…") **also skips the subscription-credential resolution
path**. It really does cut boot to 0.43–0.45 s — but you are no longer on the subscription,
which defeats the entire purpose of TUI mode (ADR 0007 exists solely to reach the
subscription pool).
**The failure is silent.** All 5 `--bare` samples reached input-ready (boot 0.43–0.45 s), were
sent the prompt, and then produced **no answer at all** — 60 s timeout, no error, no crash, the
pane simply never rendered a token (the API-billing account had no credit balance). Nothing in
the transcript or the exit status reveals this.
**Anyone changing spawn flags must diff the banner line before and after.**
---
## Backlog — four items, ranked by value ÷ effort
### 1. Pass `--effort` explicitly on spawn — **do this first**
`buildTuiCmd` (`lib/tui/session.mjs`) does not pass `--effort` — `grep -rn -- "--effort\|effortLevel" lib/ server.mjs`
returns zero hits. What the pane's `claude` ends up using therefore depends on **which HOME mode
`resolveTuiHome()` picked**:
| mode | HOME | effort the pane gets |
|---|---|---|
| **real-home** (legacy default — *current* service config: no `CLAUDE_CODE_OAUTH_TOKEN`, no `OCP_TUI_HOME`) | `~` | **inherits the operator's `~/.claude/settings.json` → `effortLevel: xhigh` on this host** |
| env-token scratch (`CLAUDE_CODE_OAUTH_TOKEN` set — the direction #146/#150 pushed) | `~/.ocp-tui/home` | that settings.json contains only `permissions.additionalDirectories`; `prepareTuiHome()` never writes `effortLevel` → **claude's built-in default** |
**Scope note**: TUI mode is currently *off* on this host (`CLAUDE_TUI_MODE=false`; `/health` →
`"tui": {"enabled": false}`), so live traffic takes the `-p` path today. The statement below is
about what happens **when TUI mode is enabled**.
On the current HOME config, **every TUI request would run extended thinking** — pure waste
for the typical "generate this JSON" request, and it makes latency depend on an unrelated global
setting the operator may have changed for their own interactive use. And the mode split means
the effort level silently changes if the operator ever switches to env-token mode.
**Passing `--effort` explicitly fixes both problems at once.**
- **Effect (real-home, measured)**: TTFT p50 **10.35 s → 6.17 s (−40 %)**, and the spread
collapses from 8.32–17.19 s to **5.87–6.44 s**. For a proxy, the variance reduction matters
more than the median.
- **Cost**: one flag. Suggested: a new `OCP_TUI_EFFORT` env var (default `low`), documented in
README § "Environment Variables" per `release_kit.new_feature_doc_expectations`.
- **Risk**: none — banner confirms it stays on `Claude Max` (see `billing-banner.txt`).
- ⚠️ Do **not** reach for `--bare` to shave boot: see above.
### 2. Real streaming instead of blocking on turn-terminal — **ACHIEVABLE → [`streaming-spike.md`](streaming-spike.md)**
> **2026-07-13 update — the prereq spike was run. The answer is YES, but not from either source this
> item guessed at.** (a) The transcript grows at *event* granularity (the whole answer lands in one
> line, ~0.3 s before terminal) — dead. (b) The pane is a **rendered** view whose `capture-pane` text
> no longer contains the answer's source bytes (`## `, `**`, code fences are gone) — dead, and worse
> than "lossy": it is *not the model's text*. **But there is a third source neither this backlog nor
> the first spike considered: `claude` fires a `MessageDisplay` hook carrying incremental,
> byte-faithful `delta`s of the raw reply.** Verified live on a plain interactive TUI spawn (no `-p`),
> banner `· Claude Max`: 7 fires spread across generation, `concat(deltas) === T` **byte-exactly**
> (579 == 579), `T.startsWith(S)` true at every step, `## ` / `**` / ```` ```javascript ```` all
> present in the deltas. Granularity is block-level (~5–7 chunks/answer), not token-level — plenty for
> SSE. **Build it.**
>
> ⚠️ Two corrections to this item as written: the **"~20 s" is wrong** (inferred from an external
> report, never measured through OCP — the same-turn decomposition puts OCP's own overhead at **~4 s**,
> n=1), and **streaming moves the first byte, not the last** — so a consumer needing the *complete*
> answer (the JSON-card case that motivated this) gains **nothing** from it. Build it for
> progressively-rendering consumers, not as a throughput win.
>
> Full evidence + implementer caveats (the hook is `forceSyncExecution` — claude BLOCKS on it):
> **[`streaming-spike.md`](streaming-spike.md)**. Original framing preserved below.
Today `runTuiTurn` blocks on the transcript until the turn is *finished*. The pane is already
rendering tokens incrementally the whole time — this harness proves you can observe first token
at ~6 s by polling `tmux capture-pane`.
- **Effect**: turns a 30 s wall into a ~6 s TTFT with progressive output; enables SSE streaming
on the OCP endpoint instead of a single blob at the end.
- **Cost**: real work. Pane capture is ANSI/redraw-based and lossy for exact text (wrapping,
scrollback, spinner lines). Two candidate sources: (a) incremental reads of the transcript
JSONL, (b) `capture-pane` diffing with a stable start marker. (a) is much cleaner **if it
holds**.
- **Prereq spike (do this before designing anything)**: does the transcript JSONL grow *during*
a turn, or only at the end? If only at the end, (a) is dead and you are stuck with (b).
### 3. Warm pane pool — ~1 s
Every request spawns a fresh tmux session + `claude` (`randomUUID()` + `new-session`, then
`kill-session` in `finally`; `grep -rn "pool\|warm\|reuse" lib/tui/*.mjs` → zero hits). Boot to
input-ready is ~1.0 s, paid on every request. A pool of pre-booted panes (single-use, replaced in
the background) amortizes it to zero for any workload below the pool refill rate.
- **Effect**: −1.0 s.
- **Cost**: moderate; interacts with the session reaper and the per-port prefix scoping added in
#148 — pooled panes must not look like zombies to the sweep.
- Lower priority than #1 and #2: it is the smallest slice.
### 4. Trim the prefill — ~~probably not worth it~~ **MEASURED: no detectable benefit. Do not adopt.**
> **2026-07-13 update.**`--exclude-dynamic-system-prompt-sections` was measured with the same
> harness (`floor.sh`, n=5, Sonnet 5, on top of `--effort low`): **TTFT median 6.39 s**
> (5.87–10.54 s) vs **6.17 s** (5.87–6.44 s) for `--effort low` alone — i.e. **0.22 s worse, inside
> the noise band**, with one worse outlier; dropping that outlier does not change the verdict. n=5
> cannot prove "zero", only "no benefit detectable above noise" — but there is also a **mechanistic**
> reason not to expect one: `--help` says the flag *"Improves cross-user prompt-cache **reuse**"*, and
> **OCP is single-user** — there is no cross-user cache to share, so the flag has nothing to buy here.
> The banner stayed on `· Claude Max` (no billing-pool drop), but there is no win to bank. The ~6 s
> floor stands as stated below. Raw rows: [`prefill-spike-measurements.jsonl`](prefill-spike-measurements.jsonl).
After #1–#3, the floor is **~6 s**, and it does not go lower. `claude` always injects the full
Claude Code system prompt + tool definitions (thousands to tens of thousands of prefill tokens)
regardless of what you ask it. `--exclude-dynamic-system-prompt-sections` exists and may shave
some of it — **unmeasured**; worth one spike, but do not expect to reach the direct API's
~1 s.
**Consequence to accept, and to state in the README**: even fully optimized, TUI mode has a
**~6 s TTFT floor**, so it cannot serve real-time / interactive-latency consumers. It remains
appropriate for batch, background, and cost-insensitive-latency use. The 知音 AI project
excluded it on this basis (their prompt-latency budget is 2–4 s) *independently* of the ToS
question already documented in the README.
---
## Reproduction
```bash
# harness never touches OCP's :3456 service or ocp-tui-* sessions, and never kill-server
localseg="Speaker A said the quarterly pipeline is tracking behind plan and the enterprise segment needs a different motion. Speaker B replied that the current onboarding flow loses roughly a third of trial accounts before the first integration is complete. They debated whether the fix belongs in product or in customer success. "
localbody=""
for _ in $(seq 1 22);dobody+="$seg";done
printf'%s'"You are a real-time meeting copilot. Meeting transcript so far: $body --- Task: produce ONE prompt card as compact JSON with keys: points (array of 3 short Chinese bullet points), keyline (one English sentence the user can read aloud). IMPORTANT: your reply MUST begin with three hash characters immediately followed by the uppercase word CARD (no space between them), then the JSON. No preamble, no markdown fences." > "$PROMPT_FILE"
{"hook_event_name":"MessageDisplay","index":1,"final":false,"delta":"A **mutual exclusion lock** prevents concurrent access to a shared resource, ensuring only one thread runs the critical section at a time.\n\n"}
{"hook_event_name":"MessageDisplay","index":2,"final":false,"delta":"- Acquiring a locked mutex blocks the caller until the current holder releases it.\n"}
{"hook_event_name":"MessageDisplay","index":3,"final":false,"delta":"- Failing to release a mutex causes a deadlock, freezing all waiting threads.\n\n```javascript\nconst { Mutex } = require('async-mutex');\n\nconst mutex = new Mutex();\n"}
{"hook_event_name":"MessageDisplay","index":5,"final":false,"delta":" counter++; // only one caller here at a time\n } finally {\n release();\n }\n}\n"}
transcript granularity. The log tells you **when** tokens arrive, never **what** they are. It is also
~2.7 MB per turn.
### Also checked, also not the answer
| candidate | outcome |
|---|---|
| `--output-format stream-json` (the one interface that emits `text_delta`) | **requires `--print`/`-p`** → `cc_entrypoint=sdk-cli` → the **metered** credit pool, which is exactly what TUI mode exists to avoid. Reproduced live. |
| `CLAUDE_CODE_INCLUDE_PARTIAL_MESSAGES=1` (undocumented) | No stream-json sink in interactive mode → no partials. Banner stayed `· Claude Max`. |
| `sessionMirror` (undocumented) | Gated on `outputFormat === "stream-json"` → the `-p` family. |
| `--sdk-url` (hidden) | Forces stream-json + non-interactive → `sdk-cli`. *(inferred from the minified bundle; not banner-tested)* |
| `~/.claude/sessions/<pid>.json` | Registry metadata only (`{pid, sessionId, cwd, status, version, entrypoint:"cli", kind:"interactive"}`). No assistant text. *(Its `entrypoint:"cli"` incidentally confirms the TUI path stays on the subscription pool.)* |
| `~/.claude/history.jsonl` | User prompts only; the answer text is absent. |
| Asking the model to emit plain text (so the pane renders faithfully) | Would mean **mutating the caller's prompt** — a correctness violation for a proxy, and still not byte-faithful (wrapping + indent remain). Rejected. |
---
## Value: what streaming actually buys (read before building)
Streaming is *possible*. Whether it is *worth it* depends on the consumer, and the honest answer is
uncomfortable:
- **Streaming never makes the answer arrive sooner. It moves the *first* byte, not the *last*.** The
final token lands at the same wall-clock moment either way.
- So a consumer that must have the **complete** answer before it can act — e.g. one parsing a structured
JSON reply, **which is exactly the 知音 AI use case that motivated this entire investigation** — gains
**nothing at all**. Only a **progressively-rendering** consumer (a chat UI) gains.
And the number the backlog attached to this item was wrong:
- The backlog's "~20 s" was inferred from an external 30–32 s report, **never measured through OCP**.
Measured through a real OCP instance (TUI mode, `claude-sonnet-4-6`, ~1850-token prompt, n=5):
**median 11.30 s** before [#156](https://github.com/dtzp555-max/ocp/pull/156), **9.55 s** after.
- **Same-turn decomposition** (baseline row `i=5`): **11.563 s** wall through OCP vs `turn_duration:
7.319 s` of CLI-internal time on that same turn → **OCP's own overhead ≈ 4.2 s** (n=1, baseline
`effort=high` config). *Caveats*: n=1; and `turn_duration` is the CLI's internal duration of an
**OCP-driven** turn, not a separate "native" baseline. Do **not** subtract this `effort=high` 7.3 s
from the `effort=low` 9.55 s median — a low-effort turn generates faster, so mixing them
*understates* the overhead.
- So OCP's own overhead is **single-digit seconds**, not ~20 s. The rest of any large number is the
model generating a long answer — which streaming hides but does not shorten.
**Recommendation**: build it — the contract is clean and the cost is small — but size the expectation
honestly. It is a *perceived-latency* feature for progressively-rendering consumers, not a throughput
win, and it does not move the **~6 s TTFT floor** ([`README.md`](README.md)) that rules TUI mode out for
Part of [OCP](../README.md) — full troubleshooting manual. The README keeps a slim version with the most common issues and the one-time bootstrap quirks; everything else lives here.
# Troubleshooting
The simplest path: ask your AI.
Paste this prompt:
```
Run `ocp doctor` and follow its `next_action`. Tell me if you hit
anything that needs human input.
```
The doctor produces a JSON `next_action` with `ai_executable[]` (commands
the agent runs verbatim) and `human_required[]` (steps that need you,
typically just OAuth).
## Manual debugging
### Setup fails with "claude: command not found"
`setup.mjs` requires the Claude CLI to be on `PATH`. Install it via the [official guide](https://docs.anthropic.com/en/docs/claude-cli), confirm with `which claude`, then run `claude auth login` before re-running `node setup.mjs`.
### Setup fails with "EADDRINUSE: port 3456 already in use"
Something else is already bound to port 3456 — usually an old OCP instance. Check what:
```bash
lsof -nP -iTCP:3456 -sTCP:LISTEN
```
If it's an old OCP process, stop it before re-running setup:
systemctl --user stop ocp-proxy # Linux systemd (installed as a --user unit)
```
(There is no `ocp stop` subcommand — the proxy runs as a service, so stopping it goes through the service manager above. `ocp restart` exists for the bounce case.)
### Setup fails with "node: command not found" or version error
OCP requires Node.js 22.5+. Install:
```bash
brew install node # macOS
# Linux: see https://nodejs.org/en/download for current install commands
```
Confirm with `node --version` (should be ≥ v22.5).
### Requests fail or agents stuck
```bash
# Clear sessions and restart
ocp clear
ocp restart
# If using OpenClaw gateway
openclaw gateway restart
```
### Env var change (e.g. `CLAUDE_BIND`, `CLAUDE_CODE_OAUTH_TOKEN`) doesn't take effect after restart
On **macOS**, `ocp restart` does a full `launchctl bootout` + `bootstrap` of the agent, which **re-reads the plist `EnvironmentVariables`** — so an env change you made (in `~/Library/LaunchAgents/dev.ocp.proxy.plist`) actually takes effect:
```bash
ocp restart
```
This is deliberate: the older `launchctl kickstart -k` only re-execs the process and **reuses launchd's cached environment**, so plist env edits would be silently ignored. If you ever restart the agent by hand, use bootout+bootstrap, not `kickstart -k`:
On **Linux**, `systemctl --user restart` already re-reads the unit's `EnvironmentFile`, so no special handling is needed.
### Usage shows "unknown"
Usually caused by an expired Claude CLI session. Fix:
```bash
claude auth login
ocp restart
```
### Startup log warns "OpenClaw registry out of sync"
On boot, OCP compares OpenClaw's registered models against [`models.json`](../models.json) and warns if they drift. Cause: someone (or an OpenClaw upgrade) modified `~/.openclaw/openclaw.json` and removed entries OCP expects. Fix:
```bash
node ~/ocp/scripts/sync-openclaw.mjs
```
This is read-only at startup; the warning never blocks the gateway from running.
### A TUI session vanished right after upgrading OCP
If you ran a pre-3.21.1 OCP instance and a post-3.21.1 instance on the same host at the same time during an upgrade, the new instance's one-time boot reap can, once, kill an old-format (`ocp-tui-<8hex>`) live TUI session belonging to the still-running old instance — restart the affected session (`ocp restart` or re-run your TUI turn) and it will come back under the new instance's port-scoped naming.
### OpenClaw shows old models after `ocp update` (v3.10→v3.11 only)
One-time bootstrap quirk for the v3.10.0 → v3.11.0 jump only — the running shell had the old `cmd_update` cached. Run once manually:
```bash
node ~/ocp/scripts/sync-openclaw.mjs
openclaw gateway restart # so OpenClaw re-reads the config
### Response-cache hit rate drops once after upgrading to v3.25.0
Only affects instances running with the response cache **on** (`CLAUDE_CACHE_TTL > 0`); it is off by default, so most installs see nothing.
v3.25.0 keys the cache on the **resolved** model rather than on the string the client sent, so rows written for an alias (`opus`, `sonnet`, `haiku`, or a legacy alias like `claude-haiku-4-5`) no longer match. Those rows orphan and are reaped by the TTL cleanup interval within one window — **no migration script, no action required**; expect one window of extra misses and then normal hit rates.
Two different scopes, worth being precise about:
- **Normal cache** — only *alias-addressed* rows rekey. Rows written under a literal model id (`claude-sonnet-5`) keep matching — **unless the instance runs `OCP_LOCAL_TOOLS=1`**, in which case the entire normal cache rekeys once. That is a separate mechanism: v3.25.0 also reworded the local-tools wrapper, and the wrapper text is one of the four inputs to `CONFIG_EPOCH`, which every normal cache key folds in (established behavior since v3.23.0, not new here).
- **Structured-output cache** — **every** row rekeys, alias or literal. The same change also folds the config epoch into the structured key, which it had never included; that gap meant a `CLAUDE_SYSTEM_PROMPT` change did not invalidate structured answers either. Structured caching only exists from v3.24.0, so there is at most one release worth of rows to orphan.
This is deliberate, and it is what makes an alias repoint take effect. Before v3.25.0, changing where an alias pointed (v3.25.0 itself repoints `opus` → `claude-opus-5`) left the cache serving the **old** model's answers under that alias until TTL expiry, because `models.json` is read once at boot while the SQLite cache survives the restart. If you were running with the cache on and repointed an alias in an earlier version, that is why it appeared not to take.
A side effect worth knowing: an alias and its canonical id now **share** a cache slot, since both produce an identical spawn. That is a small hit-rate improvement in steady state.
<a id="tui-401"></a>
### TUI-mode returns a permanent `Please run /login` 401 (re-login doesn't stick)
A long-running TUI-mode host can get stuck returning a permanent 401 (`Please run /login · API Error: 401`) that re-login cannot fix.
**Root cause (two layers):** interactive `claude`**prefers `~/.claude/.credentials.json` over the `CLAUDE_CODE_OAUTH_TOKEN` env var** (this is *unlike* the `-p` path, where the env token wins). So (a) a stale/corrupt `credentials.json`**shadows** the env token — passing the token is not enough on its own; and (b) when claude does use `credentials.json`, its single-use OAuth refresh token can be corrupted (ending up an empty string) by the per-request spawn + `kill-session` teardown racing claude's token rotation. Re-login writes a fresh token, but the next spawn re-corrupts it. Proven live on PI231: *env token passed + broken `credentials.json` present → 401; env token passed + `credentials.json` moved aside → works.*
**Fix:** set `CLAUDE_CODE_OAUTH_TOKEN` on the OCP host and leave `OCP_TUI_HOME`**unset**. OCP then runs the TUI `claude` in a **credential-isolated home** (`$HOME/.ocp-tui/home`) that has **no `credentials.json`** at all, so the env token is the only credential (authoritative — nothing shadows it) and claude never runs the refresh path (so the single-use token can't be corrupted). Then restart — on systemd `daemon-reload`, on launchd `bootout`+`bootstrap`; `kickstart -k` does **not** reload env. Verify the env reached the process and the boot log shows the isolated home:
```bash
# Linux (systemd): confirm the token is in the service env
# Boot log should read: TUI-mode: ON home=$HOME/.ocp-tui/home ... auth=env-token (credential-isolated home — no credentials.json)
```
> If you previously set `OCP_TUI_HOME` to the real home (or any home that contains a `credentials.json`), **unset it** so the credential-isolated default takes effect — otherwise the shadowing `credentials.json` remains in play.
See [Subscription-pool (TUI) mode](tui-mode.md#subscription-pool-tui-mode) and ADR 0007 PR-C / PR-D amendments.
Part of [OCP](../README.md) — subscription-pool (TUI) mode: serve requests through interactive `claude` so they bill the Pro/Max subscription pool instead of the metered Agent SDK path.
# Subscription-pool (TUI) mode
> **SECURITY — read before enabling.**
> TUI-mode is **single-user / single-operator only**. `claude` runs with the OCP process owner's filesystem access regardless of `HOME` setting. If OCP serves multiple users or guest API keys, a guest prompt could exfiltrate files or exhaust the subscription. **Never enable `CLAUDE_TUI_MODE=true` on a multi-user OCP.**
## What it is and why
> **⚠️ Status (as of 2026-07): the billing split below is PAUSED.** Anthropic announced it for 2026-06-15, then paused it on the effective date — *"For now, nothing has changed: Claude Agent SDK, `claude -p`, and third-party app usage still draw from your subscription's usage limits"* ([official help article](https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan)). While the pause holds, OCP's default `-p` path bills the subscription and **TUI-mode is a hedge, not a necessity**. The table describes the *announced* regime, kept here because Anthropic says a reworked change will return (with advance notice) — everything in this section is ready to flip on that day.
The announced routing keys `claude` invocations by `cc_entrypoint`:
| Launch method | `cc_entrypoint` | Billing pool (announced regime, currently paused) |
|---------------|-----------------|-------------|
| `claude -p` / `--output-format` (OCP default) | `sdk-cli` | Agent SDK credit pool (~$20/mo on Pro) |
| Interactive `claude` (no flags) | `cli` | Pro/Max subscription pool |
TUI-mode lets OCP serve requests via the interactive path so they bill against the subscription pool under that regime. The response is read from claude's native JSONL session transcript once the turn is complete, then replayed to the caller as a normal OpenAI completion or chunked SSE response.
# tmux must be installed: brew install tmux / apt install tmux
# Enable
export CLAUDE_TUI_MODE=true
# STRONGLY RECOMMENDED on a TUI host — authenticate via the long-lived OAuth token.
# With this set (and OCP_TUI_HOME left UNSET), OCP runs the interactive claude in a
# credential-isolated home ($HOME/.ocp-tui/home, no credentials.json), so the env token
# is the only credential and is authoritative. This both stops a stale credentials.json
# from shadowing the token AND ends the refresh-token corruption that caused a permanent
# "Please run /login" 401 (no credentials file → claude never runs the refresh path).
# See the auth note below + ADR 0007 PR-D.
export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...
# Optionally tune:
export CLAUDE_TUI_WALLCLOCK_MS=180000 # 3 min cap for long Opus turns
export OCP_TUI_CWD=$HOME/.ocp-tui/work # default; override if needed
export OCP_TUI_ENTRYPOINT=cli # default; use 'auto' to observe TTY-derived value
# Do NOT set OCP_TUI_HOME for the recommended setup — leaving it unset is what enables
# the credential-isolated home. Set it only to opt into the legacy symlinked-creds mode.
```
Then restart OCP. At boot you will see (with the env token set, isolated home auto-selected):
```
⚠️ TUI-mode ON — single-user only; do NOT enable on a multi-user OCP ...
TUI-mode: ON home=/home/user/.ocp-tui/home cwd=/home/user/.ocp-tui/work auth=env-token (credential-isolated home — no credentials.json) wallclock=120000ms maxConcurrent=2
```
## What changes / what doesn't
- **Callers see no API change.** The response is a normal OpenAI completion object or chunked SSE — identical wire format.
- **Real streaming is opt-in (`OCP_TUI_STREAM=1`), and off by default.** By default TUI-mode buffers the full response and replays it as chunked SSE — you see a delay, then the complete response. Set `OCP_TUI_STREAM=1` and `stream:true` turns emit real SSE `delta.content` chunks as `claude` renders them, sourced from `claude`'s own `MessageDisplay` hook (byte-faithful raw markdown, on the subscription pool, no `-p`). Two honest caveats: granularity is **block-level** — the hook fires once per rendered block, so a handful of chunks per answer, scaling with length, not token-by-token; and it moves the **first** byte, not the last, so a consumer that must parse a complete reply gains nothing. The transcript stays authoritative: every streamed turn is asserted against it at the end, and a turn whose stream disagrees is **failed rather than served** (watch `tui.streamDivergences` on `/health`). Evidence: [`plans/2026-07-13-tui-latency/streaming-spike.md`](plans/2026-07-13-tui-latency/streaming-spike.md).
- **Cache and singleflight work normally.** TUI-mode writes the buffered response to the cache on success; cache-hits skip the interactive turn entirely.
- **The host's `CLAUDE.md` / auto-memory is never injected.** OCP is a proxy — the proxied client (OpenClaw / your IDE) owns its own context and memory. TUI-mode runs `claude` with `--safe-mode`, which disables all host customizations (CLAUDE.md, skills, plugins, hooks, MCP servers) while leaving auth, model selection, built-in tools, and permissions untouched — so a `CLAUDE.md` on the OCP host can never leak into a proxied turn and steer the answer, and the session still bills the subscription pool (`cc_entrypoint=cli`, unlike `--bare`). The `CLAUDE_CODE_DISABLE_CLAUDE_MDS` / `CLAUDE_CODE_DISABLE_AUTO_MEMORY` env vars are kept as a fallback (see #4). Built-in tool schemas + the interactive system prompt remain (the inherent ~20–35K context floor of interactive mode); MCP is hard-disabled. **Exception:** the two opt-in modes that deliberately need a customization `--safe-mode` would strip — real streaming (`OCP_TUI_STREAM`, which registers a `MessageDisplay`**hook** via `--settings`) and `OCP_TUI_FULL_TOOLS` (which grants an **MCP** / skills surface) — run without `--safe-mode` and rely on the env-var suppression alone.
- **Authenticate via `CLAUDE_CODE_OAUTH_TOKEN` in a credential-isolated home (recommended).** tmux does not forward the parent process's env to the pane, so OCP sets the token explicitly on the spawned `claude` when `CLAUDE_CODE_OAUTH_TOKEN` is present. With the env token set and `OCP_TUI_HOME` unset, OCP runs claude in a **credential-isolated home** (`$HOME/.ocp-tui/home`) that has **no `credentials.json`** — so the env token is the only credential and is authoritative, and claude never runs the token-refresh path. This both stops a stale `credentials.json` from shadowing the token and ends the refresh-token corruption behind the permanent `Please run /login · API Error: 401` (full two-layer root cause, live proof, and fix in [Troubleshooting § the permanent TUI-mode 401](troubleshooting.md#tui-401)). Transcripts land under the same isolated home, so the answer-reader is unaffected. Without the env token, claude falls back to the real home's `credentials.json` (byte-for-byte the previous behaviour). (The token is visible in `ps` on the pane command — acceptable for the single-user A-path; the multi-user B-path is refused at boot.) See ADR 0007 PR-C / PR-D amendments.
- **Stale tmux sessions are reaped.** The pane's `claude` is a child of the tmux server (not OCP), so OCP cannot reap it directly; `claude` zombies can otherwise accumulate as `<defunct>` over a long-running host. OCP reaps them at boot and on a 15-min idle sweep by issuing `tmux kill-server` — but **only when no foreign tmux session remains** (it never disrupts a co-hosted `olp-tui-*` instance). See ADR 0007 PR-C amendment.
- **Default path unchanged.** Unset `CLAUDE_TUI_MODE` and restart → `callClaude` / `callClaudeStreaming` are used again, byte-for-byte identical to today.
- **Concurrency is bounded separately.** TUI turns are heavy (per-request cold-boot + long wallclock), so the TUI path has its own limiter — `OCP_TUI_MAX_CONCURRENT` (default `2`), independent of `CLAUDE_MAX_CONCURRENT`. Excess turns queue; a full queue returns a 503. Tune it up only on a host that can run more interactive `claude` sessions at once.
- **Optional warm pane pool (`OCP_TUI_POOL_SIZE`, default off).** Pre-boots panes so a request skips the cold boot — measured p50 `10.17s` → `6.00s` (−41%). Pooled panes are **single-use** (one turn, then killed and replaced in the background), each carrying its own fresh `--session-id`, so one session still means one exchange and no earlier-turn text can leak into a later answer. They are named `ocp-tui-<port>-p<hex>` and coexist with the reaper by design: the sweep **drains the pool first**, then reaps (so `kill-server` still flushes `<defunct>` zombies), then the pool refills in the background. Drain→reap→resume is synchronous, so no request can land mid-sweep; a request arriving while the pool is still re-booting simply misses it and cold-boots. A live pooled pane is never reaped — **including one that is still booting**, whose tmux session already exists — while an *orphaned* one (left by a previous process generation) still is.
## ⚠️ Latency: TUI mode has a ~6-second floor, and it is immovable
**TUI mode cannot serve real-time or interactive-latency consumers.** This is a hard property of the
path, stated plainly so you can rule it out before building on it:
`GET /health` includes a `tui` block so you can poll for a silent billing-pool drift (the top risk under the announced split, if it re-lands — a lost TTY flipping `cc_entrypoint` from `cli` to `sdk-cli` would still return answers but land in the metered pool). The block is **always present** (with `enabled:false` when TUI-mode is off):
```jsonc
"tui": {
"enabled": true, // CLAUDE_TUI_MODE === "true"
"entrypointMode": "cli", // OCP_TUI_ENTRYPOINT (cli | auto | off)
"lastEntrypoint": "cli", // last cc_entrypoint observed in a transcript, or null
"entrypointMismatches": 0, // count of cli-expected-but-got-other turns — ALERT if this climbs
"inflight": 1, // TUI turns running right now
"queued": 0, // TUI turns waiting for a concurrency slot
"maxConcurrent": 2, // OCP_TUI_MAX_CONCURRENT
"pool": { // warm pane pool — null when OCP_TUI_POOL_SIZE=0 (the default)
Alert on `entrypointMismatches > 0` (or `lastEntrypoint !== "cli"`): it means a turn drew from the metered Agent SDK pool instead of the subscription. `inflight` / `queued` show how close the TUI path is to its concurrency cap.
With the pool on, `hits` / `misses` is the hit rate (a steady single-model consumer should sit near 100% after the first request), and `warm` is your standing idle-process cost. A climbing `bootFailures` means panes are not reaching their input bar — the pool then degrades safely to the cold path, but latency reverts to the un-pooled numbers. `cancelled` counts boots OCP killed *on purpose* (a drain, a model switch) and is **not** a fault signal — do not alert on it. A steadily climbing `dropped` is likewise normal: the 15-min reap sweep drains and re-boots the pool on every tick so `kill-server` can still flush `<defunct>` zombies.
## Kill-switch
```bash
unset CLAUDE_TUI_MODE
# restart OCP
```
The stream-json path is restored immediately. No other change is needed.
## Operator checklist for the (paused) billing split
> **Status:** the 2026-06-15 split never took effect — Anthropic paused it on the effective date (see the status note at the top of this section). **Nothing needs flipping while the pause holds.** The checklist is retained verbatim as the runbook for if/when a reworked change lands (Anthropic has promised advance notice).
Under the announced regime, every host serving traffic must be flipped to TUI-mode **and** canary-verified before the effective date, or it will bill the metered Agent SDK credit pool instead of the subscription.
- **[Flip/rollback runbook](runbooks/tui-flip-rollback.md)** — how to set `CLAUDE_TUI_MODE=true` on systemd (Linux) and launchd (macOS) hosts. Covers the `daemon-reload` requirement (systemd) and the `bootout`+`bootstrap` cycle requirement (launchd — `launchctl kickstart -k` does not reload plist env).
- **[615-canary runbook](runbooks/615-canary.md)** — after each flip, run one quiesced request and compare the Agent SDK credit balance before and after. `entrypoint:cli` in the transcript (the `cc_entrypoint` billing classifier) is necessary but not sufficient — only a stable credit balance confirms the subscription pool is being used. Balance check is a manual step (no known programmatic API for the Agent SDK credit pool balance).
## Architecture and design decisions
See [`adr/0007-tui-interactive-mode.md`](adr/0007-tui-interactive-mode.md) for the full rationale, home-strategy options, MCP-disable mechanism, coexistence rules, and the B-path (multi-tenant isolation) roadmap.
## TUI-mode environment variables
The README [Environment Variables](../README.md#environment-variables) table lists these as one-line pointers; the full behaviour of each lives here.
<a id="ocp-tui-stream"></a>
### `OCP_TUI_STREAM` — real SSE streaming (opt-in)
`OCP_TUI_STREAM` default `0` (off). When `=1`, `stream:true` requests emit **real SSE `delta.content` chunks as `claude` generates them**, instead of buffering the turn and replaying it. Deltas come from `claude`'s own `MessageDisplay` hook (registered with `--settings` on the ordinary interactive spawn — banner-verified to stay on the subscription pool, `· Claude Max`). Granularity is **block-level**, not token-level. The transcript remains authoritative: the streamed text is asserted equal to it at end-of-turn, the auth-banner and truncation gates still run before anything is committed, and only the transcript text is cached. A turn whose stream cannot be reconciled with the transcript is **refused** (SSE error frame, not cached) and counted as `tui.streamDivergences` on `/health`. A total hook failure (e.g. `--settings` stops registering it after a `claude` version bump) is a *different, silent* failure mode — every streamed turn still succeeds, fully buffered, with no divergence and no error — so it is counted separately as `tui.streamZeroDeltaTurns` (streamed turns where the hook fired **zero** times) and logged as `tui_stream_zero_deltas`; watch it alongside `streamDivergences`. Default off — the buffered path is unchanged and remains the stable default. ⚠️ **Tool-using turns:** the transcript keeps only the model's **last** assistant message, so if the model narrates before calling a tool ("I'll check that file…") and that narration exceeds `OCP_TUI_STREAM_HOLDBACK`, it has already been streamed and cannot be retracted — the turn is then **refused** rather than served (measured live: Opus narrated 475 chars before a `Bash` call). If your deployment lets the model use tools (the TUI default, and anything with `OCP_TUI_FULL_TOOLS=1`), either raise `OCP_TUI_STREAM_HOLDBACK` above the typical narration length — the narration then stays held back and is correctly discarded, at the cost of a later first chunk — or leave streaming off. Streaming is best suited to tool-light chat proxying. See ADR 0007 (2026-07-13 amendment).
Two related streaming knobs:
- **`OCP_TUI_STREAM_DIR`** (default `$HOME/.ocp-tui/stream`) — directory holding the static `MessageDisplay` hook script + settings file, and the per-session delta sink (`<session-id>.jsonl`, removed at turn teardown). One sink **per session-id** — this is what keeps concurrent TUI turns (`OCP_TUI_MAX_CONCURRENT` ≥ 2) from interleaving one client's deltas into another's stream.
- **`OCP_TUI_STREAM_POLL_MS`** (default `100`) — interval at which OCP drains the delta sink. The hook fires at block granularity (seconds apart), so a finer poll buys nothing.
<a id="ocp-tui-stream-holdback"></a>
### `OCP_TUI_STREAM_HOLDBACK`
`OCP_TUI_STREAM_HOLDBACK` default `100`. (TUI-mode, streaming) Characters withheld before the first chunk reaches the client. Two jobs. (1) It keeps the **auth-banner gate** alive under streaming, via a guarantee with two required halves: (i) nothing is emitted for a message until its trimmed accumulation exceeds 100 chars — past the default banner detector's reach, since real banners are ≤100 chars — and (ii) once a message boundary follows an emit, nothing further is ever emitted for the rest of the turn, and the turn is refused outright. Half (i) alone only covers a turn's first message; half (ii) is what covers an error banner rendered as a *later* message (e.g. after tool-using prose). Raise the holdback if you replace the detector via `CLAUDE_TUI_ERROR_PATTERNS` with patterns that can match longer messages — that only affects half (i); OCP warns at boot if you do. (2) It is the knob for **tool-using turns** — see the `OCP_TUI_STREAM` caveat above. Answers shorter than the holdback are simply delivered whole at end-of-turn, exactly as the buffered path does.
<a id="ocp-tui-pool-size"></a>
### `OCP_TUI_POOL_SIZE` — warm pane pool
`OCP_TUI_POOL_SIZE` default `0` (off). Number of **pre-booted warm `claude` panes** kept ready, so a request does not pay the cold boot. `0` disables the pool entirely — the request path is then exactly the cold-boot path. Max `4`; an unparseable value disables it rather than guessing. **Measured on a Mac mini (Sonnet 4.6, `--effort low`): end-to-end p50 `10.17s` (n=6, pool off) → `6.00s` (n=12 warm hits) — −4.2 s / −41%** — the pool recovers both the ~1.2 s boot *and* ~2.9 s of post-input-bar init that a pane which has been idle a moment has already finished. **Cost:** each warm pane is a *live idle `claude` process* held whether or not a request ever arrives (peak processes ≈ pool size + `OCP_TUI_MAX_CONCURRENT` + 1 booting replacement) — which is why it is opt-in. Panes are **single-use**: one turn, then killed and replaced in the background. The **first request after start (and after any model switch) is always a cold miss** — the pool warms the most recently requested model, since OCP cannot know which model the next caller wants. See [`plans/2026-07-13-tui-latency/`](plans/2026-07-13-tui-latency/).
<a id="ocp-tui-full-tools"></a>
### `OCP_TUI_FULL_TOOLS` — full tool surface (single-user only)
`OCP_TUI_FULL_TOOLS` default *(unset)*. (TUI-mode, **single-user only**) When `=1`, grant the interactive session the **same tool surface as the `-p` path** — `--allowedTools` (+ optional `--mcp-config`, read from `CLAUDE_ALLOWED_TOOLS` / `CLAUDE_MCP_CONFIG`) — instead of the default MCP-walled, built-in-tools-only set. Lets a trusted single-operator TUI deployment run a **tool-using / MCP agent** (e.g. an OpenClaw assistant) on the subscription pool. Safe because TUI **refuses to boot under `AUTH_MODE=multi`** (hard exit) — no guest key can ever reach the TUI path, so this gate cannot expose tools to an untrusted caller. (Under `AUTH_MODE=shared` + `OCP_TUI_ALLOW_LAN=1`, anyone holding the single shared key reaches it — that is the existing TUI trust model, unchanged.) Note: `--dangerously-skip-permissions` / `CLAUDE_SKIP_PERMISSIONS` is **not** supported for TUI — claude v2.1.x shows an interactive bypass-acceptance screen in headless tmux that cannot be answered, bricking the pane. Use scratch-home `settings.json``additionalDirectories` instead. See ADR 0007.
<a id="tui-other-vars"></a>
### Other TUI-mode variables
- **`OCP_TUI_MAX_CONCURRENT`** (default `2`) — Max concurrent interactive TUI turns. **Independent** of `CLAUDE_MAX_CONCURRENT` (which bounds the `-p`/stream-json path; TUI never uses it). A TUI turn is heavy (per-request cold-boot of tmux+claude + up to `CLAUDE_TUI_WALLCLOCK_MS` wallclock), so the default is low to keep small hosts (e.g. a Pi 4) alive under a burst. Excess turns **queue** (bounded); a full queue yields a 503. See ADR 0007 PR-B amendment.
- **`OCP_TUI_ENTRYPOINT`** (default `cli`) — Billing-classifier labeling: `cli` (default) pins `cc_entrypoint=cli` deterministically; `auto` lets claude self-classify via TTY detection; `off` leaves the inherited env untouched. Honest only when the spawn is a genuine interactive PTY — see the "Billing-classifier labeling" section above and ADR 0007.
- **`OCP_TUI_EFFORT`** (default `low`) — Effort level passed to the interactive `claude` as an explicit `--effort` flag: `low` (default), `medium`, `high`, `xhigh`, `max`, or `inherit` to omit the flag (the pre-flag behaviour: the pane inherits a HOME-dependent effort — the operator's `~/.claude/settings.json``effortLevel` in real-home mode, claude's built-in default in env-token scratch mode). Explicit `low` cuts measured TTFT p50 by ~40% and collapses run-to-run variance ~15× versus an inherited `xhigh` (see [`plans/2026-07-13-tui-latency/`](plans/2026-07-13-tui-latency/)); proxied requests rarely benefit from extended thinking. Banner-verified to stay on the subscription pool (`· Claude Max`). An invalid value logs a warning and falls back to `low`.
- **`OCP_TUI_HOME`** (default *(auto)*) — `HOME` claude runs under. **When unset, OCP picks it for you:** if `CLAUDE_CODE_OAUTH_TOKEN` is set → a **credential-isolated** scratch home `$HOME/.ocp-tui/home` (no `credentials.json`, env-token auth — **recommended**); if no env token → the operator's real home (legacy shared `credentials.json`). Setting this to an **explicit** path overrides the auto-default. The credential handling at that path still follows the env token: **with** the env token it is credential-free (env-token auth, no `credentials.json` written); **without** the env token (and the path ≠ real home) it uses the legacy symlinked-credentials scratch mode, which carries the credential-fork caveat — see ADR 0007. If you previously set this to the real home (or any home containing a `credentials.json`) and hit a permanent 401, unset it — see [Troubleshooting § the permanent TUI-mode 401](troubleshooting.md#tui-401).
- **`CLAUDE_TUI_WALLCLOCK_MS`** (default `120000`) — Maximum time in ms to wait for the native transcript to signal turn completion. Increase for long Opus thinking turns.
- **`OCP_TUI_CWD`** (default `$HOME/.ocp-tui/work`) — Scratch working directory where interactive claude sessions run. Transcripts land under `<HOME>/.claude/projects/<encoded-cwd>/`. Created automatically.
- **`CLAUDE_CODE_OAUTH_TOKEN`** — the recommended TUI credential; when set (and `OCP_TUI_HOME` unset) it selects the credential-isolated home. Full precedence and the 401 root cause it prevents are in [Troubleshooting § the permanent TUI-mode 401](troubleshooting.md#tui-401).
Part of [OCP](../README.md) — the full upgrade manual (`ocp update` paths, manual flags, rollback, and OpenClaw auto-sync). The README keeps a short stub with the one-liner.
# Upgrading
The simplest path: ask your AI.
Paste this prompt:
```
Upgrade my OCP. Run `ocp update` and follow whatever it says.
If it tells me to run `claude auth login`, I'll do that.
```
What `ocp update` does:
- **Patch bump** (e.g. `v3.21.0 → v3.21.1`):
light path (git pull + npm install + restart).
- **Cross-minor** (e.g. `v3.18 → v3.22`):
full path: pre-flight check, snapshot, `setup.mjs` (with plist env-merge),
service restart, post-flight `/health` and `/v1/models` verification.
- **Old version** (< v3.4.0):
fresh-install. Pre-v3.4 lacked admin-key/usage-db, so there is nothing to
migrate. Your OAuth token (managed by the Claude Code CLI, not OCP) is
preserved; you do not need to re-OAuth unless your token expired
separately.
Snapshots are saved to `~/.ocp/upgrade-snapshot-<ISO-ts>/` and never
auto-deleted. Clean old ones with `rm -rf ~/.ocp/upgrade-snapshot-*` once
you're confident the upgrade is stable.
## Manual upgrade — same command, no AI
```bash
ocp update # smart-pick path
ocp update --check # show available updates, don't apply
ocp update --dry-run # preview plan
ocp update --target v3.13.0 # pin a specific version
ocp update --rollback --list # list snapshots, no mutation
ocp update --rollback --dry-run # preview rollback plan
```
## When upgrade fails
`ocp update` prints a recovery line on failure. To restore from the snapshot:
```bash
ocp update --rollback --yes # --yes confirms the destructive restore
ocp doctor
```
If `ocp doctor` still reports problems after rollback, open a GitHub issue
with the snapshot path and the doctor JSON output (`ocp doctor --json`).
## OpenClaw Auto-Sync (v3.11.0+)
Whenever the model list in [`models.json`](../models.json) changes, `ocp update` automatically reconciles your OpenClaw config so the model dropdown stays in sync — no more "I upgraded OCP but my Telegram bot still shows the old models" surprises.
**What gets synced** (and only this — all other config keys are preserved):
- `models.providers."claude-local".models` in `~/.openclaw/openclaw.json`
**Opt-out**: `ocp update` only invokes the sync if `node` and `scripts/sync-openclaw.mjs` are both present. Removing the script disables auto-sync; the rest of `ocp update` still works.
**One-time bootstrap caveat (v3.10.0 → v3.11.0 only)**: the first `ocp update` to v3.11.0 runs the *old*`cmd_update` already loaded into your shell, so the new sync hook does NOT fire on this single jump. Run `node ~/ocp/scripts/sync-openclaw.mjs` once manually. Every future update from v3.11.0+ syncs automatically. (Also captured in the README Troubleshooting section as a bootstrap quirk.)
**Other IDEs** (Cline / Aider / Cursor / opencode) query `/v1/models` live, so they pick up new models on the next request — no sync needed. Continue.dev users edit their own `config.json` model id manually.
return"OCP_LOCAL_TOOLS=1 is incompatible with CLAUDE_AUTH_MODE=multi — a guest/anonymous prompt would be told it may drive the operator's filesystem/shell. Single-user only.";
}
if(!loopbackBind){
return"OCP_LOCAL_TOOLS=1 requires a loopback bind (127.0.0.1/::1) — a network-exposed positive-tools wrapper could reach an untrusted peer. Bind to loopback, or leave OCP_LOCAL_TOOLS off.";
}
if(anonymousKey){
return"OCP_LOCAL_TOOLS=1 is unsafe with PROXY_ANONYMOUS_KEY set — anonymous callers could reach the local-tools-enabled model without a named key. Remove PROXY_ANONYMOUS_KEY, or leave OCP_LOCAL_TOOLS off.";
console.error(`[tui] invalid OCP_TUI_EFFORT=${JSON.stringify(process.env.OCP_TUI_EFFORT)}; using "low" (valid: ${EFFORT_LEVELS.join("|")}, or "inherit" to omit the flag)`);
effortArgs=["--effort","low"];
}
// --settings registers the MessageDisplay hook. Omitted entirely when streaming is off,
// so the OFF argv is byte-for-byte the pre-streaming argv.
"description":"Schema for models.json, the single source of truth for model metadata (ADR 0003). Enforced in CI by test-features.mjs, which validates models.json against this file using the repo's own validateJsonSchema (lib/structured-output.mjs) — no new dependency. ⚠ ONLY A SUBSET OF DRAFT 2020-12 IS ENFORCED: type, required, const, enum, additionalProperties (boolean and schema forms), items, minItems, maxItems, anyOf/allOf/oneOf, $ref, nullable. Anything else — minimum, maxLength, pattern, uniqueItems, propertyNames, not — is SILENTLY IGNORED by that validator, so adding one here buys nothing and misleads the next reader. Add such a constraint as a test in test-features.mjs instead. NOTE: referential integrity (every aliases/legacyAliases target must exist as a models[].id) is likewise not expressible here and is covered by separate tests.",
"description":"Canonical CLI model id, passed verbatim to `claude --model`. Must match the id in the compiled CLI registry."
},
"displayName":{
"type":"string",
"description":"Human label. Also used as the OpenClaw agent alias by scripts/sync-openclaw.mjs."
},
"openclawName":{
"type":"string",
"description":"Label written into OpenClaw's model registry."
},
"reasoning":{
"type":"boolean",
"description":"Whether OpenClaw should treat the model as reasoning-capable."
},
"contextWindow":{
"type":"integer",
"description":"Advertised context window. GLOBAL side effect: MAX_PROMPT_CHARS derives from max(contextWindow) x 3 across ALL entries (lib/prompt.mjs derivePromptCharBudget), so raising this on ONE model raises the truncation ceiling for EVERY model. See ADR 0009. Also feeds OpenClaw's compaction budget."
},
"maxTokens":{
"type":"integer",
"description":"Advertised output cap. OCP does not enforce it; it is propagated to OpenClaw (via setup.mjs / scripts/sync-openclaw.mjs) where it bounds request and compaction budgets."
}
}
}
},
"aliases":{
"type":"object",
"description":"Short name -> canonical models[].id. Client-addressable, so a repoint changes routing for every request using the alias. Cache keys resolve these before hashing (server.mjs cacheModel).",
"additionalProperties":{"type":"string"}
},
"legacyAliases":{
"type":"object",
"description":"Retired ids kept resolvable for backward compatibility -> canonical models[].id. Also client-addressable and also resolved in cache keys.",
"description":"OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.