Compare commits

...
Author SHA1 Message Date
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
bdb6662c7c chore(release): v3.24.0 — OpenAI multimodal + structured outputs, SPOT prompt budget, agentic fix, OCP_LOCAL_TOOLS (#186)
* 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>
2026-07-21 21:19:13 +10:00
4f9e2ff281 feat(server): OCP_LOCAL_TOOLS — positive local-tools system-prompt wrapper (single-user, default off) (#182)
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>
2026-07-21 21:05:38 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
45c5717aea fix(structured): crash-safe validation façade — deep model reply → refusal not 500 (closes #181) (#184)
* 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>
2026-07-21 20:05:50 +10:00
47e324b68f fix(server): assemble every assistant message so agentic turns return the final answer (#183)
* 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>
2026-07-21 19:58:09 +10:00
788cbbcd99 feat(server): honor OpenAI response_format for structured-output clients (#153)
* 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>
2026-07-20 07:55:40 +10:00
ac81badda1 feat(chat): forward OpenAI image_url parts to Claude (multimodal vision) (#154)
* 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>
2026-07-20 07:50:17 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
fe12419386 feat(server): SPOT-derived prompt budget — MAX_PROMPT_CHARS follows models.json (ADR 0009) (#179)
* 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>
2026-07-18 10:11:38 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
17038b56e6 chore(release): v3.23.0 — sonnet-5 default, upgrade reliability, CLAUDE_SYSTEM_PROMPT, README restructure (#178)
* 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>
2026-07-17 19:40:02 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
73314e6698 fix(cache): fold a boot-config epoch into the response-cache key (#176) (#177)
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>
2026-07-17 19:31:31 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
e6f1a6aac1 fix(server): wire CLAUDE_SYSTEM_PROMPT (dead since APPEND_SYSTEM_PROMPT retirement) (#175)
* 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>
2026-07-17 19:18:57 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
f14f4ec754 fix(upgrade): doctor fetches tags before deciding latest + post-flight asserts served version (#173) (#174)
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>
2026-07-17 18:52:45 +10:00
bafad077ff fix(upgrade): make snapshot paths Windows-safe (#167)
* 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>
2026-07-17 18:39:09 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
b038d3ceac docs: restructure README (1205 → 497 lines) — ops-manual content moves to docs/ (#172)
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>
2026-07-17 14:28:36 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
faea02d951 docs(readme): date-stamp the billing-split status (PAUSED) + scope LAN mode to chat-class workloads (#171)
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>
2026-07-17 13:54:05 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
6f18613f9d docs(readme): staleness sweep — model count/examples, phantom ocp stop, undocumented env vars, ocp-connect claim (#170)
* 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>
2026-07-17 09:30:57 +10:00
0c3e42b2e4 feat(models): repoint default sonnet alias to claude-sonnet-5 (#168)
* 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>
2026-07-17 08:10:33 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
0fc8d6973b chore(release): v3.22.1 — retitle unpublished v3.22.0 + fold in #161 (Windows resolve) and #152 (Sonnet 5) (#169)
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>
2026-07-17 07:59:45 +10:00
27216646c8 feat(models): add Claude Sonnet 5 to models.json SPOT (#152)
* 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>
2026-07-17 07:35:42 +10:00
d501e786b8 fix(init): resolve Windows claude.exe only (#161)
* 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>
2026-07-16 09:34:35 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
b7463a63f5 chore(release): v3.22.0 — TUI effort/pool/streaming (opt-in) + post-audit hardening (#166)
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>
2026-07-16 05:47:32 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
eeec2bf83d fix(setup): never carry test-only key-store redirection vars into a server OCP launches (A4) (#165)
* 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>
2026-07-15 22:28:49 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
63c2de7128 fix(tui): clamp stream holdback to safe floor (A1) + record cc_entrypoint before honesty gates (A3) (#164)
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>
2026-07-15 21:40:13 +10:00
1d65bc309e fix(test): stop the suite writing live API keys into the operator's real key store (#163)
* 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>
2026-07-15 08:33:08 +10:00
88d8bed2e3 docs(readme): stop the feature bullet promising what § honest limits forbids (#136) (#162)
* 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>
2026-07-15 08:33:05 +10:00
a90f830b5d fix(tui): a null message_id on the first hook fire must not disarm the F1 guard (#160)
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>
2026-07-14 08:21:30 +10:00
1b324968f4 feat(tui): real SSE streaming via claude's MessageDisplay hook (OCP_TUI_STREAM, default off) (#159)
* 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>
2026-07-14 08:19:24 +10:00
9f5bc3264a feat(tui): warm pane pool — single-use pre-booted panes, opt-in via OCP_TUI_POOL_SIZE (−41%) (#158)
* 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>
2026-07-13 16:51:12 +10:00
e7ce9899f3 docs(plans): TUI streaming IS achievable (MessageDisplay hook) — prereq spike + honest latency constraints (#157)
* 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>
2026-07-13 16:30:55 +10:00
5258d5d395 feat(tui): pin spawn effort via OCP_TUI_EFFORT (default low) (#156)
* 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>
2026-07-13 15:16:30 +10:00
6854075c01 docs(plans): TUI-mode latency floor — measured decomposition + backlog (#155)
* 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>
2026-07-13 14:22:17 +10:00
45152d58b0 chore(release): v3.21.1 — concurrency queue + spawn-token + TUI session-scope fixes (#151)
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>
2026-07-07 23:09:01 +10:00
d96da46fa0 fix: honor runtime-lowered concurrency limit + cancel queued waiters on disconnect (#149)
* 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>
2026-07-07 23:04:13 +10:00
2538233059 fix(server): serialize -p real-HOME token fallback + TTL-cache keychain + de-stale isolation decision (F3/F5/F6) (#150)
* 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>
2026-07-07 22:50:52 +10:00
31e5a44099 fix(tui): scope session prefix + reap/kill-server to this instance's port (F7) (#148)
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>
2026-07-07 22:43:19 +10:00
2922d68842 fix(server): re-resolve -p spawn OAuth token per-spawn, expiry-aware (③ regression) (#146)
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
38da104b97 chore(release): v3.21.0 — TUI cleanup + client-tools/ToS docs + promotion plan (#145)
* refactor(tui): dead-code / footgun cleanup (A1/A2/A3)

ALIGNMENT.md Rule 2: infra-only, no new cli.js wire behavior.
No protocol, no endpoint, no credential changes.

A1 (session.mjs): delete resolveTuiEntrypointEnv() and its call site
+ the redundant env-strip block around the spawnSync call. The function
mutated a {env} object passed to spawnSync (tmux itself), but tmux does
NOT forward that env to the pane; the pane's claude gets its env ONLY
from the `env` prefix string built inside buildTuiCmd (verified live
2026-06-01). The spawnSync {env} is intentionally minimal now; only
env.HOME is retained (tmux binary reads it). All claude-specific vars
go via the buildTuiCmd prefix string, unchanged. Tests for the now-
deleted function removed; test count drops by 7 (expected).

A2 (transcript.mjs): delete encodeCwd() and transcriptPath() exports.
Production resolves transcripts exclusively via findTranscriptPath()
(glob by session-id); these two helpers carried a fragile path-encoding
rule used only by their own tests. grep confirms zero non-test importers.
Added a TODO comment near findTranscriptPath() noting a CI fixture-
contract test would make claude-schema drift fail loudly. Tests removed;
count drops by 2.

A3 (session.mjs + README): remove the CLAUDE_SKIP_PERMISSIONS branch
that pushed --dangerously-skip-permissions when OCP_TUI_FULL_TOOLS=1.
OCP_TUI_FULL_TOOLS=1 now always takes the --allowedTools path. Rationale:
claude v2.1.x shows an interactive bypass-acceptance screen that a
headless tmux TUI cannot answer — bricks the turn (tui_paste_not_landed
/ wallclock cap), not recoverable without a human. The working path is
--allowedTools + scratch-home settings.json additionalDirectories.
README OCP_TUI_FULL_TOOLS row updated to document the removal and the
correct alternative; CLAUDE_SKIP_PERMISSIONS row for the -p path is
unchanged (still used in server.mjs). Test updated: skip-permissions
case replaced with an assertion that --dangerously-skip-permissions is
absent from the full-tools command.

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

* docs: client-tools boundary, ToS honesty, promotion plan (B1/B2/B3)

ALIGNMENT.md Rule 2: docs-only, no new cli.js wire behavior.
No protocol, endpoint, or credential changes.

B1 (README): add 'Client-tools boundary' subsection under 'How It
Works'. Documents that OCP is a text-prompt bridge only — it does not
pass OpenAI tools/functions or Anthropic tool_use blocks to the client.
Clients receive assistant TEXT only; client-local tool execution is
not supported by design (bypassing cli.js = out of scope per
ALIGNMENT.md).

B2 (README): two updates to 'Why OCP?' and the LAN-sharing section.
(a) New bullet: OCP drives the official claude CLI as-is — no OAuth
token extraction, no binary patching, no protocol invention — so
traffic looks like genuine Claude Code (cc_entrypoint=cli).
(b) LAN-sharing paragraph strengthened: pooling one Claude subscription
across multiple distinct people may violate Anthropic's Consumer ToS
and risk account suspension by the abuse classifier. The defensible
framing is 'one person, your own devices'; friends/team sharing is
not. Replaces the softer 'account terms are your call' language.
Feature and auth-mode docs are unchanged.

B3 (docs/PROMOTION.md): new promotion strategy doc. Covers: goal
(polish + low-key OSS visibility, NOT growth-hacking given the live
ToS/billing risk), pre-requisites (stability first), honest ToS
disclosure requirement, items explicitly skipped (multi-backend
routing, gateway model-discovery — delegated to OLP; raw API
passthrough — ALIGNMENT.md scope), TUI toggle as billing-split
insurance, and low-key visibility actions. Framed as a recommendation
for the maintainer to review, not a committed plan.

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

* chore(release): v3.21.0 — TUI cleanup + docs honesty + promotion plan

ALIGNMENT.md Rule 2: release prep; no new cli.js wire behavior.

Bump version 3.20.1 → 3.21.0. CHANGELOG entry covers:
- A1/A2/A3 TUI dead-code removals (inert entrypoint-env path,
  test-only transcript helpers, headless-unusable skip-permissions)
- B1/B2/B3 docs (client-tools boundary, ToS honesty, Why-OCP posture,
  promotion plan)
- Previously-shipped v3.20.x items documented for completeness:
  spawn-home isolation, bounded concurrency queue + 429, ocp restart,
  ocp-plugin OpenClaw compat.

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

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 11:50:06 +10:00
5aaab5ea28 fix: -p spawn-home isolation (③ latency 3x) + concurrency queue/429 (⑥) + ocp restart env + /ocp plugin compat (#144)
* fix(server): isolate default -p spawn in credential-free scratch HOME (latency ③)

The default (-p/stream-json) spawn inherited the operator's real HOME (global
~/.claude plugins/skills/hooks) and ran with cwd=~/ocp (project CLAUDE.md/skills),
loading heavy host context on EVERY request. Measured: pure API floor for haiku
"hi" ≈ 1–2s; same CLI in the operator's real HOME/cwd ≈ 10–28s; a clean minimal
HOME + CLAUDE_CODE_OAUTH_TOKEN ≈ 3–7s with auth intact.

When an OAuth token is resolvable (and OCP_SPAWN_REAL_HOME!=1), spawnClaudeProcess
now runs claude under a credential-free minimal scratch HOME (<HOME>/.ocp/spawn-home,
no .credentials.json / settings.json / plugins) with cwd = that neutral dir and the
resolved token in CLAUDE_CODE_OAUTH_TOKEN (env token is authoritative for -p). Mirrors
the TUI path's resolveTuiHome() env-token mode. Falls back to real HOME + inherited cwd
when no token resolves (zero regression); OCP_SPAWN_REAL_HOME=1 is a kill-switch.

The token is resolved ONCE (memoized via getSpawnHomeMode, reusing getOAuthCredentials,
the same resolver the /usage probe uses) and never logged. Adds a startup log line and
an additive /health `spawn` block so the operator can confirm isolation is on.

ALIGNMENT.md Rule 2 justification: this is an INFRA / PROCESS-MANAGEMENT change
(HOME/cwd/env isolation of the spawned process). It does NOT mirror a cli.js wire
operation, introduces no new endpoint/header, and adds no API token to the wire path —
so there is no cli.js function to cite. CLAUDE_CODE_OAUTH_TOKEN / CLAUDE_CODE_DISABLE_*
are existing claude-CLI env contracts already used by the TUI path and getOAuthCredentials.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(server): bounded wait-queue + HTTP 429 for -p concurrency overflow (⑥)

spawnClaudeProcess used `if (activeRequests >= MAX_CONCURRENT) throw` → the client
got an opaque 500 AND the rejection was uncounted (a 15-concurrent stress run returned
7×500 while /health stats.errors stayed 0). The TUI path already had a bounded-queue
semaphore (TuiSemaphore); the -p path did not.

Now the -p path reuses TuiSemaphore as `claudeSemaphore = new TuiSemaphore(MAX_CONCURRENT,
{ maxQueue: CLAUDE_MAX_QUEUE })`. Requests beyond MAX_CONCURRENT WAIT (up to CLAUDE_MAX_QUEUE,
default 16) instead of being rejected; only when the queue is ALSO full does the request get
HTTP 429 + Retry-After (rate_limit_error, NOT 500), a distinct `concurrency_queue_full` log,
and a stats.queueRejections counter surfaced on /health. callClaude / callClaudeStreaming now
acquire a slot (acquireClaudeSlot) before spawning; the release fn is wired into the existing
idempotent cleanup() so the slot is freed on EVERY exit path (close/error/timeout/abort) — the
#37/#40 slot-leak guard. MAX_CONCURRENT semantics (max concurrent claude procs) are unchanged;
only overflow handling changed from throw-500 to queue-then-429. claudeSemaphore.limit is kept
in sync with runtime /settings maxConcurrent changes.

Live-verified (fake claude, MAX_CONCURRENT=1 MAX_QUEUE=1): 3 concurrent → exactly one 429
(Retry-After: 7, rate_limit_error body) + two 200s, on BOTH the non-streaming and streaming
paths; /health stats.queueRejections=1 while stats.errors=0; after drain activeRequests=0 /
inflight=0 / queued=0 and a follow-up request returns 200 (no slot leak). 2 new unit tests
(247 passed, 0 failed).

ALIGNMENT.md Rule 2 justification: this is an INFRA / PROCESS-MANAGEMENT change (a concurrency
queue + backpressure status code in front of the existing spawn). It does NOT mirror a cli.js
wire operation, adds no new endpoint or wire header, and introduces no API token — so there is
no cli.js function to cite. (Retry-After is a standard HTTP response header on OCP's own 429,
not a claude wire header.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ocp): macOS restart uses bootout+bootstrap so plist env is re-read

The macOS restart path used `launchctl kickstart -k gui/$uid/dev.ocp.proxy`, which
only re-execs the process and reuses launchd's CACHED environment — so a plist
EnvironmentVariables edit (CLAUDE_BIND, CLAUDE_CODE_OAUTH_TOKEN, etc.) was silently
ignored until a full unload/reload. This is the documented pit-index footgun.

`ocp restart` (macOS) now does a full `launchctl bootout` + `bootstrap` of the agent
via a new `_launchd_reload` helper, which re-reads the plist EnvironmentVariables so env
changes take effect. Success is keyed on the bootstrap (the env-reloading load), not the
bootout (which may legitimately fail if the agent is not currently loaded). A missing
plist returns failure so the `elif` chain falls through to the legacy label and then to
the Linux `systemctl --user restart` path unchanged (systemctl already re-reads its
EnvironmentFile). Updates `ocp restart` help text and adds a README Troubleshooting
subsection ("Env var change doesn't take effect after restart") with the manual
bootout+bootstrap commands and a ps-based verification one-liner.

Verified `_launchd_reload` with a stubbed launchctl: missing plist → rc=1 (falls through,
no launchctl call); bootout-fail + bootstrap-ok → rc=0; bootstrap-fail → rc=1; call order
is bootout then bootstrap. `bash -n ocp` clean; npm test 247/0 (server.mjs untouched).

ALIGNMENT.md Rule 2 justification: this changes ONLY the `ocp` CLI wrapper's local
service-restart mechanism (launchctl invocation). It does not touch server.mjs, the wire
path, any endpoint/header, or any API token — so there is no cli.js function to cite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(readme): document new env vars CLAUDE_MAX_QUEUE / CLAUDE_QUEUE_RETRY_AFTER / OCP_SPAWN_REAL_HOME

Release_kit contract (CLAUDE.md Iron Rule 5.5: "new env var → README § Environment
Variables table") requires the three env vars added by the perf/concurrency fixes to be
in the README table. Adds rows for CLAUDE_MAX_QUEUE (16) + CLAUDE_QUEUE_RETRY_AFTER (5)
(FIX ⑥ -p wait-queue + 429) and OCP_SPAWN_REAL_HOME (FIX ③ spawn-home isolation
kill-switch), each cross-referencing the additive /health.concurrency and /health.spawn
fields. Addresses the independent reviewer's MEDIUM finding. Docs only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ocp-plugin): add openclaw.extensions for OpenClaw 2026.5.27 compat + sync version (②/ocp)

ocp-plugin/package.json's openclaw object lacked the 'extensions' field that
OpenClaw 2026.5.27 requires to install/load a plugin (matches the sibling olp
plugin). Without it the daemon refused to load the local path plugin, breaking
/ocp Telegram commands. Version synced 3.12.0 -> 3.16.2 to match the manifest.
Plugin-layer change; no server.mjs / ALIGNMENT cli.js surface touched.

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:26:57 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus> <noreply@anthropic.com>
3bd19956ff docs(readme): honest ToS framing for LAN sharing (#136) (#143)
External user (#136) reported the LAN setup prompt instructs Claude to "share
my Claude Pro/Max subscription with family" — which Claude Code refuses
(Anthropic Usage Policy: per-user accounts). Reword the LAN setup prompt to
"my own devices ... reach my subscription via a local OpenAI-compatible
endpoint" (no longer triggers the refusal), and add an honest "account terms
are your call" note to the existing sharing-limits section. Keeps the truthful
origin story and the existing honest security-limits framing.

Docs only. No server.mjs / Class A surface / models.json touched.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus> <noreply@anthropic.com>
2026-06-13 17:04:07 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus> <noreply@anthropic.com>
fe615cb0d3 chore(release): v3.20.1 — TUI credential-isolated auth (ends recurring 401) + defunct-session reaping (#141) (#142)
Bump 3.20.0 → 3.20.1 + CHANGELOG. Ships the already-merged, twice-reviewed #141
(credential-isolated env-token home + zombie reaping). README/docs updated in #141.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus> <noreply@anthropic.com>
2026-06-13 16:55:59 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus> <noreply@anthropic.com>
60930f0ba4 fix(tui): credential-isolated env-token auth (ends recurring 401) + reap defunct sessions (#141)
* fix(tui): pass CLAUDE_CODE_OAUTH_TOKEN to spawned claude + reap defunct sessions

Root cause (PI231 incident): tmux does not forward the parent's env to the
pane, so the TUI claude never saw CLAUDE_CODE_OAUTH_TOKEN and fell back to
~/.claude/.credentials.json, whose single-use refresh token got corrupted to an
empty string by the per-request spawn + kill-session teardown racing claude's
token rotation -> permanent "Please run /login" 401 (re-login re-corrupted on
the next spawn). Connected leak: the pane's claude is a child of the tmux server
(not node), so kill-session left <defunct> zombies the server never reaped (25
over 30 days; tmux kill-server dropped it 25->3).

Fix 1: buildTuiCmd now adds CLAUDE_CODE_OAUTH_TOKEN=<shq-escaped> to the pane
env prefix when the env is set, so claude authenticates via the long-lived token
and never touches the credentials.json refresh path (matching stable hosts).
Unset -> no token added (credentials.json-only hosts unaffected).

Fix 2: reapStaleTuiSessions kill-servers after clearing our own sessions ONLY
when no foreign tmux session remains (never disrupts a co-hosted olp-tui-*).
kill-server is the only node-reachable action that ACTUALLY reaps -- server exit
reparents survivors to init, which waitpids them; a per-session kill cannot,
since node is not the zombies' parent. Added a 15-min periodic reap (server.mjs)
gated on TUI_MODE and on the TUI path being idle. Residual: a request whose pane
is created in the idle-check/kill-server window fails cleanly via the existing
honesty gates (documented).

ALIGNMENT: Class B (OCP-owned TUI spawn). cli.js does NOT perform either
operation -- there is no cli.js analogue for "how the TUI pane authenticates" or
"reaping tmux-server-owned zombies"; authorized by ADR 0007 (PR-C amendment) per
ALIGNMENT.md's Class B citation requirement. No Class A wire surface, no endpoint
shape, no alignment.yml token, and no models.json entry touched.

Tests: +6 in test-features.mjs (buildTuiCmd token set/unset/shq-injection;
reaper kill-server ours-only / foreign-present / no-server). 241 passed, 0 failed.

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

* fix(tui): isolate TUI auth to env-token-only home (no credentials.json shadowing)

Passing CLAUDE_CODE_OAUTH_TOKEN to the spawned interactive `claude` (commit
6394ca3) is necessary but INSUFFICIENT to fix the PI231 401: interactive `claude`
PREFERS ~/.claude/.credentials.json over the env var (unlike `-p`, where the env
token wins), so a stale/corrupt credentials.json SHADOWS the env token. Decisive
live evidence on PI231 (claude 2.1.104):

  - env token passed + a broken ~/.claude/.credentials.json present → 401
    ("Please run /login · API Error: 401").
  - env token passed + credentials.json moved aside              → real answer.

Fix: when CLAUDE_CODE_OAUTH_TOKEN is set (and OCP_TUI_HOME is unset), run the TUI
`claude` in a CREDENTIAL-FREE scratch home (<HOME>/.ocp-tui/home) that has NO
credentials.json — no symlink, no copy. The env token is then the only credential
and is authoritative because nothing shadows it. This ALSO ends the original
refresh-corruption incident (25-zombie / empty-refresh-token) at the ROOT: with no
credentials file, claude never runs the token-refresh path, so the single-use
refresh token can never be rotated/corrupted by the per-request spawn+kill cycle.

This RESOLVES — not reintroduces — the ADR 0007 scratch-home concern. The old
caveat was about a SYMLINKED credentials.json being forked on token refresh; in
env-token mode there is no credentials file to fork and no refresh ever happens.

Mechanism: scratch HOME (not CLAUDE_CONFIG_DIR). The claude binary supports
CLAUDE_CONFIG_DIR, but it relocates transcripts to <CONFIG_DIR>/projects/ rather
than <HOME>/.claude/projects/, forking the transcript-resolution rule across modes
for no benefit. Scratch-HOME reuses the existing, tested prepareTuiHome/ehome
plumbing; readTuiTranscript reads from the same home claude runs under, so
transcripts land under the scratch home and findTranscriptPath globs them there.

Backward compatible: when CLAUDE_CODE_OAUTH_TOKEN is unset, behaviour is byte-for-
byte unchanged (real home + credentials.json) so hosts that intentionally rely on
credentials.json are unaffected. Explicit OCP_TUI_HOME still wins. Onboarding +
cwd-trust are seeded in the scratch .claude.json (hasCompletedOnboarding=true +
trust ONLY the scratch cwd) so no interactive trust/onboarding dialog can hang the
turn.

Changes:
- lib/tui/session.mjs: add resolveTuiHome() (pure) + DEFAULT_TUI_SCRATCH_HOME;
  prepareTuiHome() gains { envTokenMode } — skips the credentials symlink and seeds
  a minimal .claude.json; runTuiTurn derives envTokenMode = token set && ehome!==rhome.
- server.mjs: TUI_HOME computed via resolveTuiHome(); boot log surfaces the auth mode.
- test-features.mjs: env-token credential-free prepareTuiHome test (asserts NO
  credentials.json created/symlinked, .claude.json seeded with onboarding + cwd
  trust) + 3 resolveTuiHome decision tests; existing buildTuiCmd-token + reaper +
  legacy/real-home tests stay green (245 passed, 0 failed).
- docs/adr/0007: PR-D amendment (corrects the PR-C rationale + the original
  scratch-home caveat); README Troubleshooting #401 + env-var table + TUI section.

ALIGNMENT: Class B (OCP-owned TUI spawn). cli.js has no analogue for the TUI pane's
auth/home strategy — authorized by ADR 0007 (PR-D amendment) per ALIGNMENT.md's
Class B citation requirement. No Class A wire path, no alignment.yml blacklist
token, no models.json touched. server.mjs is touched only to wire TUI_HOME via
resolveTuiHome() and surface auth mode in the boot log.

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

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus> <noreply@anthropic.com>
2026-06-13 16:54:32 +10:00
dtzp555-maxGitHubtaodengClaude <claude-fable-5> <noreply@anthropic.com>
c86e3d014f chore(release): v3.20.0 — TUI billing-safety hardening for 2026-06-15 (PR-A/B/C, #137-139) (#140)
Bump 3.19.0 → 3.20.0 + CHANGELOG. Aggregates three already-reviewed, already-merged PRs:
- #137 (PR-A) TUI honesty/cache correctness
- #139 (PR-B) TUI concurrency limit + /health observability
- #138 (PR-C) 6/15 canary + flip/rollback runbooks + setup auth-probe guard

README env-var / endpoint tables were updated in the respective feature PRs.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-fable-5> <noreply@anthropic.com>
2026-06-10 21:56:56 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus> <noreply@anthropic.com>
3322d7bdae feat(tui): per-path concurrency limit + /health observability (PR-B) (#139)
Two P1 audit fixes for the TUI subscription-pool bridge (ADR 0007). The
default path (CLAUDE_TUI_MODE unset) is unchanged except the additive
/health `tui` block (enabled:false when off).

C-4 — TUI path had NO concurrency bound. The global MAX_CONCURRENT gate
lives in spawnClaudeProcess (the -p/stream-json path); callClaudeTui never
calls it — it calls runTuiTurn, which cold-boots a full interactive claude
in tmux. So N concurrent TUI requests spawned N simultaneous cold boots (a
family burst of 5 on a Pi 4 = OOM risk + subscription rate-limit pressure).
Adds an independent in-process limiter (lib/tui/semaphore.mjs, TuiSemaphore)
gating callClaudeTui: OCP_TUI_MAX_CONCURRENT (default 2 — a TUI turn is heavy:
per-request cold-boot + up to 120s wallclock). Queues rather than rejects
(mirrors MAX_CONCURRENT intent), with a bounded wait queue (default 32x the
limit) → tui_queue_full (503) on overflow rather than unbounded growth. The
slot releases in a finally, so PR-A's honesty-gate throws / timeouts / paste
failures never leak a slot.

C-5 — no operator-visible TUI drift surface. The tui_entrypoint_mismatch
warning only reached journald; after the 6/15 flip a silent sdk-cli drift
(the documented top risk) would drain metered credits invisibly. Adds an
additive `tui` block to /health: { enabled, entrypointMode, lastEntrypoint,
entrypointMismatches, inflight, queued, maxConcurrent }. lastEntrypoint /
entrypointMismatches are recorded in callClaudeTui (same mismatch branch the
journald warning covers); inflight/queued come from the C-4 semaphore.

ALIGNMENT (Class B): cli.js does NOT perform this operation — both the TUI
path and /health are OCP-owned, so no cli.js citation applies. /health is a
grandfathered B.2 endpoint (ADR 0006, frozen at v3.16.4). The response-shape
change is authorized by the ADR 0007 PR-B amendment added in this commit and
is behaviour-preserving: the `tui` block is NEW fields only — no existing
/health field is changed, renamed, removed, or re-typed, and no existing
semantics change, so existing consumers (dashboard, ocp-connect, monitoring)
are unaffected. Per ALIGNMENT.md's grandfather provision, an additive
behaviour-preserving change to a grandfathered B.2 endpoint is authorized by
an ADR; ADR 0007 is the authority for the TUI observability surface. No
Class A forwarding path, no alignment.yml, no models.json touched — alignment
blacklist is unaffected (zero new network tokens).

Tests: 11 new (lib/tui/semaphore.mjs is importable, so the semaphore + the
two pure /health helpers are tested directly): limit=1 serializes two
overlapping calls; limit=2 runs two + queues the third; slot released on
throw; bounded queue → tui_queue_full; mismatch counter increments on
cli→other drift; auto mode never counts a mismatch; /health tui block shape
+ live inflight/queued. npm test: 235 passed, 0 failed (was 224).

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus> <noreply@anthropic.com>
2026-06-10 21:54:07 +10:00
79c1d61e1d docs(tui): 6/15 canary + flip/rollback runbooks + setup auth-probe guard (PR-C) (#138)
Docs scope (no server.mjs touched):
- docs/runbooks/615-canary.md — executable canary runbook: quiesce host,
  read Agent SDK credit balance manually (no programmatic API exists for
  that pool — this is stated explicitly to avoid endpoint hallucination),
  send one Haiku turn via TUI-mode, confirm cc_entrypoint=cli in transcript,
  re-read balance, green/red decision tree, periodic self-classification
  mini-canary with OCP_TUI_ENTRYPOINT=auto.
- docs/runbooks/tui-flip-rollback.md — flip and rollback procedure for
  systemd (EnvironmentFile + daemon-reload) and launchd (plist edit +
  bootout/bootstrap cycle). Calls out both known pitfalls explicitly:
  daemon-reload required on systemd; launchctl kickstart -k does NOT
  reload plist env on launchd (matches MEMORY.md operational pit entry).
- README.md § "2026-06-15 operator checklist" — brief fleet checklist +
  pointers to both runbooks. Placed inside the existing TUI-mode section
  just before "Architecture and design decisions".
- README.md § Environment Variables — adds OCP_SKIP_AUTH_TEST row.

Operator-tooling scope (setup.mjs, not a Class A wire path):
- setup.mjs auth quick-test: wraps the existing claude -p probe in an
  OCP_SKIP_AUTH_TEST=1 gate; adds inline comment warning that after
  2026-06-15 the probe draws from the Agent SDK credit pool. The setup
  flow is unchanged when OCP_SKIP_AUTH_TEST is unset.

Alignment note: this PR does not touch server.mjs. The setup.mjs change
is operator-tooling only — it guards a local test spawn, not any wire
path. No cli.js citation required (no Class A surface is modified).

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude claude-sonnet-4-6 <noreply@anthropic.com>
2026-06-10 21:41:03 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus> <noreply@anthropic.com>
a37ff713d9 fix(tui): honest error/truncation handling + version-robust entrypoint + short-prompt paste (PR-A) (#137)
TUI-mode (CLAUDE_TUI_MODE=true) is an OCP-owned subscription-pool bridge for the
2026-06-15 Anthropic billing split. This PR fixes four honesty/robustness defects
confirmed by a prior audit and live-reproduced on PI231 (2026-06-10).

C-1 (P1) — upstream AUTH-FAILURE banner returned/cached as a real answer.
  The interactive claude CLI renders in-session errors as ordinary assistant text.
  The R-1 case C-1 exists to catch is expired/invalid credentials, where EVERY turn
  comes back as the same one-line auth-failure banner, e.g. the two live PI231
  banners (2026-06-10):
    "Please run /login · API Error: 401 Invalid authentication credentials"  (69 chars)
    "Failed to authenticate. API Error: 401 Invalid authentication credentials" (73 chars)
  callClaudeTui returned that banner verbatim → OCP cached it, shared it via
  singleflight, and recorded a model SUCCESS. New detectTuiUpstreamError()
  (lib/tui/transcript.mjs) flags such a turn; callClaudeTui throws tui_upstream_error
  + logEvent("error", …) BEFORE recordModelSuccess/cache write-back, so the error
  never enters the cache; the client gets a 5xx.

  C-1 NARROWING (false-positive probe, 2026-06-10). An earlier generalised default
  rule — ^<short auth-failure prefix>?API Error: <3-digit> <detail>$ — was TOO BROAD:
  the unbounded ".*" detail tail let any short prefix + "API Error: NNN" + an
  arbitrarily long sentence match, so it KILLED legitimate long answers that merely
  DISCUSS an API error (e.g. "API Error: 500 happened because the server was
  overloaded. To fix this, retry with exponential backoff …"). A false-positive costs
  the user a missing answer AND a double-burn retry — strictly worse than the rare
  false-negative (caching one transient error for the 5-min TTL). C-1 is therefore
  reframed from "detect any API error" to "detect a claude-CLI AUTHENTICATION-FAILURE
  banner", and is CONSERVATIVE: when unsure it PASSES. The narrowed default detector
  flags a turn only when ALL of the following hold over the WHOLE trimmed text
  (a conjunction; any one failing => PASS):
    1. SHORT whole-message — length ≤ 100 (live banners are 69/73; cap gives headroom
       while rejecting multi-sentence prose; a 226-char auth answer is dropped on
       length alone).
    2. Contains "API Error: 4\d{2}" — auth failures are 4xx (401/403); transient 5xx
       and bare "HTTP 401 means unauthorized." (no API-Error core) are excluded.
    3. Contains an auth keyword — authenticat | /login | credential (case-insensitive);
       rejects "To debug a 401 … API Error: 401 Unauthorized …" (authoriz-, not
       authenticat-).
    4. Contains NO backtick/quote char (` ' ") — a real banner is plain text; quoted
       text signals an answer QUOTING the error, e.g. "You'll see `API Error: 401` …
       run /login to fix it." (short + 4xx + /login, excluded only by this signal).
  Full required matrix (2 KILL + 7 PASS) encoded as tests; npm test green.
  CLAUDE_TUI_ERROR_PATTERNS still overrides (a non-empty value REPLACES the default
  with operator regexes; empty/whitespace DISABLES detection). New fixture
  lib/tui/fixtures/error-401-failauth.jsonl + positive/negative tests retained.

C-2 (P1) — wallclock-truncated partial text returned as silent success.
  readTuiTranscript returned {text, entrypoint} identically on the terminal-marker
  path and the cap-with-partial path, so a cut-off turn was cached + returned as
  finish_reason:stop. It now returns truncated:false on terminal-marker and
  truncated:true on the cap-with-partial path (additive field; no-text cap path
  still throws). callClaudeTui throws tui_wallclock_truncated on truncated.

C-3 (P1) — verifyEntrypoint only read the turn_duration line.
  Some claude builds don't emit turn_duration (Mac mini: absent; PI231/2.1.104:
  present), while the entrypoint field is on ordinary lines on BOTH. Reading only
  turn_duration made the server.mjs tui_entrypoint_mismatch assertion get got:null
  every turn on non-emitting builds. verifyEntrypoint now PREFERS the turn_duration
  entrypoint and FALLS BACK to the entrypoint field on any line.

C-4 (P2) — short prompts 100% failed paste-landing.
  tuiPromptLanded required needle.length >= 3, so a 1–2 char first line ("hi","ok")
  never matched and 5s-failed with tui_paste_not_landed every time (live-repro:
  "hi"). Threshold lowered 3 → 2; the input box starts empty (placeholder excluded
  by the affirmative-signal design) so a 2-char needle present in the pane is the
  prompt. Kept >=2 (not >=1) to avoid collisions with claude's chrome glyphs.

Tests: extended test-features.mjs with unit coverage for each fix + three fixtures
(lib/tui/fixtures/error-401.jsonl, error-401-failauth.jsonl, no-turn-duration.jsonl).
The C-1 block now encodes the full narrowed auth-banner matrix (2 must-kill + 7
must-pass + supporting regression guards incl. a length-cap-load-bearing test).
npm test: 224 passed, 0 failed. Default path (CLAUDE_TUI_MODE unset →
upstreamCall=callClaude) is byte-identical: the only server.mjs change is one import
+ the callClaudeTui body, and callClaudeTui is unreachable when TUI_MODE is off.

ALIGNMENT: Class B (OCP-owned compatibility surface). cli.js does not perform this
operation (TUI is an OCP-owned subscription-pool bridge); scope authorized by ADR
0007 (Class B). No Class A path touched (callClaude / handleUsage / OAuth unchanged);
no change to .github/workflows/alignment.yml or any blacklisted token.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus> <noreply@anthropic.com>
2026-06-10 21:39:01 +10:00
6d4751f983 feat(tui): OCP_TUI_FULL_TOOLS gate — -p-equivalent tools for single-user TUI (#135)
* feat(tui): OCP_TUI_FULL_TOOLS gate — -p-equivalent tool surface for single-user TUI

Lets a SINGLE-USER / trusted TUI deployment run a tool-using / MCP agent (e.g. an
OpenClaw assistant) on the subscription pool. When OCP_TUI_FULL_TOOLS=1, buildTuiCmd
grants the interactive session the SAME tool surface as the -p A-path — --allowedTools
(+ optional --mcp-config / --dangerously-skip-permissions, read from the same
CLAUDE_ALLOWED_TOOLS / CLAUDE_MCP_CONFIG / CLAUDE_SKIP_PERMISSIONS env as buildCliArgs)
— instead of the default MCP-walled, built-in-tools-only set.

Motivation: the default TUI tool wall (--strict-mcp-config --disallowedTools mcp__*)
exists for multi-tenant safety, but it also blocks a trusted single-operator agent from
its MCP tools — forcing tool-using agents onto the metered -p pool after 2026-06-15.
This gate resolves that for the single-user case.

Safe to gate ON only because TUI is hard-incompatible with AUTH_MODE=multi (server.mjs
refuses to boot, see existing guard), so this can NEVER widen a guest's tool surface.
Default (gate unset) is unchanged: MCP wall + built-in tools only.

Verified live on PI231 through the real OCP TUI path (spike 2026-06-02):
- built-in tools: claude created + read a file on the host (2 tool_use entries);
- MCP: claude invoked a custom MCP server's tool (mcp__spike__echo_marker) and returned
  its output;
- billing: entrypoint=cli (subscription pool) WITH full tools + MCP;
- completion: end_turn, no permission stall, no hang.

Tests: +1 (full-tools branch: --allowedTools present + MCP wall dropped; skip-permissions
supersedes; mcp-config threaded). 195 pass. README: new OCP_TUI_FULL_TOOLS env var entry.

ALIGNMENT.md: changes lib/tui/session.mjs, not server.mjs — server.mjs hard requirements
do not trigger. Mirrors buildCliArgs() permissions logic; the flags are documented Claude
Code CLI flags, not invented endpoints. cli.js citation N/A under Rule 2. ADR 0007 (A-path).

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

* fix(tui): shq operator tool tokens in full-tools shell string (reviewer Finding 2)

Independent reviewer (APPROVE WITH CHANGES) caught that buildTuiCmd returns a SHELL
STRING (run by tmux via sh -c), unlike buildCliArgs which returns an argv array to
spawn(). Operator-supplied CLAUDE_ALLOWED_TOOLS can be a scoped specifier such as
"Bash(npm run test:*)" or "Read(~/**)", whose ( ) * ~ would break / inject the shell
command if pasted bare. Now shq() each allowed-tool token (operator-self-injection only —
guests cannot reach TUI per the multi-mode boot guard, but it's a real correctness bug).

Also: tightened the README OCP_TUI_FULL_TOOLS wording per reviewer Finding 1 — the precise
safety property is "no guest key can reach the TUI path (multi-mode boot is a hard exit)",
and noted the AUTH_MODE=shared + OCP_TUI_ALLOW_LAN trust model is unchanged.

Test: +scoped-specifier assertions (token shq'd; never appears unquoted). 195 pass.

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

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 11:03:20 +10:00
0dced52215 chore(release): v3.19.0 — TUI large-prompt reliability (#130) + proxy-purity context (#4) (#134)
Release prep only: bump package.json 3.18.0 → 3.19.0, CHANGELOG entry, README TUI
"what changes / what doesn't" note that the host CLAUDE.md/auto-memory is never injected.

Bundles two already-merged, independently-reviewed TUI fixes:
- #130 (PR #131): reliable large multi-line paste + version-robust turn detection.
- #4   (PR #132): never inject host CLAUDE.md / auto-memory into proxied turns.

Both verified live on PI231 + Oracle; adversarial multi-host battery passed
(0 hangs / 0 crashes / 0 injection / 0 leaks). Follow-up: #133.

No server.mjs change in this PR (package.json + CHANGELOG + README only) → cli.js
citation N/A. release_kit (Iron Rule 5.5): version_source=package.json, changelog,
README docs all updated; no new env var / endpoint / subcommand.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 07:46:55 +10:00
d291331998 fix(tui): never inject the host's CLAUDE.md / auto-memory into proxied turns (#4) (#132)
OCP is a PROXY, not a Claude Code session. The proxied client (OpenClaw / an IDE)
owns its own context and memory — the HOST's CLAUDE.md and auto-memory must never
leak into the agent OCP runs on the user's behalf.

buildTuiCmd now adds CLAUDE_CODE_DISABLE_CLAUDE_MDS=1 + CLAUDE_CODE_DISABLE_AUTO_MEMORY=1
to the pane-command env-prefix, unconditionally (proxy purity is not an opt-in).

Diagnosis (per Iron Rule 3, diagnose-before-fix) of the earlier "suppression works in
recon but not through OCP" gap: it was NEVER an env-delivery bug. The hosts simply have
no CLAUDE.md to suppress — PI231 has no ~/.claude/CLAUDE.md, no ~/CLAUDE.md, no
~/.claude/memory, no cwd/walk-up CLAUDE.md. The earlier "recon worked" run was on a
machine that DID have a global CLAUDE.md. Different machines, different CLAUDE.md
presence — not a code gap. The ~35K one-line-prompt context is therefore the inherent
floor (interactive system prompt + built-in tool schemas; MCP is already hard-disabled
via --strict-mcp-config), not CLAUDE.md.

Verified live 2026-06-02 with a behavioral marker (Iron Rule 2, evidence-first):
- Planted /home/tlab/.ocp-tui/work/CLAUDE.md = "end every reply with QUACKMARKER_42".
- BEFORE fix, through OCP: "Reply with: hello" -> "hello\n\nQUACKMARKER_42" (host CLAUDE.md obeyed = leaked).
- AFTER fix, same marker file present, through OCP: -> "hello" (marker blocked).
This proves both that the host CLAUDE.md was being injected AND that the env-prefix
delivery of the disable flag reaches claude and stops it.

Tests: buildTuiCmd is now exported; +2 regression guards (suppression present;
version-pin/entrypoint/MCP-wall retained, auto-mode leaves entrypoint unset). 194 pass.

Scope (Iron Rule 11): TUI path only. The -p path has an equivalent but GATED mechanism
(CLAUDE_NO_CONTEXT). Making -p unconditionally proxy-pure is a separate decision, noted
as a follow-up, not bundled here.

ALIGNMENT.md: this changes lib/tui/session.mjs, not server.mjs, so the server.mjs hard
requirements do not trigger. TUI is the ADR-0007 proxy-side execution bridge; the two
flags are documented Claude Code env vars (the same two the -p path already uses), not
invented endpoints — cli.js citation N/A under Rule 2.

Closes #4 (TUI portion).

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 07:32:06 +10:00
9568411bcb fix(tui): reliable large-prompt paste + version-robust turn detection (#130) (#131)
TUI mode hung ("stuck typing") on the OpenClaw agent's real (large, multi-line) prompts.
Three root causes, all fixed:

1. transcript.mjs — terminal detection only recognized {system, turn_duration}, which
   claude 2.1.114 (the cloud host) does not emit → readTuiTranscript never saw completion
   and ran to the 120s wallclock even though claude had answered. Now also treats an
   {assistant} line with message.stop_reason ∈ {end_turn, stop_sequence, max_tokens} as
   terminal (version-robust; stop_reason "tool_use" stays non-terminal, so tool turns are
   not truncated — preserves the v3.17.1 narrowing).

2. session.mjs paste — send-keys -l "$(cat file)" delivers a large multi-line prompt's
   newlines as separate key events (≈repeated Enter), so the prompt never lands. Replaced
   with tmux load-buffer + paste-buffer -p (bracketed paste): atomic, no shell arg limit,
   claude ingests it as one "[Pasted text]".

3. session.mjs verify — the old "placeholder-gone" heuristic false-positived on claude's
   empty curly-quote placeholder, so Enter fired into an empty box (THE root cause of the
   hang). tuiPromptLanded now trusts only positive signals ([Pasted text] indicator or the
   prompt's own text); readiness-poll + paste-verify-poll + fast-fail (tui_paste_not_landed)
   turns a 120s wallclock hang into a deterministic ~5s error.

Also: env delivered to the pane via an `env`-prefix on the pane command (tmux does NOT
forward spawnSync's env, and new-session -e needs tmux ≥3.2 while the cloud host runs 2.7),
carrying DISABLE_AUTOUPDATER (version pin) + CLAUDE_CODE_ENTRYPOINT labeling.

Validated live on both hosts: a 300-line / ~30KB prompt that previously hung now returns
in ~5-8s; small prompts ~4-5s. 192 tests pass (new: terminal-detection across schemas,
positive-signal verify incl. the curly-quote false-positive guard, paste predicates).

Scope discipline: an attempt to also strip claude's CLAUDE.md/auto-memory injection (context
reduction) was REVERTED — it didn't measurably reduce context through OCP, caused an
off-response, and is a separate concern. It will be its own measured branch.

ALIGNMENT.md: TUI is the ADR-0007 proxy-side execution bridge, not a forwarded Anthropic
operation, so a cli.js citation is N/A under Rule 2. No blacklisted tokens / port literals.

Independent fresh-context reviewer (opus): APPROVE WITH CHANGES (Iron Rule 10) — validated
the three fixes, caught that a per-session tmux socket would break the startup stale-session
reaper, and flagged the context-suppression scope-creep; both were reverted per the review.

Closes #130.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 21:26:25 +10:00
1f577c075f chore(release): v3.18.0 — code-audit hardening (P0 /health + P2/P3 batch) + 3 follow-ups (#129)
Bundles the 2026-05-31 multi-agent code audit remediation and its follow-ups:

- #109 (P0): /health no longer leaks the anonymous quota key to LAN (opt-in PROXY_ADVERTISE_ANON_KEY)
- #110: request-validation + OpenAI-compat correctness
- #111: error-output sanitization + process-lifecycle hardening
- #112: OAuth-host verification + models.json SPOT
- #113: CLI/installer hardening
- #114: dashboard XSS escaping + key-name validation
- #115: TUI non-loopback LAN gate + cc_entrypoint assertion
- #123: alignment.yml wrong-host pin + ALIGNMENT.md amendment
- #124: dashboard status/plan card escaping
- #125: isLoopbackBind extracted to lib/net.mjs

Each landed as its own PR with a fresh-context independent reviewer (Iron Rule 10) and
green alignment CI. Version bumped 3.17.1 → 3.18.0; CHANGELOG finalized. 181 tests pass.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 08:47:10 +10:00
6dff36959a chore(governance): pin legacy OAuth host in alignment.yml + ALIGNMENT.md amendment (#123) (#128)
Follow-up to the 2026-05-31 audit (deferred from #112). The OAuth token host
platform.claude.com/v1/oauth/token was verified against the compiled cli.js in #119;
this pins the legacy WRONG host so a future accidental revert hard-fails CI.

- .github/workflows/alignment.yml: add "console.anthropic.com/v1/oauth/token" to the
  BLACKLIST; rewrite the comment + failure message so the blacklist now documents TWO
  kinds of token — known hallucinations AND pinned wrong-host variants of a verified
  Class A endpoint (a hit means a drift, not necessarily a hallucination). The pinned
  token is absent from server.mjs (which uses platform.claude.com), so CI stays green.
- ALIGNMENT.md: new "OAuth token-host verification (2026-05-31)" subsection recording the
  binary verification (claude.exe 2.1.154, strings, no live probe) and the dual-purpose
  blacklist policy. Purely additive; Rules / audit pin / Historical Lesson untouched.

Per ALIGNMENT.md Amendment Procedure: (a) motivating evidence cited (issues #112/#119/#123),
(b) independent fresh-context opus reviewer APPROVE — verified the pinned token does not
trip the build (absent from server.mjs; live host not blacklisted), YAML valid, amendment
consistent with the server.mjs verification comment, purely additive scope. (c) not
incident-driven (a confirming verification, not a new drift) so Historical Lesson unchanged.

Closes #123.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 08:43:36 +10:00
1b02f181fa refactor: extract isLoopbackBind to lib/net.mjs (#125) (#127)
Follow-up cleanup from #115's review. isLoopbackBind was defined in server.mjs and
copy-pasted into test-features.mjs (with a "keep in sync" comment, because importing
server.mjs would run server.listen()). Extracted to a new importable lib/net.mjs;
server.mjs and the test now import the one definition, removing the drift surface.

Pure refactor — the function body is byte-identical to the prior server.mjs version,
the TUI LAN-gate call site is unchanged, and the 8 isLoopbackBind truth-table tests now
exercise the real shared function. 181 tests pass.

ALIGNMENT.md: touches server.mjs but adds/removes no operation — it relocates an existing
(#115) helper into a module. cli.js citation N/A under Rule 2.

Independent fresh-context reviewer (opus): APPROVE (Iron Rule 10) — byte-identical body,
exactly one definition, wiring + scope correct, no test dropped, scope clean.

Closes #125.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 07:03:55 +10:00
0000926358 fix: escape dashboard status/plan cards (#124) (#126)
Follow-up defense-in-depth from #114's review. The status-cards and plan-cards in
dashboard.html rendered string values into innerHTML without escaping. These are
trusted-but-external (p.version/p.uptime are server-local; s.percent/s.resetsIn/
w.percent/w.resetsIn come from Anthropic's upstream plan API), so not the stored-XSS
vector #114 fixed — but wrapping them in the existing escapeHtml() helper gives uniform
defense-in-depth across all innerHTML sinks. Numeric/computed fields (request counts,
sPct/wPct, barColor) are left unescaped (not injectable).

dashboard.html is a client-side static asset, not server.mjs → cli.js citation N/A.

Independent fresh-context reviewer (opus): APPROVE (Iron Rule 10) — confirmed every
string sink wrapped, numbers correctly untouched, escapeHtml in scope, template literals
intact, 181 tests pass (no regression).

Closes #124.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 06:59:15 +10:00
aa1c65beb1 fix: TUI hardening — non-loopback LAN gate + assert cc_entrypoint (#115) (#122)
Two TUI-mode findings from the 2026-05-31 audit (ADR 0007):

1. The fail-loud LAN gate refused TUI boot only when CLAUDE_BIND === "0.0.0.0",
   but binding to a concrete LAN IP, a Tailscale 100.x address, or IPv6 :: is
   equally network-exposed and slipped through — letting any reachable peer drive
   the operator's full-filesystem claude session. New isLoopbackBind() treats only
   127.0.0.0/8 / ::1 / localhost / ::ffff:127.0.0.1 as safe; the gate now trips on
   any non-loopback bind (OCP_TUI_ALLOW_LAN=1 escape hatch retained).

2. verifyEntrypoint() was implemented and unit-tested but never wired: readTuiTranscript
   discarded the events and returned only text, so a silent degrade to the metered
   sdk-cli (Agent SDK) billing pool — which still returns text but costs money — went
   undetected. readTuiTranscript now returns { text, entrypoint }; runTuiTurn passes it
   through; callClaudeTui logs a "tui_entrypoint_mismatch" warning when configured cli
   mode yields a non-cli entrypoint (including null/unverified). callClaudeTui still
   returns Promise<string>, so singleflight/cache/completion downstream is unchanged.

ALIGNMENT.md: TUI is a proxy-side execution-mode bridge (ADR 0007); this adds a local
bind classifier (startup gate) + a non-fatal log assertion of an existing classification
— no Anthropic operation forwarded, so a cli.js citation is N/A under Rule 2. No
blacklisted tokens or port literals introduced; alignment.yml passes.

Independent fresh-context reviewer (opus): APPROVE WITH MINOR (Iron Rule 10) — verified
isLoopbackBind via truth table (no exposed address misclassified as loopback — the
dangerous direction), the full string→{text,entrypoint} contract ripple across all
callers (Promise<string> preserved), the mismatch logic (warns on sdk-cli AND null in
cli mode, silent for auto/off, non-fatal), and ALIGNMENT N/A via a fetch/header grep.
Minors are process-only (this commit body; a future lib/ extraction of the test-mirrored
helper). npm test → 181 passed, 0 failed.

Closes #115.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 23:13:47 +10:00
879b40fe93 fix: escape dashboard DB-sourced values + validate key names (#114) (#121)
The dashboard built table rows via template-literal innerHTML, interpolating
DB-sourced strings (key names, usage rows) with no HTML escaping, and an
onclick="revokeKeyUI('${k.name}')" sink a single quote could break out of. Key
names were unvalidated at creation. Admin-gated (self-XSS today), but a real
unescaped-sink gap that becomes cross-user if key creation is ever delegated.

- dashboard.html: added escapeHtml() and applied it to every DB/string-sourced
  interpolation in refreshUsage and refreshKeys (key_name, name, keyPreview,
  created_at, last_request, model). Replaced the inline-onclick revoke button with
  a data-revoke attribute + addEventListener, so a name can never break out into an
  event-handler string. (model is attacker-pickable via the request body, so its
  escaping is the load-bearing one.)
- server.mjs: POST /api/keys now rejects names not matching /^[A-Za-z0-9 ._-]{1,64}$/
  before createKey() — defense-in-depth so a <script>/quote name can never reach the
  DB. Creation-only; existing keys unaffected; the default key-${Date.now()} passes.

Noted (out of scope, optional follow-up): the status/plan summary cards render
trusted server/Anthropic-upstream values unescaped — non-user-controlled, so not part
of this stored-XSS fix.

ALIGNMENT.md: the server.mjs change is proxy-policy input validation with no Anthropic
operation forwarded, so a cli.js citation is N/A under Rule 2. No blacklisted tokens or
port literals introduced; alignment.yml passes.

Independent fresh-context reviewer (opus): APPROVE WITH MINOR (Iron Rule 10) — verified
escapeHtml correctness, full sink coverage (incl. the attacker-pickable model field and
the data-revoke attribute), revoke round-trip via getAttribute, the anchored/bounded
key-name regex running before createKey, and that createKey has no other unvalidated
caller. Both minors non-blocking (trusted status-card escaping; this commit-body note).

Closes #114.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 23:03:50 +10:00
68d58e7df4 fix: CLI/installer hardening — restart labels, key permissions, unit-secret escaping (#113) (#120)
Three CLI/installer findings from the 2026-05-31 audit (no server.mjs):

1. ocp-plugin `cmdRestart` hardcoded uid 501 + the legacy `ai.openclaw.proxy`
   label, and fell through to a dangerous `pkill -f 'node.*server.mjs' && cd
   ~/.openclaw/projects/*/; node server.mjs &` that could kill an unrelated node
   process and relaunch an env-less ghost proxy from a glob-ambiguous dir. Now uses
   process.getuid() + the live labels (dev.ocp.proxy on macOS, ocp-proxy on Linux
   systemd; the OpenClaw gateway label is unchanged) and drops the pkill fallback
   entirely (returns a manual `ocp restart` message on failure).

2. ocp-connect wrote the quota key unquoted into rc files and a world-readable
   environment.d/ocp.conf. Now single-quotes the value and chmod 600s the rc files
   and ocp.conf (matching the existing auth-profiles.json 0o600 convention).

3. setup.mjs interpolated the injected service-unit secrets (CLAUDE_BIN,
   OCP_ADMIN_KEY, PROXY_ANONYMOUS_KEY) raw into plist <string> and systemd
   Environment= lines. Added xmlEscape() for all plist <string> values and
   assertSafeInjectValue() which rejects control characters (\x00-\x1f — newline,
   CR, tab) before any unit is written, blocking a newline-injected rogue
   Environment= directive. Spaces are intentionally allowed (CLAUDE_BIN paths may
   contain them). XML-escaping on write also resolves plist-merge.mjs's [^<]* regex
   concern transitively (no raw < reaches it) — comment added, logic unchanged.

ALIGNMENT.md: CLI/installer scripts only, no Anthropic operation forwarded → cli.js
citation N/A. No blacklisted tokens or port literals introduced; alignment.yml passes.

Independent fresh-context reviewer (opus): APPROVE (Iron Rule 10) — verified the
control-char regex byte-exact via od (no space-rejection regression), the pkill
fallback fully removed, restart labels match setup.mjs ground truth, OCP keys
(base64url) cannot break the single-quoting, and the validator runs before any write.

Closes #113.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:56:47 +10:00
4a7d79c330 fix: record OAuth-host verification + models.json SPOT for usage-probe/default (#112) (#119)
Three alignment/SPOT findings from the 2026-05-31 audit:

1. The OAuth token-refresh host (platform.claude.com/v1/oauth/token, a Class A
   surface) was introduced in the 2026-04-11 drift commit and had no verification
   record. Verified against the compiled cli.js (claude.exe v2.1.154) via `strings`:
   OAUTH_TOKEN_URL and OAUTH_CLIENT_ID both appear in the binary byte-for-byte (in
   the same `prod` config object), and the legacy host console.anthropic.com/v1/oauth
   is absent (0 hits). Recorded this as an inline ALIGNMENT citation comment above the
   constants. No live OAuth probe was run — a refresh-token grant would rotate the
   operator's real credentials; the strings-on-binary evidence is decisive.
   (cli.js: verified against compiled claude.exe v2.1.154, 2026-05-31.)

2. fetchUsageFromApi() hardcoded the haiku model ID; now derives from
   modelsConfig.aliases.haiku (ADR 0003 SPOT). Prevents a silent /usage break on a
   future haiku ID bump.

3. [P3] The default request model hardcoded the sonnet ID; now derives from
   modelsConfig.aliases.sonnet (ADR 0003 SPOT).

Both SPOT values are byte-identical to the literals they replace today, so zero
behavior change — only drift-resistance. The alignment.yml blacklist pin for the
wrong-host variant was deliberately NOT included here: extending the blacklist is a
governance-layer change (alignment.yml inline policy) and belongs in its own PR.

ALIGNMENT.md: finding 1 IS the verification (cli.js citation recorded inline);
findings 2-3 are SPOT hygiene that forward no new operation. No blacklisted tokens or
port literals introduced; alignment.yml passes.

Independent fresh-context reviewer (opus) INDEPENDENTLY re-ran `strings` on the binary
and confirmed the host/client_id present and the legacy host absent — APPROVE (Iron
Rule 10; alignment hard-requirement #3 satisfied).

Closes #112.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:44:15 +10:00
c3b1f32c86 fix: error-output sanitization + process-lifecycle hardening (#111) (#118)
Four findings from the 2026-05-31 audit (3×P2 + 1×P3), all in the error/process
lifecycle layer:

1. sanitizeError() helper — streaming error paths sent raw claude error_message /
   stderr to the client, leaking home-dir / credential-file paths that the
   non-streaming path already redacted. Factored the path-strip regex into one
   helper and applied it at all 9 client-facing jsonResponse/sendSSE error emits;
   de-duped the 3 pre-existing inline .replace() sites. Operator-log calls
   (logEvent/trackError) and admin-gated endpoints left raw by design.

2. res.on("close") SIGKILL escalation — a client disconnect sent only SIGTERM; a
   SIGTERM-resistant child held its concurrency slot until the request timeout
   (narrow #37 on the hottest exit path). Now escalates to SIGKILL 5s after SIGTERM,
   cleared on proc exit. Per review: gated on the child still being alive
   (exitCode===null && signalCode===null) so the normal-success close no longer
   fires a spurious SIGTERM or leaks the 5s timer; killTimer.unref() so a genuine
   disconnect timer never delays graceful shutdown.

3. Per-key quota TOCTOU — documented as best-effort/eventually-consistent (inline
   comment + README note): concurrent requests at the boundary can overshoot by up
   to MAX_CONCURRENT and cache hits are uncounted. Chose documentation over an
   in-flight counter to avoid a decrement-on-all-paths liability (the #37 class) on
   a low-blast-radius internal family rate-limiter — not a payment boundary.

4. [P3] overallTimer cleared on semantic completion — the request timer was cleared
   only on proc exit, so a streamed response that res.end()'d before the child
   exited could record a spurious post-success timeout. New clearOverallTimer()
   (clears the timer ONLY, never touches the `cleaned` slot-accounting flag — no
   slot leak) is called in the streaming stop-success path; cleanup() on exit still
   clears it idempotently and decrements the slot.

ALIGNMENT.md: error-shaping / process-lifecycle / rate-limit documentation forward
no Anthropic operation, so a cli.js citation is N/A under Rule 2. No blacklisted
tokens or port literals introduced; alignment.yml passes.

Independent fresh-context reviewer (opus): APPROVE WITH MINOR (Iron Rule 10) — the
two critical concerns (clearOverallTimer slot-leak class, SIGKILL double-kill) were
verified clean; MINOR #1 (kill-timer leak on success path) folded in; MINOR #2
(pre-existing regex over-redaction of ratios/URLs) left as out-of-scope.

Closes #111.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:34:19 +10:00
4458490caa fix: request validation + OpenAI-compat correctness (#110) (#117)
Three correctness/compat defects on the request path:

1. Non-array `messages` (e.g. {"messages":"x"}) passed the `!messages?.length`
   guard but threw in `messages.reduce(...)`; since the handler runs without
   await, the rejection silently hung the client until socket timeout. Now
   guarded with `Array.isArray(messages) && length>0` → 400 invalid_request_error,
   placed before the first use of `messages`.

2. OpenAI array `content` ([{type:"text",text},{type:"image_url",...}]) was
   JSON.stringify'd into the prompt as literal noise. New `contentToText()` helper
   flattens text parts and replaces non-text parts with a placeholder; used in
   messagesToPrompt, extractSystemPrompt, and all promptChars char-count sites
   (zero `JSON.stringify(m.content)` remain).

3. A streamed upstream error arriving after eager SSE headers emitted a bare
   finish_reason:"stop" + [DONE] — byte-identical to a successful empty completion.
   Both streaming error paths (the parsed.error result branch AND the non-zero-exit
   close-handler branch — the latter folded in per reviewer) now emit an SSE
   `data:{"error":{...}}` frame so clients can distinguish failure from empty.

Error-text sanitization across all emit sites is intentionally deferred to #110's
sibling issue #111 (security layer).

ALIGNMENT.md: these are OpenAI-compat shim + input-validation behaviors; OCP does
not forward, add, or alter any Anthropic API operation here (cli.js does not speak
the OpenAI wire format), so a cli.js citation is N/A under Rule 2. No blacklisted
tokens or port literals introduced; alignment.yml passes.

Independent fresh-context reviewer (opus): APPROVE WITH MINOR (Iron Rule 10) —
the close-handler sibling fold-in addresses MINOR #1; this commit body addresses
MINOR #2.

Closes #110.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:22:41 +10:00
36be723198 fix(security): gate /health anonymousKey behind opt-in PROXY_ADVERTISE_ANON_KEY (#109) (#116)
/health is an unauthenticated, LAN-reachable endpoint that returned the live
anonymous bearer key (anonymousKey: PROXY_ANONYMOUS_KEY). Any device that could
reach the port harvested a working, quota-spending credential (P0).

The anonymousKey field is now included only when the caller is localhost (already
fully trusted by the auth path, via the unspoofable req.socket.remoteAddress) OR
the admin explicitly opts in with the new PROXY_ADVERTISE_ANON_KEY=1 env var
(default off). ocp-connect's absent-field fallback (interactive --key / anonymous
access) is unchanged — comment-only update there.

ALIGNMENT.md: this gates an OCP proxy-policy field in /health; it does NOT forward,
add, or alter any Anthropic API operation, so a cli.js citation is N/A under Rule 2.
No blacklisted tokens introduced; alignment.yml passes.

Independent fresh-context reviewer (opus): APPROVE WITH MINOR (Iron Rule 10).

Closes #109.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:12:26 +10:00
7b065600aa docs(readme): honest multi-user/security positioning + 6/15 framing + OLP cross-link (#108)
- Deployment model & security section: single-user multi-IDE is the supported
  model; shared/multi keys give usage/quota tracking but NOT a security isolation
  boundary (claude runs with operator FS, not sandboxed) — trusted users only;
  real per-user sandboxed isolation is planned post-2026-06-15.
- Soften the multi-mode 'recommended' label accordingly.
- Add a 'Related: OLP' section linking the multi-provider sibling project.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 15:44:25 +10:00
1b5a742711 chore(release): v3.17.1 — code-audit P1/P2 hardening (crash fixes + multi-tenant gates) (#107)
Co-authored-by: dtzp555 <dtzp555@gmail.com>
2026-05-31 13:19:10 +10:00
05a984df89 fix: OCP code-audit P1+P2 hardening (crash bugs + multi-tenant gates) (#106)
* fix(daemon): P1-1 guard proc.stdin against EPIPE crash

In spawnClaudeProcess, attach an error listener on proc.stdin BEFORE
the write/end calls so an EPIPE (child closed stdin mid-write) is
swallowed and logged rather than thrown as an unhandled exception.

The existing proc.on("error") listener is on the ChildProcess object,
NOT on the stdin Writable — it does not catch stdin write errors.

Hardening per OCP code audit; entry-surface contract unchanged for
single-user default path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(tui): P1-3 remove tool_use from isTerminalLine; only turn_duration is terminal

In interactive TUI mode, stop_reason=tool_use does NOT mean the turn is
complete. Claude handles the tool call internally and continues generating —
the transcript advances to another assistant entry. Treating tool_use as
terminal truncated tool-using turns mid-flight.

Only {type:"system", subtype:"turn_duration"} is the authoritative
completion marker (claude CLI v2.1.157+ interactive session transcript).

Updated two unit tests that previously asserted tool_use → true; they now
assert false (the correct behaviour). The real-fixture terminal detection
test is unaffected because the fixture uses turn_duration.

Hardening per OCP code audit; TUI path behaviour fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(tui): P2-5 add -l (literal) flag to send-keys prompt paste

A prompt that equals a tmux key token (e.g. "C-c", "Escape") would be
interpreted as that key binding rather than typed as literal text.

The -l flag forces literal character-by-character input. The separate
Enter key event afterward deliberately omits -l so tmux sends a real
carriage-return keypress to submit the prompt line.

Authority: tmux send-keys(1) § -l flag. Hardening per OCP code audit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(audit): String-coerce parsed.error + stale tool_use comment (review fast-follow)

Folds the 2 minor findings from the independent review of the audit fixes:
- String(parsed.error) before .slice/message in callClaude + callClaudeStreaming
  (defensive: claude could emit a non-string result/error_message).
- correct the readTuiTranscript comment that still listed tool_use as terminal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 13:17:36 +10:00
a30b20978c chore(release): v3.17.0 — Phase 6c stream-json default + opus 4.8 + opt-in TUI-mode (#105)
- Phase 6c: default claude spawn → stream-json + --system-prompt (~64% cost cut)
- opus 4.8 model
- opt-in CLAUDE_TUI_MODE interactive subscription-pool bridge (single-user)

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 09:33:27 +10:00
cd98b51b96 docs(plans): add anthropic-only sandbox strategy handoff (#102)
Forward-looking planning doc capturing prior-art analysis from OLP's
Phase 7 PR-B re-evaluation, scoped down to OCP's single-provider
(anthropic) deployment.

Documents:
- Multi-tenant gap for OCP (cross-key lateral read, OAuth exposure)
- Why OLP's outer-bwrap PR-B approach is the wrong path to copy
  (Anthropic design intent, ~/.claude.json upstream not-planned)
- Three viable alternatives:
  A. Ephemeral $HOME via env var (~50 LOC, recommended Phase 1)
  B. bwrap --tmpfs $HOME + ro-bind credentials (apt dep, Linux only)
  C. OverlayFS lowerdir+upperdir (needs CAP_SYS_ADMIN)
- Orthogonal cross-key isolation layer (per-spawn customConfig denyRead
  or per-OS-user spawning)
- Trust-tier framing (single-user / family-zone / shared-host)

Not an ADR — becomes one only when work actually starts. Not binding;
ALIGNMENT.md authority requirements still apply when sandbox code lands.

Cross-references OLP's parallel multi-provider work at
dtzp555-max/olp ADR 0014 Amendment 1 (pending) and archive branch
phase-7-pr-b-outer-bwrap-snapshot.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 09:21:40 +10:00
74260d7f6f feat(tui): opt-in CLAUDE_TUI_MODE — interactive subscription-pool bridge (PR-1..PR-4 squashed) (#104)
Reader + driver + home modes + server wiring + entrypoint hardening. 5 independent
reviews folded; e2e green; default stream-json path byte-identical when flag off.
See PR description + ADR 0007 for the full layer/authority/security detail.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 09:21:11 +10:00
885f62addf feat: Phase 6c — claude stream-json port + --system-prompt + opus 4.8 (#103)
* wip(rescue): OCP Phase 6c stream-json port + opus 4.8 (uncommitted /tmp work)

Rescue commit — preserves the subagent-produced Phase 6c port (claude -p →
stream-json + --system-prompt + NDJSON parser) and opus 4.8 model addition
done 2026-05-30 in a /tmp clone (volatile). NOT reviewed-for-merge; this is
preservation only on a WIP branch so a /tmp reboot does not lose the work.

Strategy context: OCP is being made the first-mover for TUI-mode (users are
on OCP). Phase 6c here is the DEFAULT-path foundation (stream-json = sdk-cli =
the "Max-user stable mode" fallback). TUI-mode (cc_entrypoint=cli subscription
bridge) lands on top as an opt-in. Re-review before merging to main.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(phase6c): fold review findings — scrub hostname, README prose, parser tests, version citation

P3-1 (binding): scrub private hostname "PI231" from server.mjs comment; replace
with role-based term "the test server". Repo is public — no machine-specific
hostnames may appear.

P3-2: update README.md "How It Works" diagram and prose from stale "claude -p"
to "claude --output-format stream-json", matching the Phase 6c spawn change already
in server.mjs and CHANGELOG.

P2-2: update all five authority citations in server.mjs from bare "claude CLI v2.1.104"
to "claude CLI § <flag> (ported from OLP, verified v2.1.104; behavior stable through
v2.1.158)" — honest about OLP provenance and version range, without inventing new facts.

P2-1: add "Stream-JSON parsers" suite to test-features.mjs (17 new tests, 84 → 101
total). Copies parseStreamJsonLines and parseStreamJsonEvent verbatim from server.mjs
with a MIRRORS header comment; logEvent stubbed to avoid live-server side-effects.
Covers: content_block_delta deltas, assistant-aggregate fallback, no-double-count guard,
aggregate-only short responses, partial-line buffering across two chunks, is_error result
variants, malformed/non-JSON lines, system/user/unknown event types.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 09:13:36 +10:00
1dd6fb9440 docs(governance): add ADR 0006 OpenAI shim scope + Class A/B taxonomy (#100)
Introduces an explicit two-class taxonomy of OCP endpoints to resolve
the structural ambiguity surfaced by PR #99 (external response_format
honoring on /v1/chat/completions):

- Class A: cli.js-mirror endpoints. Rules 1-5 of ALIGNMENT.md apply
  verbatim. Citation requirement unchanged (cli.js:NNNN). The 2026-04-11
  drift discipline is preserved without weakening.

- Class B: OCP-owned compatibility endpoints. Anchored to OpenAI's
  /v1/chat/completions specification (B.1) or to an authorizing ADR
  (B.2). Citation shifts to spec section + ADR number. Class B inherits
  the same anti-invention discipline; the anchor differs.

Grandfather provision in ADR 0006 retroactively authorizes the 12
existing B.2 administrative endpoints at v3.16.4 behaviour (one-time,
contract-frozen). New B.2 endpoints or any new method on a grandfathered
endpoint requires its own ADR per Rule 4 (Class B mapping).

Changes:
- new: docs/adr/0006-openai-shim-scope.md
- new: docs/openai-compat-pin.md (placeholder for first B.1 audit)
- mod: ALIGNMENT.md (Class B section, rule mapping table, updated
  Unalignable Policy and Annual Audit scope; Rules 1-5 byte-identical)
- mod: .github/PULL_REQUEST_TEMPLATE.md (Endpoint Class radio, separate
  evidence sections for A vs B, reviewer checklist updated)
- mod: docs/adr/README.md (index entry for ADR 0006)

Independent reviewer (fresh-context opus per Iron Rule 10) verified:
all 12 load-bearing checks pass — Rules 1-5 byte-identical to
origin/main, 2026-04-11 drift narrative unchanged, explicit
non-relitigation safeguard present in ADR 0006, grandfather provision
narrowly scoped, Class B inventory matches server.mjs reality (14/14),
single governance layer per Iron Rule 11 (no server.mjs touch),
alignment.yml unmodified. Verdict: APPROVE_WITH_MINOR with 3 of 5
non-blocking suggestions folded in (operations-vs-endpoints precision,
PR template Hybrid wording, openai-compat-pin.md stub).

Triggering incident: PR #99 by external contributor (response_format
honoring on /v1/chat/completions). This governance PR ships separately
per Iron Rule 11 (IDR); the feature PR #99 will rebase on this and
declare Class B with ADR 0006 citation.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 21:01:10 +10:00
9e25160527 refactor: hoist port literal to lib/constants.mjs + CI gate (v3.16.4) (#98)
Closes the structural side of the port-drift cascade addressed by
v3.16.2/v3.16.3. Those releases reverted the literal line-by-line; this
one removes the invitation to drift.

Changes:
  * NEW lib/constants.mjs — exports DEFAULT_PORT=3456, LOCAL_HOST,
    OPENAI_API_BASE, LOCAL_PROXY_URL.
  * server.mjs / setup.mjs / scripts/upgrade.mjs / scripts/doctor.mjs
    (x2) / scripts/sync-openclaw.mjs all import DEFAULT_PORT from
    lib/constants.mjs instead of hardcoding "3456".
  * .github/workflows/alignment.yml:
    - path filter extended to setup.mjs, scripts/**, lib/**,
      ocp, ocp-connect.
    - NEW job port-spot hard-fails any PR that introduces a hardcoded
      "3478" or "3456" literal outside EXEMPT_REGEX (lib/constants.mjs,
      test-features.mjs, ocp/ocp-connect bash CLIs, docs, the workflow
      itself).
  * Doc-comment rewording so CI grep finds zero hits.

No behavior change for any user. CLAUDE_PROXY_PORT env var still wins
at runtime; only the unset-env fallback now flows through one constant.

ALIGNMENT.md note: server.mjs change is one import + one literal swap,
mechanical. No cli.js operation changed; the citation requirement does
not apply.

cli.js: not applicable — mechanical refactor, no behavior change.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
2026-05-13 06:42:15 +10:00
49c6d32e3b fix(scripts): default CLAUDE_PROXY_PORT to 3456 (completes v3.16.2 revert) (#97)
Three places in scripts/ still defaulted to 3478 after v3.16.2's
plugin / manifest / README / plist revert:

  scripts/upgrade.mjs:137
  scripts/doctor.mjs:84
  scripts/doctor.mjs:205

These were the residual cascade source for the port-drift bug originally
caused by the PR #71 dogfood accident on 2026-05-08 (see
~/.cc-rules/memory/learnings/subagent_setup_mjs_prod_host_collision.md
and v3.16.2 CHANGELOG entry).

Every `ocp doctor` / `ocp upgrade` invocation without an explicit
`CLAUDE_PROXY_PORT` in env probed port 3478 — got "OCP not responding"
against a healthy 3456 instance — and on the maintainer's host this
cascaded into wrong baseUrl writes for the OpenClaw `claude-local`
provider, taking out the OpenClaw Telegram agent ("大内总管") on
2026-05-13.

This change is the smallest possible: three string literals
`"3478"` → `"3456"`, aligning unset-env defaults with `server.mjs:126`.
Env-set users are unaffected (env precedence is unchanged).

cli.js: not applicable — this is a scripts/ change, not server.mjs.
ALIGNMENT.md hard-requirements (cli.js citation, blacklist CI,
independent reviewer) target server.mjs; this PR honors the SPOT
spirit by ending the literal-port drift across the codebase.

Bumps to v3.16.3. CHANGELOG entry added.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
2026-05-13 06:14:37 +10:00
7766fa0868 fix(plugin): revert default to 3456 + correct v3.16.1 narrative; v3.16.2 (#96)
v3.16.1's narrative ("OCP server moved to 3478 default in v3.14+") was
incorrect. OCP source default has been 3456 since 593d0dc (initial
release) and never changed. The single observation of 3478 is the
maintainer's Mac mini, whose plist was rewritten with --port 3478
during a 2026-05-08 PR #71 dogfood smoke-test accident (see
~/.cc-rules/memory/learnings/subagent_setup_mjs_prod_host_collision.md).
The drift was never reconciled and v3.16.1 mistakenly canonised the
post-accident port as the new default.

This release reverts:
- ocp-plugin/index.js fallback → http://127.0.0.1:3456
- openclaw.plugin.json configSchema.proxyUrl.default → http://127.0.0.1:3456
- README §Environment Variables CLAUDE_PROXY_PORT default → 3456
- top-level package.json → 3.16.2

PR #95's env-reading path (OCP_PROXY_URL → CLAUDE_PROXY_PORT → fallback)
is preserved — that part was good design and stays. Only the hardcoded
fallback default changes.

Hosts whose OCP plist injects a non-default port must also inject the
same CLAUDE_PROXY_PORT into the OpenClaw plist for the plugin to follow
(documented in the new index.js comment block).

Mac mini's plist was reverted from 3478 to 3456 as part of this deploy
(per-host correction; no source code reflects host-specific state).

CHANGELOG includes an explicit erratum entry under v3.16.1 marking it
superseded.

Process note: this PR was triggered by maintainer asking "why was the
port changed?" — the answer revealed I (PM) wrote v3.16.1's CHANGELOG
without running `git log -G "3478" -- setup.mjs`. Iron Rule 2
(evidence-first) was violated. Future commits asserting historical
facts must include the grep that confirmed them.

No cli.js citation needed: OCP-internal plugin + docs, no server.mjs
change.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 12:17:13 +10:00
70faeff067 fix(plugin): default OCP plugin port to 3478 + env-overridable (v3.16.1) (#95)
* fix(plugin): default OCP plugin port to 3478 + env-overridable; v3.16.1

ocp-plugin/index.js hard-coded http://127.0.0.1:3456 since the plugin
was created. OCP server moved to 3478 default in v3.14+ as part of
the same wave that renamed the launchd label (dev.ocp.proxy). The
plugin never got the memo. Result: `/ocp usage` from OpenClaw bots
(e.g. the home Telegram bot 大内总管) hit the dead port 3456 and
returned "OCP error: fetch failed".

Fix:
- Default PROXY → http://127.0.0.1:3478
- Read OCP_PROXY_URL env (full URL) first
- Else read CLAUDE_PROXY_PORT env (port only, localhost assumed)
- Else fall back to the 3478 default

openclaw.plugin.json bumped (3.12.0 → 3.16.1) and configSchema
default updated. Plugin version now matches OCP version.

Top-level package.json bumped 3.16.0 → 3.16.1. CHANGELOG entry added.

Diagnostic trail: caught 2026-05-12 when home Telegram bot reported
"OCP error: fetch failed" against `/ocp usage`. Mac mini OCP service
was healthy on port 3478; lsof -iTCP:3456 had no listener; plugin
index.js had hardcoded 3456.

No cli.js citation needed: this is OCP-internal plugin code with no
corresponding cli.js operation.

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

* docs(readme): document OCP_PROXY_URL + CLAUDE_PROXY_PORT plugin reuse (per release_kit 5.5)

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 11:51:54 +10:00
7a69d72886 feat(snapshot): gcSnapshots + ocp update --rollback --gc + auto-GC; v3.16.0 (#94)
Adds snapshot garbage collection with retention policy: keep last 5,
keep snapshots within 30 days, always keep the most recent. Configurable
via keepCount / keepDays opts.

Wire-up:
- ocp update --rollback --gc (manual trigger; --dry-run supported)
- runFullUpgrade auto-GC after successful upgrade (best-effort,
  swallows errors)

4 unit tests: keepCount enforcement, keepDays override, never-delete-
most-recent safety, dry-run mode.

Bumps to v3.16.0 (bundles PR #93 --check oauth + this GC feature).

No cli.js citation needed: this is OCP-internal snapshot lifecycle with
no corresponding cli.js operation.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 07:33:13 +10:00
a8601a6d30 feat(doctor): --check oauth fast path (#93)
* feat(doctor): --check oauth fast path

Implements the --check oauth fast path documented in cmd_doctor_help
but previously unimplemented. Skips version detection, from-version
check, git operations, and models endpoint — runs only the curl
against /health + auth.ok extraction.

Use cases:
- After `claude auth login`, fast verify OCP can spawn cli.js
- After a known service blip, quick health gate before larger ops
- AI agent's setup-repair loop: ./ocp doctor --check oauth in a
  retry-after-fix step

3 unit tests cover: PASS path, OAuth FAIL → fix_oauth, service down →
fix_service.

No cli.js citation needed: this is OCP-internal doctor logic with no
corresponding cli.js operation.

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

* chore(doctor): nit fixes for --check oauth (N3 body=null test + N4 skipped sentinel comment)

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 07:25:40 +10:00
cd6ec2a212 fix(doctor): dynamic latest_version from origin/main; release v3.15.1 (#92)
v3.15.0 doctor used a hard-coded `latest = "v3.14.0"` fallback, causing
any v3.15.0+ install to report kind=upgrade against a stale value.
`ocp update` would then attempt `git checkout v3.14.0` — a downgrade.

Doctor now fetches `git -C ~/ocp show origin/main:package.json` to
determine the actual latest. On failure (offline, fresh clone, no
remote), falls back to currentVersion so kind=noop instead of
recommending a downgrade.

Regression test added: doctor with unreachable ocpDir falls back to
currentVersion as latest (not the old hardcoded v3.14.0).

Caught during v3.15.0 post-deploy verification on home-mac: ./ocp
doctor reported `kind=upgrade` immediately after v3.15.0 install,
which would have been a critical user-facing bug.

No cli.js citation needed: this is OCP-internal doctor logic with
no corresponding cli.js operation.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 07:01:49 +10:00
ab03c13332 feat(upgrade): ocp doctor + cross-version ocp update + rollback + AI prompts (#91)
* feat(doctor): add ocp doctor with --json + next_action contract

Implements scripts/doctor.mjs with semver-aware path selection
(noop/update/upgrade/fresh_install/fix_oauth/fix_service) and the JSON
contract documented in the design spec.

Service health + OAuth checks integrated; mockable via opts.mockHealth
for unit tests. 8 unit tests cover the kind dispatch tree and the
next_action shape for each kind.

No cli.js citation needed: this is OCP-internal tooling with no
corresponding cli.js operation.

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

* feat(ocp): wire cmd_doctor into bash CLI; dispatch to scripts/doctor.mjs

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

* fix(doctor): handle unparseable version + empty health body

Three issues raised by code-quality reviewer on b65201b:

1. semverCompare returned 0 for unparseable input, causing fromSupported=true
   and kind=noop for an install with unreadable package.json. Now treats
   unparseable currentVersion as fresh_install candidate.
2. mockHealth: { status: 200, body: null } routed to fix_oauth (because
   health.body?.auth?.ok was undefined → falsy). 200 with empty body is
   server-broken, not OAuth-broken; now routes to fix_service.
3. Removed unused KIND_ENUM declaration (dead code).

Two regression tests added.

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

* feat(upgrade): add scripts/upgrade.mjs + scripts/lib/snapshot.mjs

Implements the upgrade dispatcher (noop / dry-run / light delegation /
full path) and the snapshot writer/reader/list module. Full path snapshots
plist + db + admin-key + openclaw.json before mutating, runs the 6 phases
(pre-flight, snapshot, fetch+install, reconfigure, restart, post-flight),
and emits a heads-up before launchctl bootout per
notify_before_prod_service_restart.md policy.

mockExec/mockDoctor injection points let tests verify the phase ordering
without touching the real shell. fresh_install + rollback paths are
deferred to Bundle 3.

No cli.js citation needed: this is OCP-internal upgrade tooling with no
corresponding cli.js operation.

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

* fix(upgrade): error path completeness + observability

5 issues raised by code-quality reviewer on c12013a:

A. exec() wrapper now captures stderr from execSync failures and
   re-throws with `phase X failed: <stderr>` instead of the terse
   "Command failed: ..." default. Operators see the actual git/npm
   error.
B. runFullUpgrade body wrapped in try/catch; any error after phase 2
   (snapshot written) carries snapshotPath + phases + hint pointing
   at `ocp update --rollback`. Aligns with the post-flight failure
   pattern.
C. CLI entrypoint now prints snapshotPath + hint on error.

Plus minor:
- snapshot.mjs tryCopy logs a [snapshot] warn line instead of silently
  swallowing copy errors (e.g. permission-denied admin-key)
- heads-up window 1s → 3s, more operable per the policy intent
- opts.yes intent comment added (Bundle 3 will use)

One regression test added.

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

* feat(upgrade): fresh-install + rollback paths

Implements the two missing branches of runUpgrade dispatcher:

- runFreshInstall: gated by --yes, runs doctor.next_action.ai_executable
  steps in order, fails fast on first error, attaches steps[] to thrown
  errors. Accepts mockExec for unit tests.
- runRollback: locates latest or named snapshot in ~/.ocp/, reads
  from-commit.txt, restores plist + db + admin-key + service file (with
  per-file warn lines on copy failure), git-checkouts the from-commit,
  npm installs at that revision, restarts the service. --list shows all
  snapshots; --dry-run prints the plan without mutation.

Both paths use the same exec() error-wrap pattern as runFullUpgrade
(stderr capture, phases attached to thrown errors, restart heads-up).

CLI entrypoint extended to parse --rollback / --list / --target / and
optional positional snapshot path after --rollback.

6 unit tests cover: --yes gate, fresh_install ai_executable run,
--rollback --list, no-snapshots error, --rollback --dry-run, mock-exec
restore.

No cli.js citation needed: this is OCP-internal upgrade tooling with
no corresponding cli.js operation.

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

* chore(upgrade): nit fixes from Bundle 3 code-quality review

3 micro-fixes on 48e9408:
1. Remove unused mkdirSync import
2. snapshot-not-found error message hints "must be inside ~/.ocp/upgrade-snapshot-*"
3. runFreshInstall failure now includes e.stderr (or e.message fallback) in the
   thrown error and steps[].error so non-interactive callers see the actual reason

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

* refactor(ocp): cmd_update dispatches via doctor; --rollback added; light path preserved

cmd_update now calls scripts/doctor.mjs to determine which path to take:
  noop          → "already at latest" exit 0
  update        → existing light path (git pull + npm install + restart),
                  extracted into _cmd_update_light helper to keep the daily
                  case fast and shell-only
  upgrade       → exec node scripts/upgrade.mjs (full path with snapshot
                  + post-flight)
  fresh_install → exec node scripts/upgrade.mjs (gated by --yes)
  fix_oauth/fix_service → print error referring user to `ocp doctor`

cmd_update --rollback path: exec node scripts/upgrade.mjs --rollback "$@"
forwards remaining args (--list, --dry-run, optional snapshot path).

cmd_update_help expanded to document new flags.

cmd_update --check fast path is preserved exactly (no doctor call there).

No cli.js citation needed: this is OCP-internal CLI dispatch with no
corresponding cli.js operation.

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

* fix(ocp): forward all args to cmd_update so multi-flag invocations work

Bug found via runtime smoke test:
  ./ocp update --rollback --list → "no snapshots" (wrong; should list)

Root cause: dispatch was `cmd_update "\${1:-}"` (only first arg). When
user typed `--rollback --list`, cmd_update only received `--rollback`,
the shift left $@ empty, and exec node ... --rollback got no flags.
Other commands using "\${1:-}" don't need multi-arg, but cmd_update now
does (--rollback --list, --rollback --dry-run, --target X --yes, etc.).

Change: dispatch is now `cmd_update "\$@"`. cmd_update internals already
handle multi-arg correctly (\$1 == --check fast path; \$1 == --rollback
shift+forward; otherwise doctor-driven).

Verified:
  ./ocp update --check         → existing behaviour preserved
  ./ocp update --rollback --list → "Found 0 snapshots:" exit 0
  ./ocp update --rollback --dry-run → no-snapshot error exit 1

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

* docs(release): v3.15.0 — README AI prompt blocks + Upgrading rewrite + CHANGELOG

§Installation, §Upgrading, §Troubleshooting each start with a copy-paste
AI prompt block for Claude Code / Cursor / Copilot. The Upgrading section
explains the three paths (light / full / fresh-install) and rollback usage.

All Commands table gains an `ocp doctor` row.

package.json bumped to 3.15.0.

CHANGELOG.md gains the v3.15.0 entry covering doctor, the cross-version
update path, --rollback, fresh-install routing, and AI prompt blocks.
Notes the dependency on PR #90 (plist env merge bug fix, already merged).

No cli.js citation needed: docs + version bump only, no server.mjs change.

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

* docs(readme): show --yes in rollback usage examples

Per Iron Rule 10 reviewer nit on PR #91: live rollback requires --yes
even for interactive humans. Update §Upgrading examples to show the
canonical human form. (AI agents pass --yes by convention; humans were
hitting a confusing "requires --yes" error following the prior README.)

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

* fix(scripts): CLI entrypoint guard resilient to symlinked install paths

Bug found via integration test on MacBook Pro (macOS /tmp → /private/tmp):
`import.meta.url === \`file://\${process.argv[1]}\`` evaluates false when
the install path traverses a symlink, because import.meta.url is canonicalised
but process.argv[1] is not. Result: ./ocp doctor (and ./ocp update via
upgrade.mjs) exit silently with code 0 and no output, instead of running.

Fix: use fileURLToPath + realpathSync on both sides of the comparison.
Affects any install at a symlinked path (/tmp, NFS mounts, /var/ paths,
docker bind mounts, etc.). Normal ~/ocp installs were unaffected.

No cli.js citation needed: this is OCP-internal CLI dispatch with no
corresponding cli.js operation.

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

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 06:56:17 +10:00
55c576bbb1 fix(setup): preserve user-customised plist/systemd env vars on re-setup (#90)
* fix(setup): merge plist/systemd env vars instead of overwriting

setup.mjs previously wrote the launchd plist and the Linux systemd unit
with writeFileSync(path, NEW_TEMPLATE_STRING), which silently dropped any
user-customised env vars (CLAUDE_HEARTBEAT_INTERVAL, CLAUDE_CACHE_TTL,
etc.) on every re-setup or upgrade. This change introduces
scripts/lib/plist-merge.mjs which preserves keys present only in the
existing file and lets the template's known keys win. Linux variant uses
the same logic against Environment=KEY=VALUE lines.

Tests added in test-features.mjs cover preserve / override / first-install
for both formats.

No cli.js citation needed: this is OCP-internal installer behaviour with
no corresponding cli.js operation.

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

* fix(setup): plist-merge code-quality follow-up

Three issues raised by the code-quality reviewer on 0fd1838:

1. mergeSystemdEnv now early-returns when the template has no
   Environment= anchor, instead of silently dropping preserved lines
   (defensive guard for a future template change).
2. Both parsers (parsePlistEnv, parseSystemdEnv) now Buffer-normalise
   their input, removing the TypeError-vs-coerce asymmetry.
3. Two idempotency tests added (calling merge twice on the same
   input/output pair must be stable).

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

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 03:41:53 +10:00
750b25ba77 chore(release): v3.14.0 — security hardening (sessions namespacing + file modes + /api/usage scope) (#89)
Bump version 3.13.0 → 3.14.0. No functional code change in this PR;
all three security fixes are already merged in main via PRs #86, #87, #88.

This PR covers only metadata + docs:
- package.json: version bump to 3.14.0
- CHANGELOG.md: v3.14.0 entry with Features / Behavior changes / Verification /
  Governance sections
- README.md: /api/usage self-scope note in Auth Modes §, API Endpoints table
  row update, file-mode bullet in Important Notes §

cli.js citation N/A: this release PR modifies no server.mjs / setup.mjs / keys.mjs.
The three underlying security PRs (#86, #87, #88) each carry their own
cli.js-citation-not-applicable disclaimer per PR #75 pattern, as they are
OCP-internal access-control, session-state, and file-permission changes with
no corresponding cli.js operation.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 06:49:00 +10:00
fd6e875bd7 fix(security): /api/usage default scope = self; admin all-keys requires ?all=true (#88)
Identified during privacy/security audit (.claude/research/ocp-security-audit.md
§3 "/api/usage 全量泄露所有 key 时序"). Previously, any caller authenticated as
admin received the full per-key usage byKey + recent + timeline block, which
means a brief admin-token compromise exposes every household member's request
volume, timing, and which-model — metadata, but sensitive metadata.

This is a deliberate breaking change for least-privilege.

Behavior matrix (post-change):

  Caller                                  | Default scope     | ?all=true
  ----------------------------------------|-------------------|---------------------
  anonymous (PROXY_ANONYMOUS_KEY)         | own ("anonymous") | ignored (still own)
  authenticated non-admin key             | own (key.name)    | ignored (still own)
  admin (no flag)                         | own ("admin")     | n/a
  admin with ?all=true                    | n/a               | full byKey/recent
  localhost-no-token / "local"            | own ("local")     | full (isAdmin=true)

Response shape is unchanged except for an additional advisory `scope` object
(`{self, all}`); existing fields keep their structure so dashboards and scripts
continue to parse cleanly — they just see less data unless they opt in.

Audit-friendly addition: when admin opts into `?all=true`, the server now
emits `logEvent("info", "admin_usage_full_scope", { caller, ip })` so every
privilege-escalation moment is recorded.

Backward-compat warning for admin scripts:
existing cron jobs / scripts that call `/api/usage` to inventory all keys must
add `?all=true` after this change. Default-self is the new safe behavior.

Dashboard (dashboard.html): adds a "Show all keys" checkbox in the Usage by
Key section. Hidden by default; revealed only when refreshKeys() succeeds
(same admin gate as the keys-management section). Toggle state persists in
localStorage as `ocp_usage_show_all`.

cli.js citation
---------------
This is OCP-internal admin API behavior — there is no `cli.js` operation to
cite. ALIGNMENT.md Rule 2 (the rule that limits OCP to operations that exist
in `cli.js`) does not constrain access-control rules for the OCP server's
own admin endpoints.

Smoke test (isolated test server on :3489, prod :3478 untouched):
- node --check server.mjs       SYNTAX_OK
- npm test                      43/43 passed
- alignment.yml blacklist grep  BLACKLIST_CLEAR
- LAN scope matrix              alice/bob own only; alice ?all=true denied;
                                anon ?all=true denied; admin all=true full +
                                audit log emitted

Iron Rule 10
------------
Author cannot self-approve. A separate fresh-context reviewer must read the
diff and confirm the cli.js-not-applicable statement is correct before merge.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 23:56:16 +10:00
8c0b97f3ae fix(security): tighten on-disk credential file modes (700/600) (#87)
Credential-bearing OCP artifacts were created at default umask (0644/0755),
making them world-readable on multi-user hosts. This commit hardens them at
writer time and adds an idempotent startup reconciliation so existing prod
installs are fixed automatically on next service restart.

Changes:
- keys.mjs: mkdirSync(OCP_DIR, { mode: 0o700 }) + chmodSync after to handle
  pre-existing dirs; chmodSync(DB_PATH, 0o600) after first getDb() open.
- setup.mjs: chmodSync(plistPath, 0o600) after writeFileSync on macOS;
  chmodSync(unitPath, 0o600) after writeFileSync on Linux.
- server.mjs: _tightenFileModesIfPossible() reconciliation block — idempotently
  chmods ~/.ocp (700), ~/.ocp/admin-key (600), ~/.ocp/ocp.db (600) on startup;
  emits a single info-level log line when any file is tightened; ignores ENOENT
  and wraps EPERM in a warn log so startup is never crashed by chmod failure.

Backward compat: all files remain accessible to the same-user owner; 0o600
is still fully readable/writable by the process. Existing prod boxes with
old 0644 ~/.ocp directories get fixed-up on next launchd/systemd restart
without any manual intervention.

cli.js citation: this change is OCP-internal file-permission hardening only.
No cli.js function corresponds to chmod or credential-file management. This
is an OCP-local security improvement that is out of scope for the cli.js
citation requirement per ALIGNMENT.md Rule 2; the cli.js boundary applies
to proxy protocol and API surface changes, not host-filesystem hardening.
Identified during privacy/security audit — .claude/research/ocp-security-audit.md.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:56:10 +10:00
68acf15373 fix(security): namespace sessions Map by keyId — close cross-key conversation collision (#86)
**Bug**: the sessions Map used the raw client-supplied conversationId string as
its key. Two callers with different API keys (or one anonymous + one
authenticated) using the same session_id="default" collided in the Map,
sharing a cli.js subprocess and conversation history — a cross-tenant context leak.

**Fix**: introduce _sessionKey(conversationId, keyName) → "${keyName}|${conversationId}".
Replace every sessions Map site (has / set / get / delete) with the namespaced key.
keyName comes from req._authKeyName (set by auth middleware). Anonymous callers
produce "anon|<id>"; admin produces "admin|<id>"; per-key callers produce
"<keyName>|<id>" — matching the convention used by cacheHash() for per-key cache
isolation (D1, v3.13.0).

**cli.js citation — not applicable**: This PR changes OCP-internal session lifecycle
bookkeeping (the sessions Map that OCP maintains to thread --resume flags across
requests). There is no corresponding cli.js operation to cite. ALIGNMENT.md Rule 2
(limiting OCP to operations cli.js performs) does not constrain in-process state
representation. Same pattern as #75.

**Scope of changes (server.mjs only)**:
- New helper: _sessionKey() — 3 lines after sessions Map declaration
- spawnClaudeProcess(): accepts keyName param; all 4 sessions Map calls → _sessionKey
- handleSessionFailure(): uses sessionKey (not raw conversationId) for sessions.delete
- callClaude(): accepts and forwards keyName to spawnClaudeProcess
- callClaudeStreaming(): forwards authInfo.keyName to spawnClaudeProcess
- Request handler: both callClaude() call sites pass req._authKeyName
- Cleanup interval + GET /health + GET /sessions: strip "keyName|" prefix before log/display

**Smoke test**:
- node --check server.mjs → SYNTAX OK
- npm test → 43/43 passed, 0 failed
- Hand-traced has/set/get/delete paths: cross-key collision absent ✓

Identified during privacy/security positioning brainstorm — `.claude/research/ocp-security-audit.md`

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:56:04 +10:00
a71c939bf8 docs(readme): remove zhihu blog backlink + badge (article was removed) (#85)
The Chinese blog post linked from these two places was taken down by
the 知乎 platform shortly after publication. Both pointers now resolve
to a "content removed" warning page, which is a worse signal to
visitors than no link at all. Removing both before they reach more
README readers.

Reverts the additions from #81 specifically:
- Top badges row: drop the "blog · engineering story" shield
- §Why OCP? closing blockquote: drop the line pointing at the article

The 知乎 article URL is no longer reachable, so retaining the references
would route incoming traffic to a dead page that suggests the project
is itself problematic. Better to leave that section silent until a
working canonical write-up exists somewhere.

Doc-only change. server.mjs not touched. ALIGNMENT.md Rule 5 (cli.js
citation) does not apply.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 22:43:59 +10:00
d245c62df7 docs(images): replace dashboard.png with multi-client + multi-session traffic (#84)
Continues #82 / #83 — those captures had stats counters at 0 / 3
respectively, with 0 active sessions. The Sessions card therefore
visually undermined the rest of the dashboard.

Re-captured after deliberately seeding LAN-side traffic from both
Pi231 and MacBook clients (each holding a per-key API token issued
on the Mac mini server), mixing single-turn and multi-turn requests
with distinct session_id values to populate the in-memory sessions
Map.

New screenshot data points:

- Uptime: 21h 56m
- Requests: 24 / 0 active (up from 3 / 0)
- Errors: 0 / 0 timeouts
- Sessions: 8 active (was 0)
- Plan Usage: 5h 20%, weekly 28%
- byKey table: 11 keys with rich history, now including
  pi231-test + macbook-test rows from this round
- Recent Requests: visible row count grown

Multi-turn correctness was incidentally verified during seeding —
a Pi231 request with session_id=pi-multi-01 turn 2 correctly
recalled the number passed in turn 1 ("the number 7"), confirming
session state persists across cli.js subprocess turns as designed.

Capture method unchanged (Chrome headless, 1400x2400,
--virtual-time-budget=14000).

Doc-only change. server.mjs not touched. ALIGNMENT.md Rule 5 (cli.js
citation) does not apply.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 14:05:28 +10:00
047750e642 docs(images): replace dashboard.png with stats-populated version (#83)
#82 captured the dashboard mid-idle — server had been up 21h with
zero traffic since restart, so the in-memory stats counters all
showed 0 (Requests 0 / Errors 0 / Sessions 0). That made the top
strip of the screenshot look like a dead service even though the
historical byKey + Plan Usage data below were healthy.

Re-captured after firing 3 small haiku requests through localhost
to populate stats.totalRequests. The new screenshot now shows:

- Status: ok / v3.13.0
- Uptime: 21h 39m (up from 21h 28m)
- Requests: 3 / 0 active (was 0 / 0)
- Plan Usage: 5h 17%, weekly 28% (was 13% / 27%)
- Recent Requests: 2 visible rows showing model + latency + status
  (was empty)

Same capture method as #82: Chrome headless --window-size=1400,2400
--virtual-time-budget=12000.

The 3 priming requests were short haiku prompts ("reply with the single
word OKn"), max_tokens=12 each. Plan-usage delta < 1%, byKey table
already had 11 active keys with rich history so the priming did not
distort the long-tail data.

This addresses feedback that the previous screenshot's empty stats
strip undermined the rest of the dashboard's data richness for first-
time README readers.

Doc-only change. server.mjs not touched. ALIGNMENT.md Rule 5 (cli.js
citation) does not apply.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 13:29:36 +10:00
3bdeb50ed5 docs(images): refresh dashboard.png with current prod data (#82)
The previous dashboard.png was captured on day-1, before any real
traffic — the screenshot was effectively empty (no Plan Usage bars,
single API key, no Recent Requests). It made the Web Dashboard look
like a placeholder rather than a working observability surface.

Replaced with a fresh capture of the maintainer's Mac mini production
OCP (running v3.13.0, multi auth) showing:

- Status / uptime / active+queued counters
- Plan Usage bars (5h: 13%, weekly: 27%) — real subscription draw
- Usage by Key table — 11 active keys with request counts, success/error
  rates, average latency, last-seen timestamps
- API Keys section — all 32 registered keys with truncated key prefixes
  + creation date + active/revoked status (truncation matches existing
  dashboard rendering, no full keys are exposed)
- Recent Requests log — request stream with model, prompt size, latency

Capture method: Chrome headless (no Playwright extension required) at
1400x2400, --virtual-time-budget=12000 to let dashboard JS finish
fetching + rendering before the screenshot frame.

PNG dimensions: 1400 x 2400 (was 1400 x 1739). The added height comes
from the now-populated Usage by Key + API Keys + Recent Requests
sections — not from layout changes.

Doc-only change. server.mjs not touched. ALIGNMENT.md Rule 5 (cli.js
citation) does not apply.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 13:18:13 +10:00
fbbf3b6c7c docs(readme): add engineering-story backlink to 知乎 article + badge (#81)
Adds two pointers from README to a Chinese-language engineering blog
post (https://zhuanlan.zhihu.com/p/2036388634207770402) that walks
through OCP's cli.js-alignment philosophy, the 2026-04-11 drift
incident (the 9-day hallucinated /api/oauth/usage endpoint), the
three-tier guardrail design, and the recent fresh-state E2E pass
that produced PRs #74-#78.

Two minimal touches to README only:

1. Badges row: a "blog · engineering story" shield linking to the
   article. Slot fits between the existing Release badge and the
   Buy Me a Coffee badge so the row's visual rhythm is preserved.

2. New blockquote line at the end of §Why OCP? (between the
   single-maintainer / pre-1.0 disclosure and §Supported Tools).
   Brief, factual, no marketing voice.

Why this PR

The 知乎 piece is a self-contained engineering narrative about
maintaining a single-maintainer LLM-assisted proxy without endpoint
drift. README is the project's primary surface; pointing at the
narrative lets readers who land on the repo decide if the project's
discipline matters to them before they invest in install. Reverse
direction: GitHub readers who follow the link bring some traffic
back to the article, validating that engineering content has a
home.

Doc-only change. server.mjs untouched. ALIGNMENT.md Rule 5 (cli.js
citation) does not apply. Same pattern as #68 / #69 / #71 / #78 / #79.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 12:32:52 +10:00
d760d7fcce docs(readme): add restrained star + issue CTA below tagline (#79)
* docs(install): remove false symlink claim, fix Anonymous mode startup command

Three doc inaccuracies surfaced during fresh-state install testing on Pi231 +
MacBook (Round 1+2):

1. README §Server Setup claimed setup.mjs "Symlink `ocp` to /usr/local/bin for
   CLI access" — false. setup.mjs writes start.sh + plist/systemd unit but
   creates no PATH symlink. Removed the line; added a short PATH tip showing
   the user's options (manual symlink to ~/.local/bin or /usr/local/bin, or
   shell alias) right after the install summary.

2. README §Anonymous Access told users to run `ocp start` to enable the
   feature — there is no `ocp start` subcommand. Available commands are
   restart / stop / status / logs / keys / usage / update / lan / health /
   clear / settings (verified via `~/ocp/ocp` enumeration). Replaced with
   the correct flow: export PROXY_ANONYMOUS_KEY, then `node setup.mjs
   --bind 0.0.0.0 --auth-mode multi`.

3. The same paragraph implied that exporting PROXY_ANONYMOUS_KEY in an
   interactive shell is enough to enable anonymous access — but the running
   proxy is auto-started by launchd/systemd from the service unit's own
   env, not from the user's shell. Spelled this out and noted that if OCP
   was installed before exporting the env var, the user must re-run
   setup.mjs (idempotent) so the service unit env is refreshed, then
   `ocp restart`.

The PROXY_ANONYMOUS_KEY mechanism described becomes 100% accurate when
PR B (`fix/setup-inject-service-env`, sibling PR) lands; current setup.mjs
on main does not yet inject this env into the service unit.

Doc-only — no `server.mjs` change, no version bump, no `cli.js` citation
required.

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

* docs(readme): add restrained star + issue CTA below tagline

A single italic line under the existing personal-note italic, asking
readers to  the repo if they get value, and to file issues — framed
explicitly as "issues are even more useful than stars" so the CTA reads
as feedback-seeking rather than vanity-metric chasing.

Tone matches the rest of README: low-key, single-maintainer self-deprecating,
no marketing voice. No "save money / free / $0" verbiage. Coexists with the
existing buy-me-a-coffee personal-note line above it (different ask:
funding vs. social-proof).

This is doc-only. ALIGNMENT.md Rule 5 (cli.js citation) does not apply.
Same pattern as #68 / #69 / #71 / #78.

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

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 03:01:06 +10:00
5e2effd05b fix(ocp-connect): write ~/.zshrc on macOS (default shell since Catalina) (#77)
macOS default shell has been zsh since Catalina (2019). The previous
rc-file selection logic treated macOS the same as Linux, so on a fresh
Mac where ~/.zshrc did not already exist AND the script was invoked via
`bash -s --` (e.g. `curl | bash`), the $SHELL guard was `/bin/bash`
and neither condition for zshrc was true — resulting in only ~/.bashrc
being written. Since ~/.bashrc is not sourced by interactive zsh
sessions, the OPENAI_BASE_URL / OPENAI_API_KEY exports were invisible
to interactive shells.

Fix:
- Add an explicit `elif $is_mac` branch that unconditionally includes
  ~/.zshrc: create the file (empty) if it does not yet exist, since
  zsh tolerates an empty ~/.zshrc.
- On macOS, ~/.bashrc is only written if it already exists — consistent
  with the task spec ("don't create ~/.bashrc if it didn't exist").
- Linux path is preserved unchanged.
- Fix the "Reload your shell" hint at script end: previously it printed
  only the last loop variable `$rc_file` (stale reference outside the
  loop). Now it iterates `${rc_files[@]}` so both files are shown on
  macOS (reproducing the Round A bug: hint said only `source ~/.bashrc`
  even when zshrc should also be reloaded).

Smoke-tested with HOME=/tmp/fakehome redirect for three scenarios:
  1. Fresh MacBook (no .bashrc, no .zshrc)  → only .zshrc created+written
  2. macOS with existing .bashrc             → both .bashrc and .zshrc written
  3. Linux with bash, no .bashrc             → .bashrc written (unchanged)

Identified during Round A testing on MacBook 2026-05-08.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 00:50:07 +10:00
fb2d1d3feb fix(ocp-cli): replace eval-curl with bash array to preserve JSON body quoting (#74)
`eval curl "$_AUTH_HEADER" "$@"` re-tokenizes its argument list according
to bash word-splitting rules. When OCP_ADMIN_KEY is set, the JSON body
`'{"name": "laptop"}'` (which contains a space) gets split into
`'{name:'` and `'laptop}'` — two separate args — so curl receives a
malformed body and the server rejects the request.

Fix: replace the `_AUTH_HEADER` string + `eval` pattern with a bash array
`_AUTH_ARGS`. Array expansion via `"${_AUTH_ARGS[@]}"` preserves word
boundaries across substitution with no eval required. Both code paths
(OCP_ADMIN_KEY env var and ~/.ocp/admin-key file fallback) and the empty
case (no admin key) are preserved unchanged.

Verified via `bash -x` trace:
  Before: `curl … -d '{name:' 'laptop}'` (body split, malformed)
  After:  `curl … -d '{"name": "testkey-pr-c"}'` (body intact, single arg)

Identified during fresh-state Round 2 testing on MacBook.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 00:50:01 +10:00
12b09c236e fix(server): resolve claude binary from nvm/fnm/asdf and PATH fallback (#75)
Real-world macOS dev machines using nvm-managed Node hit a startup FATAL
because the hardcoded candidate list in resolveClaude() only covered
homebrew, /usr/local, /usr/bin, and ~/.local/bin. With Claude CLI
installed at $HOME/.nvm/versions/node/<v>/bin/claude, the launchd job
failed without manual CLAUDE_BIN injection.

Fix: extend the candidate list with user-local Node version manager
paths — nvm (with default-alias), fnm, asdf, and npm-prefix-relocated
$HOME/.npm-global/bin. The existing CLAUDE_BIN env override and `which`
fallback are preserved; resolution order is now explicit CLAUDE_BIN >
hardcoded list > nvm/fnm/asdf > which > FATAL (with the message
upgraded to mention CLAUDE_BIN as a hint).

This is OCP-internal binary discovery — there is no `cli.js` operation
to cite. ALIGNMENT.md Rule 2 (the rule that limits OCP to operations
that exist in `cli.js`) does not constrain runtime path discovery for
the OCP server itself.

Smoke tests:
- default (no CLAUDE_BIN): picks /opt/homebrew/bin/claude (unchanged)
- CLAUDE_BIN=/nonexistent/claude: fail-fast preserved
- HOME=/tmp/fakenvm with synthetic .nvm tree: candidate list contains
  the fake nvm path; alias-default unshift logic verified
- npm test: 43/43 unit tests pass
- node --check server.mjs: OK
- alignment.yml blacklist grep: no hits

Identified during fresh-state Round 2 testing on MacBook.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 00:49:55 +10:00
c0f2d3ab20 docs(install): remove false symlink claim, fix Anonymous mode startup command (#78)
Three doc inaccuracies surfaced during fresh-state install testing on Pi231 +
MacBook (Round 1+2):

1. README §Server Setup claimed setup.mjs "Symlink `ocp` to /usr/local/bin for
   CLI access" — false. setup.mjs writes start.sh + plist/systemd unit but
   creates no PATH symlink. Removed the line; added a short PATH tip showing
   the user's options (manual symlink to ~/.local/bin or /usr/local/bin, or
   shell alias) right after the install summary.

2. README §Anonymous Access told users to run `ocp start` to enable the
   feature — there is no `ocp start` subcommand. Available commands are
   restart / stop / status / logs / keys / usage / update / lan / health /
   clear / settings (verified via `~/ocp/ocp` enumeration). Replaced with
   the correct flow: export PROXY_ANONYMOUS_KEY, then `node setup.mjs
   --bind 0.0.0.0 --auth-mode multi`.

3. The same paragraph implied that exporting PROXY_ANONYMOUS_KEY in an
   interactive shell is enough to enable anonymous access — but the running
   proxy is auto-started by launchd/systemd from the service unit's own
   env, not from the user's shell. Spelled this out and noted that if OCP
   was installed before exporting the env var, the user must re-run
   setup.mjs (idempotent) so the service unit env is refreshed, then
   `ocp restart`.

The PROXY_ANONYMOUS_KEY mechanism described becomes 100% accurate when
PR B (`fix/setup-inject-service-env`, sibling PR) lands; current setup.mjs
on main does not yet inject this env into the service unit.

Doc-only — no `server.mjs` change, no version bump, no `cli.js` citation
required.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 00:49:49 +10:00
0d61da5153 fix(setup): inject CLAUDE_BIN, OCP_ADMIN_KEY, PROXY_ANONYMOUS_KEY into service env (#76)
Gap #1 partial (CLAUDE_BIN): setup.mjs now detects `which claude` at install
time (or reads $CLAUDE_BIN) and writes CLAUDE_BIN into the service unit
EnvironmentVariables dict. Works for nvm/homebrew paths not in server.mjs's
hardcoded list, and for older server.mjs deployments.

Gap #2 (OCP_ADMIN_KEY): reads $OCP_ADMIN_KEY from the user's shell env and
conditionally injects it into the plist/systemd unit. Empty/unset → key is
omitted entirely (server.mjs treats empty string as "no admin"). Key value is
never logged; only its length is reported.

Gap #6 partial (PROXY_ANONYMOUS_KEY): reads $PROXY_ANONYMOUS_KEY and
conditionally injects it. Unset → key is omitted (anonymous access disabled).

All three keys are read via process.env at install time; no new CLI flags.
Injection status is logged before the !DRY_RUN guard so dry-run shows what
would be written.

Identified during fresh-state Round 2 testing.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 00:49:43 +10:00
49baffe2da fix(setup): decouple OpenClaw config patch from being mandatory (#73)
OCP markets itself as a standalone OpenAI-compatible proxy with six
supported IDE clients (Cline / Cursor / Continue.dev / OpenCode / Aider /
OpenClaw). The README §Server Setup says "node setup.mjs" is the
installer entrypoint. But on a truly fresh box without OpenClaw
installed, setup.mjs hard-fails at line 111:

    if (!existsSync(CONFIG_PATH)) fail(`OpenClaw config not found...`);

This contradicts the standalone-proxy stance and was caught during
PR #71 dogfood testing on Pi231.

Root cause: the OpenClaw config patch (lines 110-148) was baked in as
required because OCP started life as an OpenClaw-internal helper. The
project has since rebranded to standalone OCP without decoupling the
installer.

Fix: gate the OpenClaw config patch (Step 2) and auth-profiles patch
(Step 3) on existsSync(CONFIG_PATH). When OpenClaw is present, behavior
is byte-for-byte identical to current main (verified via dry-run hash
diff against ~/.openclaw/openclaw.json — UNCHANGED). When OpenClaw is
absent, the installer logs a graceful warning, skips both patch
sections, and continues to start.sh / launchd-plist / systemd-unit
creation as before. The summary banner is also conditional: when
OpenClaw is absent, it points the user at README § "Client Setup"
instead of giving openclaw.json edit instructions.

Also moves `import { readdirSync }` from mid-file (line 178, post-use)
to the top-level imports block; this was a latent ESM-hoisting quirk
that worked but is now syntactically required at the top because the
readdirSync call moved inside an `if` block.

Out of scope (Iron Rule 11, single-layer PR): server.mjs, models.json,
scripts/sync-openclaw.mjs, README.md, ALIGNMENT.md, package.json
version, the duplicate-spawn / health-verify logic at line 268+ (that's
a separate fix/setup-spawn-conflict PR).

Mac mini production unaffected: ~/.openclaw/openclaw.json already
exists there, so the OpenClaw-present path is preserved. `ocp update`
doesn't invoke setup.mjs, so running services are not touched.

Smoke-tested locally:
- Path 1 (OpenClaw present): dry-run output functionally identical to
  current main; config sha256 unchanged after run.
- Path 2 (OpenClaw absent, OPENCLAW_STATE_DIR=/tmp/no-such-dir-*):
  graceful warn, both patch sections skipped, banner shows
  standalone-mode message, dry-run completes successfully.
- npm test: 43/43 pass.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:03:27 +10:00
4b01d4e768 fix(setup): remove duplicate server spawn; verify health post-install (#72)
## Dogfood evidence (Pi231, 2026-05-08)

A user ran `node setup.mjs --bind 0.0.0.0 --auth-mode multi` and got:
- setup.mjs exit 0
- /health responded with authMode:"none" and server bound to 127.0.0.1 only
- ps showed two server.mjs processes: one orphan from setup (wrong config),
  one systemd child restart-looping on EADDRINUSE

## Root cause (two-step conflict)

Step 6 (the deleted block) called `execSync('bash "${startPath}"')` which ran
start.sh's `nohup node server.mjs &` — spawning the server WITHOUT exporting
CLAUDE_BIND or CLAUDE_AUTH_MODE. That server ran with default bind=127.0.0.1
and authMode=none, ignoring the user's CLI flags.

Step 7 then wrote the systemd unit/launchd plist WITH the correct env vars and
bootstrapped the service — but port 3456 was already taken by Step 6's spawn,
causing EADDRINUSE and a silent restart loop. setup.mjs exited 0 because Step 6
had "succeeded" (a server was running, just the wrong one).

## What changed

1. Deleted Step 6 entirely (the `execSync('bash "${startPath}"')` block).
   The systemd/launchd service installed in Step 7 is now the sole authoritative
   start path. start.sh is unchanged and still available for manual non-systemd use.

2. Added Step 8 inside the `if (!DRY_RUN)` block: after Step 7 bootstrap,
   waits 3 s, then GETs http://127.0.0.1:${PORT}/health with a 5 s timeout.
   - On 200 OK: logs version, authMode, and bind socket (best-effort).
   - On failure: prints clear error pointing to service logs, exits 1.
   - Skipped when --no-start is set (existing flag).

Identified during PR #71 dogfood testing on Pi231 (RPi4 / Debian Bookworm).

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:02:45 +10:00
36fa81d1e6 docs(install): add §Quick install with AI assistance + plug five new-user pitfalls (#71)
* docs(install): add §Quick install with AI assistance + plug five new-user pitfalls

Context: a returning user observed that letting an AI assistant follow the
README to install OCP would mostly work but stumble on a handful of small
gaps — missing OS qualifier, no admin-key generation hint, server IP
discovery buried, and a few common setup errors not in Troubleshooting.
This patch closes those gaps and adds a copy-paste prompt section for
new users who'd rather have an AI walk them through the install.

Five additive changes (README only, server.mjs untouched):

1. **§Server Setup prerequisites** — add the macOS/Linux qualifier
   ("Windows is not supported — setup.mjs installs launchd / systemd")
   and `git`, both of which were implicit before.

2. **OCP_ADMIN_KEY generation hint** — replace the placeholder
   `your-secret-admin-key` with a one-line `openssl rand -base64 32`
   example, plus a reminder to add the export to ~/.zshrc / ~/.bashrc
   so it survives shells.

3. **§Client Setup** — add an inline note pointing readers to run
   `ocp lan` on the server to discover the server's LAN IP. Previously
   `ocp lan` was mentioned only in §Server Setup, leaving client-side
   readers to guess.

4. **§Troubleshooting** — three new entries for setup-time errors
   (claude not found / EADDRINUSE 3456 / node version), with the
   specific recovery commands. Existing entries left unchanged.

5. **§Installation → new ###Quick install with AI assistance subsection**
   — three copy-paste prompts (single-machine, LAN server, client) that
   pin the AI to the right README path, name the verification step, and
   forbid silent retries. Includes a pointer to the manual handbook
   sections for readers who prefer that path.

Doc-only change. server.mjs not modified. ALIGNMENT.md Rule 5 (cli.js
citation requirement) does not apply. Same pattern as #68, #69, #70.

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

* docs(install): add Claude CLI install command + ####Headless install notes

Live Pi231 (RPi4 / Debian Bookworm) test of PR #71's "Quick install with AI
assistance → LAN server" prompt surfaced two more new-user pitfalls in the
README's Prerequisites + Server Setup flow:

1. **Claude CLI install command was missing.** Previous text said "Claude CLI
   installed and authenticated" with a docs link — but the actual install
   command (`npm install -g @anthropic-ai/claude-code`) appeared nowhere.
   An AI assistant following the prompt has to fetch external docs to
   guess at the install path. Now inlined.

2. **Headless servers (Pi / NAS / VPS) had no auth guidance.** OCP's main
   deployment targets are always-on headless devices. `claude auth login`
   actually works headless (prints URL + code, OAuth completes on any
   browser-capable device), and `claude setup-token` provides a long-lived
   token — but neither was documented. New ####Headless install notes
   subsection explains both paths.

Test trail (Pi231):
-  Linux (aarch64 Debian Bookworm), Node v22.22.2, git 2.39.5 — prereqs
  satisfied except Claude CLI.
-  `claude` not on PATH (expected — fresh Pi). README didn't tell the
  user how to install it. → fix #1 above.
- ⚠️ Even after AI fetches the install command, headless OAuth was a
  documentation gap. → fix #2 above.

Doc-only change. server.mjs not modified. ALIGNMENT.md Rule 5 does not
apply. Extends PR #71 (same install-UX layer per Iron Rule 11 IDR).

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

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:02:40 +10:00
cce0110253 docs(why-ocp): reorder bullets + soften alignment framing + visceral hooks (#70)
The "Why OCP?" section previously led with technical/governance bullets
(SSE heartbeat, alignment, models.json) and buried the most relatable
selling points (LAN multi-user keys, ocp-connect IDE auto-config, cache).
First-time readers stopped reading before reaching the bullets that would
have sold them on the project.

Changes (README.md only, line 19-26):

1. Reorder: human-relatable benefits first, governance discipline last.
   New order: LAN multi-user → ocp-connect → cache → quota → SSE heartbeat
   → alignment → models.json. Quota is split out from the old combined
   "quota + cache" bullet so each gets its own one-liner.

2. Soften the alignment bullet's framing. The previous prose flagged
   "Other Claude proxies have shipped exactly that" and was deleted —
   no need to call out competitors. Replaced with a measured "LLM-assisted
   code drifts easily — it's tempting to invent plausible-looking endpoints
   that cli.js doesn't actually use" plus a deliberately understated
   payoff: "your setup keeps working when cli.js ships its next minor."

3. Add visceral hooks where bullets benefit from them:
   - SSE heartbeat: "If you've ever watched your IDE die at the 60s idle
     mark during a long Claude tool-use pause — that's nginx/Cloudflare
     default behavior" (frames the problem in user-felt terms before
     describing the fix).
   - Per-key quota: concrete example "set a kid's iPad to 20/day, a
     partner's laptop to 100/week" (replaces abstract "limits per key").

4. Cache bullet now explicitly states the per-key isolation guarantee:
   "cross-user pollution is impossible by hash construction, not by
   application logic" — this addresses the most common pre-adoption
   concern (and reflects the v3.13.0 D1 design).

5. Total length compressed ~20% despite added context — old bullets had
   redundant "what" descriptions; new bullets lead with the "what for".

Doc-only change. server.mjs not touched. ALIGNMENT.md Rule 5 (cli.js
citation requirement) does not apply. Same pattern as #68, #69.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:57:06 +10:00
40391791a1 docs(funding): raise sponsorship visibility — top badges + intro CTA + expanded support section (#69)
Context: Buy Me a Coffee + Stripe onboarding now fully live (verified
buymeacoffee.com/dtzp555 returns og:type=profile with Support CTA, default
$3 price tier, membership tier active). Previous §Support OCP at line 709
was effectively buried — last section before License, missed by most readers.

Changes (README.md only, server.mjs untouched):

1. Top of file — three shields.io badges (License MIT, latest release,
   Buy Me a Coffee) under H1, before tagline. Standard OSS pattern
   (Vue / Vite / Tailwind), high visibility, doesn't disrupt prose.

2. Just under tagline — one-line italic personal note pointing to
   §Support OCP, with inline  link as fallback for users who don't
   scroll. Quotes the spirit of the longer section without duplicating it.

3. §Support OCP expanded — adds the "open source from day one" framing
   (not freemium, not commercial-turned-open), the "my family uses it
   daily" angle, and an explicit feedback / issues invitation. The
   debugging-history paragraph is preserved verbatim.

Doc-only change. ALIGNMENT.md Rule 5 (cli.js citation) does not apply —
server.mjs is not modified. Same pattern as #68.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:29:58 +10:00
342a0a44f5 docs(funding): add Buy Me a Coffee link + GitHub FUNDING.yml (#68)
- .github/FUNDING.yml — enables GitHub's native "Sponsor" button on the
  repo page, pointing to buymeacoffee.com/dtzp555. Other platforms
  (GitHub Sponsors, Ko-fi) are commented out and can be enabled later
  by uncommenting + filling in handles.
- README.md § Support OCP — new section just before License. States the
  free-and-open-source commitment, lists the kinds of work that don't
  show up in commits (multi-machine debugging, IDE validation, drift
  incidents, concurrency leaks), and offers a single  link for users
  who want to support continued maintenance. Explicitly disclaims paid
  tiers / premium features so the open-source posture stays unambiguous.

server.mjs is not modified; this commit is doc-only and therefore exempt
from the cli.js citation requirement (ALIGNMENT.md Rule 5 applies only
to commits that touch server.mjs).

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 03:29:27 +10:00
9494fd6c69 chore(release): v3.13.0 — cache layer hardening (per-key isolation + bypass + chunked replay + singleflight) (#67)
Per release_kit overlay (CLAUDE.md § Iron Rule 5.5):
- package.json bumped 3.12.0 → 3.13.0
- CHANGELOG.md updated with v3.13.0 entry
- README.md § Response Cache updated to document the four hardening features

This is a release preparation commit. Tag push to v3.13.0 will trigger
.github/workflows/release.yml which auto-creates the GitHub Release.

cli.js does not perform proxy-layer response caching, stampede protection,
or replay; this release only ships internal cache-layer correctness and
concurrency improvements that do not change the OpenAI-compatible wire
surface visible to clients. No client-observable wire shape change.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 17:19:44 +10:00
5ff30ac9b6 feat(cache): singleflight stampede protection on non-streaming path (#66)
cli.js does not perform proxy-layer stampede protection. The singleflight
layer is a value-add proxy operation that exists only inside OCP, between
concurrent client requests and the single upstream cli.js spawn. It does
not introduce, alter, or remove any endpoint, header, request field, or
response field that cli.js emits or expects — no client-observable wire
shape change.

Justification under ALIGNMENT.md Rule 2: the singleflight Map deduplicates
concurrent identical non-streaming cache-miss requests so only one cli.js
spawn runs per unique hash window. All followers receive the same resolved
(or rejected) content. This is a proxy-internal concurrency optimization,
not a wire-level protocol change.

Spec: docs/superpowers/specs/2026-05-07-cache-upgrade-design.md (D4)

Changes:
- keys.mjs: add singleflight(hash, fn) + getInflightStats() exports;
  in-memory Map cleared via Promise.finally() on each settlement
- server.mjs: import singleflight + getInflightStats; wrap non-streaming
  cache-enabled path through singleflight with inner recheck; add TODO
  comment in callClaudeStreaming (streaming-path dedup is explicitly out
  of scope for v3.13.0, see spec D4 streaming caveat); extend /cache/stats
  to return inflight + requesters fields (additive, no removed fields)
- test-features.mjs: 7 new PR-B singleflight tests covering basic dedup,
  failure fan-out, map cleanup (success + failure), different-hash
  independence, getInflightStats shape, and sequential-call non-sharing;
  all 31 tests pass (24 existing + 7 new)

Streaming-path singleflight is explicitly out of scope; TODO left in
callClaudeStreaming for a future follow-up ticket.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 17:16:46 +10:00
16eeb66557 feat(cache): per-key isolation, cache_control bypass, chunked stream replay (#65)
* docs(governance): ADR 0005 (no multi-provider) + cache upgrade spec

Governance prelude for the cache upgrade work:

- docs/adr/0005-no-multi-provider.md — locks in the decision that
  OCP stays single-provider (Anthropic via cli.js spawn). Cache
  improvements are explicitly in scope (decision §3); multi-provider
  refactor is explicitly out of scope, with three documented trigger
  conditions for revisiting.

- docs/adr/README.md — index updated to reference 0005.

- docs/superpowers/specs/2026-05-07-cache-upgrade-design.md — design
  for the cache upgrade work split into PR-A (per-key isolation,
  cache_control bypass, chunked stream replay) and PR-B (singleflight
  stampede protection). Each design decision has a written rationale.

server.mjs is not modified; this commit is doc-only and therefore
exempt from the cli.js citation requirement (ALIGNMENT.md Rule 5
applies only to commits that touch server.mjs).

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

* feat(cache): per-key isolation, cache_control bypass, chunked stream replay

cli.js does not perform response caching at the proxy layer. OCP's response
cache is a value-add operation internal to OCP, between the wire and the
cli.js spawn. It does not introduce, rename, or alter any endpoint, header,
request field, or response field that cli.js emits or expects — this change
qualifies under ALIGNMENT.md Rule 2's value-add carve-out for non-wire-
affecting proxy operations. no client-observable wire shape change.

Spec: docs/superpowers/specs/2026-05-07-cache-upgrade-design.md

D1 — Per-key cache isolation (cacheHash v2 format)
  keys.mjs: prepend `v2|k:<keyId or "anon">|` before existing hash fields.
  Backward-compatible: absent/null/empty keyId folds to "anon".
  v1-format rows in response_cache are abandoned naturally; TTL cleanup at
  server.mjs:185 reaps them within one window. No migration needed.
  server.mjs: pass keyId: req._authKeyId at the single cacheHash call site
  (line ~1221).

D2 — cache_control bypass
  keys.mjs: export hasCacheControl(messages) — walks messages and nested
  content arrays for presence of cache_control field.
  server.mjs: if hasCacheControl(messages) is true, set req._cacheHash = null
  and log cache_skipped{reason: cache_control_present}; existing
  `if (CACHE_TTL > 0 && req._cacheHash)` guards on write-back handle the skip.

D3 — Chunked stream replay (80 codepoints/chunk, no artificial delay)
  server.mjs: replace single-chunk cached.response emission with an
  Array.from(cached.response) loop in steps of CACHE_REPLAY_CHUNK_SIZE=80.
  Array.from ensures multibyte UTF-8 codepoints (e.g. CJK) are never split.

Tests: 12 new cases in test-features.mjs (36 total, 0 failed).

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

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 13:50:50 +10:00
c998d21a4f chore(ci): add gitleaks workflow to scan PRs and pushes to main (#64)
The repo has shipped `.gitleaks.toml` (with project-specific allowlist
entries — public OAuth client ID, README placeholders, an old plan doc)
since the privacy remediation work, but no GitHub Action invoked it.
The config was orphan: real protection only when someone ran gitleaks
locally, never gating merges.

This workflow wires `.gitleaks.toml` into CI:

- Triggers on every `pull_request` (any branch) and `push` to `main`.
- Uses `gitleaks/gitleaks-action@v2`, which auto-detects the repo-root
  `.gitleaks.toml` and applies its allowlist.
- Hard-fails on any leak. No `continue-on-error`. Public repo policy.
- `permissions: contents: read` — minimum required scope.
- `fetch-depth: 0` so the action can scan full history (the action's
  default behavior; explicit here for clarity).

Verification path:
- The workflow runs on this PR itself; if any secret were ever committed
  to the repo, the scan fails here. Prior audit confirmed the tracked
  tree is clean of real secrets, so this PR's own scan should pass.

Refs: audit side-finding 4 of 4.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 09:54:36 +10:00
5be369ed68 docs(changelog): normalize version heading format to ## vX.Y.Z — YYYY-MM-DD (#63)
Audit side-finding: heading style drift in CHANGELOG.md.

Before:
  ## v3.12.0 (2026-04-25)     ← parens style (1 entry)
  ## v3.11.1 — 2026-04-21     ← em-dash style (canonical, majority)
  ## v3.11.0 — 2026-04-20     ← em-dash style (canonical, majority)

After: all 3 entries use the em-dash form (2 of 3 entries already used it,
so it's the canonical pattern by majority).

Only the v3.12.0 heading line changed. The body content under each
heading is untouched — only the date-format separator changes from
parens to em-dash.

Verification:
- `grep "^## " CHANGELOG.md` after the edit → all entries match
  `## vX.Y.Z — YYYY-MM-DD`.
- `git diff CHANGELOG.md` shows exactly one line changed.

Refs: audit side-finding 3 of 4.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 09:54:04 +10:00
3d52ffc152 chore(deps): bump engines to >=22.5 to match node:sqlite usage in keys.mjs (#62)
OCP uses Node.js built-in SQLite (`node:sqlite`) in keys.mjs:3 for the
LAN-mode key store. The `node:sqlite` module is only available in:

- Node 22.5.0+ behind --experimental-sqlite flag
- Node 23.0.0+ without any flag (fully stable)

The previous `engines: ">=18"` was inaccurate and would have caused
opaque "Cannot find module 'node:sqlite'" failures for users on
Node 18-22.4 the moment LAN mode (multi-key) was enabled.

Changes:
- package.json: engines.node ">=18" → ">=22.5"
- README.md: prerequisite "Node.js 18+" → "Node.js 22.5+ (Node 23+ recommended …)"
  with a one-line note explaining the flag distinction so users on 22.x know
  they may need --experimental-sqlite.

Verification:
- Source usage of node:sqlite confirmed via grep: keys.mjs:3
  (`import { DatabaseSync } from "node:sqlite";`).
- Local node --version = v25.8.0 (well above 22.5); `npm install` exit 0
  with no engine-mismatch warning.
- No other source file imports node:sqlite (single point of usage).

Refs: audit side-finding 2 of 4.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 09:53:46 +10:00
68b0838074 chore(deps): delete stale package-lock.json (was v3.4.0 vs package.json v3.12.0) (#61)
The repo-tracked package-lock.json declared "version": "3.4.0" while
package.json is at v3.12.0 — 8 minor versions of drift. Since
package.json has zero dependencies (only built-in node:sqlite, node:http,
node:https), the lockfile carried no useful information. It was pure
noise that would mislead anyone running `npm ci` into thinking they were
installing v3.4.0.

Verification:
- package.json has no `dependencies` or `devDependencies` field (grep -A2 '"dependencies"' package.json → no match).
- Fresh clone + checkout + `npm install` → exit 0, "audited 1 package in 93ms, found 0 vulnerabilities".
- node_modules is correctly empty after install (no bin shims to relink).

Refs: audit side-finding 1 of 4.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 09:53:20 +10:00
313cb13a78 docs: align README + governance docs with current state (uninstall, files, ADR index, ship-archive) (#59)
Multiple documentation polish items rolled into one PR (one layer:
"docs alignment with current state").

### README.md

- **Uninstall section** added between Server Setup and Client Setup
  (was missing — `node uninstall.mjs` exists but went undocumented).
- **OpenClaw definition** added as a footnote on first README mention
  (the architecture diagram and Supported Tools table both reference
  OpenClaw without ever defining it).
- **Repository Layout section** added before Security — table of
  top-level files (server.mjs, setup.mjs, uninstall.mjs, keys.mjs,
  models.json, ocp/ocp-connect, dashboard.html, scripts/, .claude/skills/,
  ocp-plugin/, docs/adr/, ALIGNMENT.md, AGENTS.md, CLAUDE.md) so a new
  contributor knows what each file does.
- **LICENSE link** added to the License section footer.

### docs/adr/README.md (new)

- Index of the three published ADRs (0002, 0003, 0004) with a one-line
  description each.
- Explains the `0001` placeholder (early internal proposal that was
  superseded; numbering deliberately starts at `0002`).
- Guidance on when to write a new ADR vs. when a commit message suffices.

### Spec/plan housekeeping

- `specs/.gitkeep` removed (the empty `specs/` placeholder confused the
  picture; canonical paths are `docs/superpowers/plans/` for active plans
  and `docs/superpowers/specs/` for long-lived design docs that other
  code references).
- Shipped plans moved to `docs/superpowers/plans/shipped/`:
  - `2026-04-10-lan-mode.md` (shipped: README LAN mode section)
  - `2026-04-25-47-sse-heartbeat-plan.md` (shipped: v3.12.0 per CHANGELOG)
- `2026-04-25-47-sse-heartbeat-design.md` left in `docs/superpowers/specs/`
  unchanged because both `server.mjs:565` and `CHANGELOG.md:7` link to
  that exact path; moving it would require a `server.mjs` edit, which
  needs `cli.js` citation per ALIGNMENT.md Rule 1.

### AGENTS.md

- Updated "Key files to know" to add `docs/adr/README.md`,
  `docs/superpowers/plans/`, and `memory/constitution.md`.
- Note explaining `memory/constitution.md` is spec-kit's standard
  location, distinct from `~/.cc-rules/memory/` and `ALIGNMENT.md`.
- Updated "Handoff expectations" item 5 from `docs/superpowers/specs/*/tasks.md`
  (which never matched anything — there were no `tasks.md` files there)
  to `docs/superpowers/plans/` (excluding `shipped/`).

### Coordination with PR #53

PR #53 is open and adds "Why OCP?", "Comparison", and "Governance"
sections to README. This PR deliberately avoids those areas — only edits
the Supported Tools table footnote, inserts Uninstall before Client Setup,
inserts Repository Layout before Security, and updates the License footer.
No expected merge conflict.

Refs: audit findings M6, M8, M9, M11, L2, L3, L6.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
2026-05-05 09:44:49 +10:00
e4b010af5e docs(readme): add Why OCP, comparison table, governance section (#53)
Adds three positioning sections to make the README convert clones-to-stars
better:

1. "Why OCP?" near the top — 6 differentiator bullets with evidence links
   (SSE heartbeat / ALIGNMENT.md / models.json SPOT / multi-key /
   per-key quota / ocp-connect).
2. "Comparison" subsection — honest table vs claude-code-router and
   anthropic-proxy. Acknowledges CCR's larger ecosystem; positions OCP
   as cli.js-aligned + subscription-multiplexing focused.
3. "Governance" section near the bottom — links to ALIGNMENT.md, AGENTS.md,
   ADRs, alignment.yml. Consolidates the governance-link surface in one
   place rather than scattered.

Net diff +43 -0. No content removed. No anchors broken. No code changes.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 09:44:37 +10:00
0752f666fb test: wire test-features.mjs to npm test + add minimal CI smoke workflow (#60)
`test-features.mjs` shipped at v3.8.0 (per CHANGELOG of the keys.mjs
quota+cache work) and is referenced from AGENTS.md as the project's only
test artifact, but until now nothing actually ran it — no `npm test`
script, no CI step. Wiring it up so it runs on every push and PR.

### Changes

- `package.json`: add `"test": "node test-features.mjs"` to scripts.
- `.github/workflows/test.yml` (new): single-job workflow that runs
  `npm test` on push to main and on every PR. Uses Node 24 because
  `keys.mjs` imports `node:sqlite`, which is stable in Node 23+ (Node
  24 is the current LTS; Node 22 would need `--experimental-sqlite`).
  No `npm install` step — OCP has zero external runtime dependencies
  per `package-lock.json`.
- `AGENTS.md`: note that `test-features.mjs` runs via `npm test` and
  is enforced by `.github/workflows/test.yml`.

### Why this is a hard check, not a soft check

`test-features.mjs` is self-contained — it imports `keys.mjs` and
exercises the SQLite-backed key/quota/cache code paths against a
throwaway test DB at `~/.ocp/ocp-test.db`. It does NOT require:

- a live claude CLI binary
- a running OCP server
- any network access

So CI can run it as a real check; no `continue-on-error` needed.

### Local verification

```
$ npm test
[...]
=== Results: 24 passed, 0 failed ===
```

24 assertions cover createKey / listKeys / quota math / cache hash
determinism / cache TTL / clearCache. Exit code is 1 on any failure
(`process.exit(failed > 0 ? 1 : 0)` at the bottom of test-features.mjs).

### Future expansion

If the suite later grows to include tests that DO require a live claude
CLI or a running OCP, mark those steps `continue-on-error: true` (or
split them into a separate job). The comment in `test.yml` documents
this contract.

Refs: audit (test-features.mjs orphan / unrunnable in CI).

Co-authored-by: dtzp555 <dtzp555@gmail.com>
2026-05-05 09:44:25 +10:00
ae4a829904 chore(naming): rename package + code refs from openclaw-claude-proxy to OCP (#57)
The npm name `ocp` is squatted (deprecated `node-ocp` v0.0.1), so the
package name is renamed to `open-claude-proxy` (verified via
`npm view open-claude-proxy` → 404, confirming availability).

### Changes

- `package.json`:
  - `"name"`: `openclaw-claude-proxy` → `open-claude-proxy`
  - `"bin"` map UNCHANGED: both `openclaw-claude-proxy` and `ocp`
    binaries still resolve, preserving back-compat for existing installs
    that reference `openclaw-claude-proxy` as a CLI command.
- `setup.mjs`: header JSDoc + start.sh banner string
- `uninstall.mjs`: header JSDoc

### Intentionally NOT changed

- `server.mjs` startup banner (line 1628):
  `console.log('openclaw-claude-proxy v...')`. Editing `server.mjs`
  requires `cli.js:NNNN` citation per ALIGNMENT.md Rule 1, and a
  cosmetic log-string rename has no `cli.js` correspondence. Deferred
  — would need an ALIGNMENT.md scope justification PR if pursued.
- `bin/openclaw-claude-proxy` symlink/path: existing installs depend on
  this name; kept as a back-compat alias.

### Evidence

```
$ npm view ocp
ocp@0.0.1 | DEPRECATED — squatted by node-ocp
$ npm view open-claude-proxy
404 — available
```

Refs: audit (naming consistency).

Co-authored-by: dtzp555 <dtzp555@gmail.com>
2026-05-05 09:44:12 +10:00
51e908e145 chore(repo): remove broken/undocumented Docker files (#58)
The Dockerfile, docker-compose.yml, and .dockerignore are removed because
the Dockerfile is structurally broken AND Docker is not a documented
runtime for OCP.

### Evidence — Dockerfile is broken

The Dockerfile COPYs only 3 files:

```
COPY server.mjs ./
COPY setup.mjs ./
COPY package.json ./
```

But `setup.mjs:43` reads `models.json` at runtime
(`JSON.parse(readFileSync(join(__dirname, "models.json"), ...))`),
and `server.mjs` requires `keys.mjs`, `dashboard.html`, etc. The
container as built would crash on first run.

### Evidence — Docker is undocumented

```
$ grep -i docker README.md AGENTS.md CLAUDE.md
# 0 hits
```

No README install path, no AGENTS.md runtime mention, no CLAUDE.md
release-kit reference. These files were ghost infrastructure.

### Why delete vs. fix

Fixing the Dockerfile to actually work would require: COPYing 6+ files,
verifying the container can write to `~/.openclaw/`, deciding on auth
mounting (claude CLI binary + auth state), and adding documentation
across README/AGENTS/CLAUDE. None of that is justified by a request.

Easier to remove the dead files now and reintroduce a working
Dockerfile when there's an actual demand for containerised OCP, with
proper documentation alongside.

Refs: audit (dead infrastructure cleanup).

Co-authored-by: dtzp555 <dtzp555@gmail.com>
2026-05-05 09:44:00 +10:00
d99534dc35 chore(repo): add .gitignore for runtime artifacts; commit scripts/heartbeat-field-check.sh (#55)
Two related changes — both repo-hygiene, no behavior impact.

### `.gitignore` (new)

Ignores runtime artifacts that should never be tracked:

- `logs/` and `*.log` — proxy.log, last_send.log, heartbeat-field-check log
- `node_modules/` — dependency cache
- `.env`, `.env.*` — local secrets/config
- `.DS_Store`, `*.swp`, `*~` — editor/OS scratch

`logs/` was previously untracked-but-present in working trees; the new
ignore makes that intent explicit and prevents accidental commits.

### `scripts/heartbeat-field-check.sh` (commit existing untracked file)

This script was authored as a one-shot field-evidence gatherer for the
v3.12.0 SSE heartbeat work (PR #49 / issue #47). It fired successfully on
2026-05-02 09:00 Australia/Brisbane via launchd
(`~/Library/LaunchAgents/dev.ocp.heartbeat-check.plist`) and posted a
summary comment to issue #47.

Useful tooling pattern (one-shot field check + launchd schedule + dry-run
flag), worth keeping under version control rather than letting it die in
an untracked working tree.

Verification: `git status` clean after both adds.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
2026-05-05 09:43:47 +10:00
39ca20536e docs(governance): correct CLAUDE.md + AGENTS.md to reflect actual alignment.yml blacklist (#56)
Both `CLAUDE.md` (line 25) and `AGENTS.md` (line 46) previously claimed the
`alignment.yml` blacklist included two tokens — `api/oauth/usage` and
`api/usage`. The actual workflow has a single token in `BLACKLIST`:
`api.anthropic.com/api/oauth/usage`.

Doc-vs-CI drift. Fix is doc-side only — `alignment.yml` itself is unchanged
because adding a second blacklist token (`api/usage`) is a constitutional
change that requires an `ALIGNMENT.md` amendment PR per ALIGNMENT.md
Amendment Procedure.

If `api/usage` should be added to the blacklist, that's a separate PR with
ALIGNMENT.md amendment + reviewer sign-off.

Refs: audit findings (governance doc accuracy).

Co-authored-by: dtzp555 <dtzp555@gmail.com>
2026-05-05 09:43:34 +10:00
8b3f50912e chore(repo): remove v2.x stale openclaw-claude-proxy/ folder + dead start.sh (#54)
Both removed:

- `openclaw-claude-proxy/` — v2.4.0 stale duplicate of the proxy. Current
  proxy is `server.mjs` at repo root (v3.x); the nested folder was an early
  packaging artifact that never got deleted. Audit finding H1.
- `start.sh` — hardcoded `/Users/taodeng/.openclaw/...` paths that only ever
  worked on the original maintainer's home directory. The real install is
  produced by `setup.mjs` (see setup.mjs:212-237 which writes the correct
  per-user start hook). Audit findings H5, H6 (path leak + dead script).

Verification: `git grep "/Users/taodeng/"` returns 0 hits in tracked files.

Refs: audit findings H1, H5, H6.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
2026-05-05 09:43:05 +10:00
780462c763 fix(cli): set DISABLE_AUTOUPDATER=1 in manual restart fallback (#52)
When ocp restart falls back to nohup (no launchctl/systemd
service registered), prepend DISABLE_AUTOUPDATER=1 so the
spawned server.mjs and any claude subprocess it spawns skip
the in-binary auto-updater check.

Why: claude-code's native binary contains an auto-updater
that fires after each successful invocation. Partial
install.cjs failures during update leave bin/claude.exe as
an ASCII shim → spawnSync ENOEXEC → OCP slot lockup
(symptoms in #37, #40 forensics). Setting this env at the
launch site stops the trigger.

Note: only covers the manual nohup path. Users with
launchctl plist or systemd unit must add the env there too
(plist EnvironmentVariables or systemd Environment=).
Authoritative settings location is ~/.claude/settings.json
env key — see learnings/claude_code_disable_autoupdate.md
in cc-rules for the full 4-layer guidance.

Verified: 24h+ stable on Mac mini after applying full
multi-layer fix on 2026-04-30 04:20 (claude.exe mtime
unchanged, OCP uptime 22h54m, 0 errors over 3 real calls).

No server.mjs change — ALIGNMENT.md unaffected.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
2026-05-01 17:36:47 +10:00
83 changed files with 16630 additions and 1864 deletions
-8
View File
@@ -1,8 +0,0 @@
.git
.gitignore
*.md
node_modules
.env
.env.*
scripts/
start.sh
+9
View File
@@ -0,0 +1,9 @@
# GitHub recognizes this file and shows a "Sponsor" button on the repo page.
# Add other platforms here as they get set up. Empty / commented-out entries
# are skipped silently.
buy_me_a_coffee: dtzp555
# github: [dtzp555-max] # uncomment after GitHub Sponsors enrollment is approved
# ko_fi: dtzp555
# custom: ["https://example.com/donate"]
+33 -7
View File
@@ -4,22 +4,46 @@
<!-- One or two sentences describing the change and why it is in scope for OCP. -->
## Endpoint Class (REQUIRED)
Per `ALIGNMENT.md` and ADR 0006, every PR that touches a network-facing endpoint must declare its class. Pick the most specific applicable class (Hybrid covers PRs that touch both A and B):
- [ ] **Class A** — forwards a `cli.js` operation (e.g., `/v1/messages`, `/api/oauth/*`, or the Anthropic-side wire call inside `/usage`)
- [ ] **Class B** — extends an OCP-owned compatibility endpoint (per ADR 0006). Sub-bucket:
- [ ] B.1 — OpenAI-compatibility surface (`/v1/chat/completions`, `/v1/models`)
- [ ] B.2 — OCP-administrative surface (`/health`, `/dashboard`, `/sessions`, `/logs`, `/status`, `/settings`, `/api/keys*`, `/api/usage`, `/cache*`)
- [ ] **Hybrid** — touches both classes (e.g., `/usage` if the PR modifies both the Anthropic wire call AND the local synthesis layer). Both evidence sections below must be filled.
- [ ] **Not endpoint-touching** — refactor / docs / tooling that does not modify any request handler. Skip both evidence sections; explain in Summary.
## Claude Code Alignment Evidence (REQUIRED)
Per `ALIGNMENT.md`, every PR that touches `server.mjs` or any network-facing surface must fill out this section. PRs with this section blank or unchecked will receive a `request changes` review and cannot be merged.
PRs with the relevant evidence section blank or unchecked will receive a `request changes` review and cannot be merged.
### If Class A
- [ ] **Corresponding `cli.js` reference.** I have identified the `cli.js` function and line range that performs the operation this PR forwards. Citation (format `cli.js:NNNN` or `cli.js vE4 <functionName>`):
<!-- e.g. cli.js:18423-18467 (function: sendUserMessage) -->
- [ ] **If `cli.js` does not perform this operation**, I have stated this explicitly below and justified the scope under `ALIGNMENT.md` Rule 2. (Note: in almost all cases this means the PR should be closed, not merged. Proxy layers do not invent endpoints.)
- [ ] **If `cli.js` does not perform this operation**, I have stated this explicitly below and justified the scope under `ALIGNMENT.md` Rule 2. (Note: in almost all cases this means the PR should be closed, not merged. Proxy layers do not invent endpoints. If the endpoint is in fact Class B, switch the class above and use the Class B section instead.)
<!-- Justification, if applicable. Empty is fine when cli.js does perform the operation. -->
- [ ] **Commit message citations.** Every "Claude Code uses X" or "cli.js uses X" assertion in every commit of this PR is immediately followed by a `cli.js:NNNN` or `cli.js vE4 <functionName>` citation. I have verified this by rereading each commit message.
### If Class B
- [ ] **Authorizing ADR.** Cite the ADR number that authorizes the endpoint this PR modifies (e.g., "ADR 0006 — OpenAI shim scope"). For B.1 endpoints (`/v1/chat/completions`, `/v1/models`), this is ADR 0006. For grandfathered B.2 endpoints, this is "ADR 0006 (grandfathered as of v3.16.4)." For new B.2 endpoints, cite the endpoint's own authorizing ADR; if none exists, the PR cannot proceed — the authorizing ADR must be drafted and merged first.
<!-- e.g., ADR 0006 -->
- [ ] **Specification citation.** For B.1 endpoints, link to the relevant section of OpenAI's `/v1/chat/completions` specification (https://platform.openai.com/docs/api-reference/chat/create), including the specific field or behaviour being implemented. For B.2 endpoints with their own ADR, cite the ADR section that specifies the behaviour. For grandfathered B.2 endpoints, the PR must be a behaviour-preserving refactor — link the existing handler code being modified.
<!-- B.1 example: OpenAI chat/completions, `response_format` parameter, https://platform.openai.com/docs/api-reference/chat/create#chat-create-response_format -->
<!-- B.2 example: ADR 00NN § "Behaviour" -->
- [ ] **No invention beyond the specification.** I confirm this PR does not introduce any field or behaviour not present in OpenAI's spec for the endpoint (B.1) or beyond the scope of the authorizing ADR (B.2). For grandfathered B.2 endpoints, I confirm the change is behaviour-preserving (no contract drift). If something the user actually wants is not in the spec, the right answer is to close this PR and propose an upstream spec change or a new ADR.
## Type of change
- [ ] Bug fix (alignment with existing `cli.js` behavior)
- [ ] Feature (new `cli.js` behavior now surfaced through OCP)
- [ ] Bug fix (alignment with existing `cli.js` behavior, or with the cited spec / ADR for Class B)
- [ ] Feature (new `cli.js` behavior now surfaced through OCP, or new field already in OpenAI's spec for Class B)
- [ ] Refactor (no wire-level behavior change)
- [ ] Deletion (unalignable feature removal per `ALIGNMENT.md` Unalignable Policy)
- [ ] Documentation / governance
@@ -28,14 +52,16 @@ Per `ALIGNMENT.md`, every PR that touches `server.mjs` or any network-facing sur
Reviewers: this section is for you, not the author. Do not approve until every box is checked.
- [ ] I opened `cli.js` at the cited line range and confirmed the operation matches.
- [ ] If Class A, I opened `cli.js` at the cited line range and confirmed the operation matches. If Class B, I opened the OpenAI spec at the cited section (B.1) or the authorizing ADR (B.2) and confirmed the behaviour described in this PR matches the cited reference.
- [ ] I ran (or confirmed CI ran) `.github/workflows/alignment.yml` and it passed.
- [ ] I am not the commit author of any commit in this PR (Iron Rule 10).
- [ ] If the PR asserts scope without a `cli.js` citation, I confirmed the justification is sound per `ALIGNMENT.md` Rule 2.
- [ ] If the PR asserts scope without a `cli.js` citation (Class A) or without an ADR (Class B), I confirmed the justification is sound per `ALIGNMENT.md` Rule 2 and ADR 0006.
- [ ] If the PR is Class B and adds a new endpoint or new method, I confirmed the authorizing ADR lands in the same merge or before this PR.
## Related
- `ALIGNMENT.md` Rule(s) invoked: <!-- e.g. Rule 3 -->
- `ALIGNMENT.md` Rule(s) invoked: <!-- e.g. Rule 3, or Rule 3 (Class B mapping) -->
- Authorizing ADR (Class B only): <!-- e.g. ADR 0006 -->
- Related issue / prior PR: <!-- #NNN -->
- Historical lesson reference (if relevant): <!-- e.g. 2026-04-11 drift, b87992f -->
+87 -4
View File
@@ -4,6 +4,11 @@ on:
pull_request:
paths:
- 'server.mjs'
- 'setup.mjs'
- 'scripts/**'
- 'lib/**'
- 'ocp'
- 'ocp-connect'
- '.github/workflows/alignment.yml'
jobs:
@@ -24,10 +29,14 @@ jobs:
exit 0
fi
# Known-hallucinated tokens. Extend only via an ALIGNMENT.md amendment PR.
# Each token is matched as a fixed string against server.mjs only.
# Blacklisted tokens — two kinds (see ALIGNMENT.md "OAuth token-host verification"):
# (1) known LLM hallucinations (e.g. the 2026-04-11 /api/oauth/usage drift), and
# (2) pinned wrong-host variants of a VERIFIED Class A endpoint (a hit means a
# drift to a known-wrong host, not necessarily a hallucination).
# Extend only via an ALIGNMENT.md amendment PR. Matched as fixed strings vs server.mjs.
BLACKLIST=(
"api.anthropic.com/api/oauth/usage"
"console.anthropic.com/v1/oauth/token"
)
FAIL=0
@@ -46,8 +55,8 @@ jobs:
============================================================
server.mjs contains a token on the OCP alignment blacklist.
These tokens were introduced by LLM hallucinations and do
not appear in cli.js at any shipped Claude Code version.
These tokens are either LLM hallucinations that never appeared in cli.js,
or pinned wrong-host variants of a verified Class A endpoint (a drift).
See ALIGNMENT.md -> "Historical Lesson: The 2026-04-11 Drift"
(commit b87992f) for the full incident record.
@@ -66,6 +75,80 @@ jobs:
echo "Blacklist scan clean."
port-spot:
name: port literal SPOT (hard fail)
# Background: from 2026-05-08 (PR #71 dogfood accident) through 2026-05-13
# a hardcoded "3478" in scripts/upgrade.mjs + scripts/doctor.mjs cascaded
# into wrong baseUrl writes for the OpenClaw "claude-local" provider,
# taking out the "大内总管" Telegram agent.
#
# Rule: the only places allowed to write a literal port number in source
# are (a) lib/constants.mjs (the SPOT), (b) bash scripts ocp / ocp-connect
# (which can't import .mjs and must keep the literal in sync — flagged
# with a `// keep in sync with lib/constants.mjs` style comment), and
# (c) test-features.mjs (intentionally pins historical ports for plist /
# systemd parser tests). Everything else MUST import from lib/constants.mjs.
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Scan for hardcoded port literals outside SPOT
shell: bash
run: |
set -euo pipefail
# Files/paths exempt from the SPOT requirement.
EXEMPT_REGEX='^(lib/constants\.mjs|test-features\.mjs|ocp|ocp-connect|CHANGELOG\.md|README\.md|docs/|\.github/workflows/alignment\.yml)'
# Hardcoded port literals to forbid in non-exempt source.
FORBIDDEN_PORTS=("3478" "3456")
FAIL=0
for port in "${FORBIDDEN_PORTS[@]}"; do
HITS="$(git ls-files | grep -E '\.(mjs|js|ts|json)$' \
| xargs grep -n -E "[^0-9]${port}[^0-9]" 2>/dev/null \
| grep -v -E "${EXEMPT_REGEX}" \
|| true)"
if [ -n "$HITS" ]; then
echo "::error::Hardcoded port literal '${port}' found outside lib/constants.mjs:"
echo "$HITS"
FAIL=1
fi
done
if [ "$FAIL" -ne 0 ]; then
cat <<'EOF'
============================================================
PORT LITERAL SPOT VIOLATION
============================================================
A hardcoded TCP port literal was found in a source file
that should import from lib/constants.mjs instead.
Background: this rule exists because between 2026-05-08 and
2026-05-13 a stray hardcoded "3478" in scripts/upgrade.mjs
and scripts/doctor.mjs cascaded into downstream OpenClaw
config writes, taking out the OpenClaw Telegram agent.
See v3.16.3 CHANGELOG and lib/constants.mjs header comment.
Required action:
1. Import DEFAULT_PORT (or related constant) from
lib/constants.mjs instead of hardcoding the literal.
2. If the file genuinely cannot import .mjs (e.g. bash
script), add it to EXEMPT_REGEX in this workflow and
add a `keep in sync with lib/constants.mjs` comment
at the reference.
3. For test files that intentionally pin historical ports
(test-features.mjs), the regex already exempts them.
============================================================
EOF
exit 1
fi
echo "Port SPOT scan clean."
commit-citation:
name: commit message citation (soft check)
runs-on: ubuntu-latest
+32
View File
@@ -0,0 +1,32 @@
name: gitleaks
# Secret scanning gate. Runs on every PR (any branch) and every push to main.
# Configuration is read from the repo-root `.gitleaks.toml` automatically.
# Hard-fails the build on any detected leak — public repo, no tolerance.
#
# To extend the allowlist (e.g. a new known-safe placeholder), edit
# `.gitleaks.toml`. Do not add `continue-on-error` here without an explicit
# governance decision.
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
scan:
name: gitleaks scan (hard fail)
runs-on: ubuntu-latest
steps:
- name: Checkout (full history)
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run gitleaks
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+41
View File
@@ -0,0 +1,41 @@
name: Tests
on:
push:
branches: [main]
pull_request:
jobs:
test-features:
name: test-features.mjs (smoke)
runs-on: ubuntu-latest
# `test-features.mjs` is self-contained — it runs assertions against the
# `keys.mjs` DB layer using a throwaway test database. It does NOT need a
# live claude CLI or a running OCP server. So this job runs as a hard
# check on every push / PR.
#
# If a future expansion of the suite adds tests that DO require a live
# claude CLI or a running OCP server, mark those steps `continue-on-error:
# true` (or split them into a separate job) — CI must not be flaky on
# things outside the contributor's machine.
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
# Node 24 ships `node:sqlite` as stable. The test imports keys.mjs,
# which uses `import { DatabaseSync } from "node:sqlite"`.
# Node 22 also works with `--experimental-sqlite`, but we run on 24
# to keep the CI step simple and to match what released OCP runs on.
node-version: '24'
# OCP has zero runtime npm dependencies (package-lock.json shows only
# the project's own package and zero external entries). No install
# step needed — `node:*` modules are built into Node 24.
- name: Run test-features.mjs
run: npm test
+15
View File
@@ -0,0 +1,15 @@
# Runtime artifacts
logs/
*.log
# Dependencies
node_modules/
# Environment files
.env
.env.*
# Editor / OS scratch
.DS_Store
*.swp
*~
+6 -4
View File
@@ -22,7 +22,7 @@ Runtime: Node.js (ESM, `.mjs` throughout). No build step. No bundler. `server.mj
- `models.json` as the single source of truth for model metadata
- GitHub Actions for CI (`alignment.yml`, `release.yml`)
- `gh` CLI assumed for PR creation and release automation
- No TypeScript. No test framework beyond `test-features.mjs`. Keep dependencies minimal.
- No TypeScript. No test framework beyond `test-features.mjs` (run via `npm test`; CI workflow `.github/workflows/test.yml`). Keep dependencies minimal.
---
@@ -36,14 +36,16 @@ Runtime: Node.js (ESM, `.mjs` throughout). No build step. No bundler. `server.mj
- `ALIGNMENT.md` — the constitution. Binding for any `server.mjs` change. See ADR 0002.
- `.github/workflows/alignment.yml` — CI blacklist grep; fails the build on known-hallucinated tokens.
- `CLAUDE.md` — Claude-Code-specific session instructions + release_kit overlay (Iron Rule 5.5).
- `docs/adr/` — Architecture Decision Records. Read these before proposing governance or SPOT changes.
- `docs/adr/` — Architecture Decision Records. Read these before proposing governance or SPOT changes. See `docs/adr/README.md` for the index.
- `docs/superpowers/plans/` — active spec-kit plans. `docs/superpowers/plans/shipped/` archives plans that have been delivered (don't propose changes against shipped plans — they're history). `docs/superpowers/specs/` holds long-lived design documents that other code references (e.g., the SSE heartbeat design referenced from `server.mjs`).
- `memory/constitution.md` — spec-kit's project constitution (its standard `memory/` location). Distinct from `~/.cc-rules/memory/` (cross-machine personal memory) and from this repo's `ALIGNMENT.md` (the OCP code-level constitution).
---
## Project-specific constraints
- **`ALIGNMENT.md` is binding.** Any PR touching `server.mjs` must cite `cli.js:NNNN` (or `cli.js vE4 <functionName>`) in the commit body and PR description. See `CLAUDE.md` § "Hard requirements for `server.mjs` changes" and ADR 0002.
- **Alignment CI is not suppressible.** The `alignment.yml` workflow greps `server.mjs` for known-hallucinated tokens (including `api/oauth/usage`, `api/usage`). Adding to the blacklist is fine; removing entries requires an `ALIGNMENT.md` amendment PR.
- **Alignment CI is not suppressible.** The `alignment.yml` workflow greps `server.mjs` for known-hallucinated tokens (currently blocking `api.anthropic.com/api/oauth/usage`). Adding new tokens is done via PR amendment to `alignment.yml`; removing entries requires an `ALIGNMENT.md` amendment PR.
- **No self-approval.** Implementation author cannot merge their own PR (Iron Rule 10). A fresh-context reviewer must open `cli.js` at the cited lines and confirm in the review comment.
- **`models.json` is the only place to add/edit models.** Do not touch `MODEL_MAP` or `MODELS` arrays directly in `server.mjs` or `setup.mjs`. See ADR 0003.
- **OpenClaw boundary.** `scripts/sync-openclaw.mjs` only writes `models.providers["claude-local"].models` and `agents.defaults.models["claude-local/*"]` in `~/.openclaw/openclaw.json`. Do not expand scope. See ADR 0004.
@@ -66,7 +68,7 @@ A fresh session picking up OCP work should read, in order:
2. `ALIGNMENT.md` — constitution; non-optional.
3. `CLAUDE.md` — tool-specific instructions and release_kit overlay.
4. `docs/adr/` — most recent ADRs first; they explain why the current structure exists.
5. Any active spec under `docs/superpowers/specs/*/tasks.md` (if present).
5. Any active plan under `docs/superpowers/plans/` (excluding `shipped/` which is the archive).
6. `~/.cc-rules/memory/auto/MEMORY.md` — cross-machine memory index.
Only after these should the session touch code.
+86 -4
View File
@@ -8,10 +8,14 @@
OCP (Open Claude Proxy) is a **proxy layer** for the Claude Code CLI. It forwards, observes, and multiplexes the traffic that `cli.js` already emits. It is **not** an extension layer. If `cli.js` does not perform a given operation, or performs it differently, OCP does not invent one.
This Core Principle applies in full to **Class A** endpoints (the `cli.js`-mirror surface). A second class of endpoint — **Class B**, the OCP-owned compatibility surface — has its own scope discipline anchored to its own specification authority. See "Scope Clarification: OCP-Owned Compatibility Endpoints (Class B)" below and ADR 0006.
---
## Rules
The following Rules apply to **Class A operations** (the `cli.js`-mirror surface — the inbound `/v1/messages` forwarding route, the outbound `/v1/messages` wire call used by `handleUsage()` for rate-limit-header extraction, the OAuth bearer machinery, and any future operations OCP forwards from `cli.js` to Anthropic). For the Class B mapping of each rule, see the Class B section below.
1. **Rule 1 (Grep First).** Before adding, renaming, or changing any endpoint, header, parameter, or response shape, the author must `grep` the reference `cli.js` and record the exact line numbers in the commit message and PR body. An absent grep hit is itself a finding and must be declared.
2. **Rule 2 (No Invention).** OCP must not introduce endpoints, headers, request fields, or response fields that are not present in `cli.js`. Speculative "Claude Code probably uses X" statements are prohibited. If the behavior is not observable in `cli.js`, the feature is out of scope.
@@ -48,6 +52,26 @@ OCP (Open Claude Proxy) is a **proxy layer** for the Claude Code CLI. It forward
The audit pin is updated once per year (see Annual Alignment Audit) and whenever a drift incident forces a re-verification.
### OAuth token-host verification (2026-05-31)
Motivating evidence: the 2026-05-31 code audit (issues #112 / #119 / #123). The OAuth bearer
machinery is a Class A surface (Rules 15). Because `cli.js` now ships as a
compiled binary, the token-refresh host was re-verified against `claude.exe` (Claude Code
`2.1.154`) on 2026-05-31 using the compiled-binary protocol — `strings` on the Mach-O, **no
live OAuth probe** (a `refresh_token` grant would rotate the operator's real credentials):
- **Verified host:** `https://platform.claude.com/v1/oauth/token` — present in the binary
byte-for-byte, paired with `OAUTH_CLIENT_ID` in the same `prod` config object (matches
`server.mjs` `OAUTH_TOKEN_URL` / `OAUTH_CLIENT_ID`). The legacy `console.anthropic.com/v1/oauth`
host is absent (0 hits).
- **Pinned wrong-host variant:** `console.anthropic.com/v1/oauth/token` is added to the
`alignment.yml` blacklist so a future accidental revert to the legacy host hard-fails CI.
The blacklist therefore now holds two kinds of token: (1) known hallucinations (e.g.
`api.anthropic.com/api/oauth/usage`, the 2026-04-11 drift), and (2) pinned wrong-host variants
of a *verified* Class A endpoint. A blacklist hit means either a re-introduced hallucination
**or** a drift to a known-wrong host — both are alignment failures under Rules 2 and 3.
---
## Historical Lesson: The 2026-04-11 Drift
@@ -68,20 +92,78 @@ On 2026-04-11, commit `b87992f` ("fix: use dedicated /api/oauth/usage endpoint f
## Unalignable Policy
A feature is **unalignable** if, after a good-faith search, it cannot be mapped to a specific `cli.js` line range or function.
A feature is **unalignable** if, after a good-faith search, it cannot be mapped to a specific `cli.js` line range or function (Class A) or to a specific OpenAI specification section AND an authorizing ADR (Class B).
- Unalignable features are **deleted**, not disabled, not feature-flagged, not deprecated.
- Deletion is the default outcome of an alignment audit finding. The burden of proof is on the feature, not on the auditor.
- A deletion PR does not require user-facing deprecation notice, because the feature was never legitimately in scope.
- If a user workflow depended on an unalignable feature, the correct remediation is to upstream the behavior into `cli.js` or to move it out of OCP into a separate tool. OCP does not retain it.
- If a user workflow depended on an unalignable feature, the correct remediation is to upstream the behavior into `cli.js` (Class A) or into OpenAI's spec (Class B) or to move it out of OCP into a separate tool. OCP does not retain it.
---
## Scope Clarification: OCP-Owned Compatibility Endpoints (Class B)
OCP has two classes of endpoint. Rules 15 above were drafted in the aftermath of the 2026-04-11 forwarding drift and are written in the language of a one-to-one proxy; they apply verbatim to **Class A** endpoints. **Class B** endpoints — the OCP-owned compatibility surface where `cli.js` is not the wire authority — have their own scope discipline, anchored to their own specification authority. The full rationale lives in **ADR 0006 (OpenAI Shim Scope)**.
**Class A**`cli.js`-mirror endpoints. The endpoint exists because `cli.js` performs the equivalent operation and OCP forwards, observes, or multiplexes that operation. Rules 15 above apply verbatim. Citation format: `cli.js:NNNN` or `cli.js vE4 <functionName>`.
**Class B** — OCP-owned compatibility endpoints. The endpoint exists because OCP itself surfaces it, with no `cli.js` analogue. Two sub-buckets: **B.1** (OpenAI-compatibility surface — protocol authority is OpenAI's `/v1/chat/completions` specification) and **B.2** (OCP-administrative surface — authority is the ADR that authorized the endpoint's existence).
### Grandfather provision for existing B.2 inventory
ADR 0006 retroactively authorizes the B.2 endpoints listed in the inventory table below, **frozen at their current behaviour as of v3.16.4**. This is a one-time provision; it does not extend to new B.2 endpoints or to B.1 endpoints. Any change to the contract (request shape, response shape, semantics) of a grandfathered B.2 endpoint is treated as a new authorization request and requires either a behaviour-preserving refactor PR or its own ADR. Any new B.2 endpoint, or any new method on a grandfathered B.2 endpoint, requires its own ADR before merge.
### Current Class B inventory
| Endpoint | Method | Sub-bucket | Authorizing ADR |
|---|---|---|---|
| `/v1/chat/completions` | POST | B.1 (OpenAI-compat) | ADR 0006 |
| `/v1/models` | GET | B.1 (OpenAI-compat) | ADR 0006; content sourced from `models.json` per ADR 0003 |
| `/health` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/dashboard` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/sessions` | GET, DELETE | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/logs` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/status` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/settings` | GET, PATCH | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/api/keys` | GET, POST | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/api/keys/:id` | DELETE | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/api/keys/:id/quota` | GET, PATCH | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/api/usage` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/cache/stats` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/cache` | DELETE | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
**Hybrid note.** `/usage` is a hybrid endpoint: the underlying call to `api.anthropic.com/v1/messages` (used to extract `anthropic-ratelimit-unified-*` headers, per the in-file comment block at `server.mjs` line 845849) is Class A and requires the standard `cli.js` citation; the local synthesis layer that adds `proxy:` stats and `models:` snapshot is Class B and is authorized by ADR 0006. A PR touching only the wire-call layer is Class A; a PR touching only the synthesis layer is Class B; a PR touching both must satisfy both citation requirements.
### Class B citation requirement
Class B PRs cite **the relevant specification section + the authorizing ADR**, in place of `cli.js:NNNN`. Examples:
- B.1: "OpenAI `chat/completions` API, `response_format` parameter (https://platform.openai.com/docs/api-reference/chat/create), authorized by ADR 0006."
- B.2 (grandfathered): "Authorized by ADR 0006 (grandfathered as of v3.16.4)."
- B.2 (with its own ADR): "Authorized by ADR 00NN (the ADR that originally authorized the endpoint)."
### Rule mapping for Class B
| Class A rule | Class B mapping |
|---|---|
| Rule 1 (Grep First) | Read the cited OpenAI spec section (B.1) or the authorizing ADR (B.2) before writing code. Record the spec URL and ADR number in the PR body. |
| Rule 2 (No Invention) | OCP must not introduce fields or behaviour not present in OpenAI's spec for the endpoint (B.1) or outside the scope of the authorizing ADR (B.2). For grandfathered B.2 endpoints, "scope" is the v3.16.4 behaviour snapshot. |
| Rule 3 (Match the Implementation) | Match OpenAI's spec wire-format (B.1) or the ADR's specified behaviour (B.2). |
| Rule 4 (Unalignable Features Are Deleted) | A Class B endpoint that maps to nothing in OpenAI's spec **and** lacks an authorizing ADR (including not being in the grandfather inventory) is unalignable and is deleted on the same terms as a Class A unalignable feature. |
| Rule 5 (Cite Line Numbers in Commits) | Cite the OpenAI spec section URL + authorizing ADR number in the commit body (B.1) or the authorizing ADR number alone (B.2). |
### New Class B endpoint procedure
Any new Class B endpoint, or any new method on an existing Class B endpoint (including grandfathered ones), requires its own ADR before merge. An "ADR-less" new Class B endpoint is itself an alignment finding under Rule 4.
---
## Annual Alignment Audit
- **Date:** 11 April each year (the anniversary of the `b87992f` drift).
- **Scope:** Diff the current `cli.js` against the pinned SHA-256 in the Golden Reference section. For every network call in `server.mjs`, re-verify that the corresponding `cli.js` reference still exists at the cited line numbers (adjust citations if line numbers shifted across Claude Code versions).
- **Output:** A signed audit note committed to `docs/alignment-audits/YYYY-04-11.md`, updating the pin.
- **Scope (Class A):** Diff the current `cli.js` against the pinned SHA-256 in the Golden Reference section. For every network call in `server.mjs`, re-verify that the corresponding `cli.js` reference still exists at the cited line numbers (adjust citations if line numbers shifted across Claude Code versions).
- **Scope (Class B):** Audit B.1 endpoints against OpenAI's current `/v1/chat/completions` specification snapshot. Audit B.2 endpoints against their authorizing ADR — for grandfathered endpoints, verify the endpoint behaviour still matches its v3.16.4 snapshot; for ADR-specific endpoints, verify behaviour still matches the ADR. The B.1 specification pin lives in `docs/openai-compat-pin.md` (created alongside the first B.1 audit; not required for ADR 0006 to land).
- **Output:** A signed audit note committed to `docs/alignment-audits/YYYY-04-11.md`, updating the Class A pin and (once `docs/openai-compat-pin.md` exists) the B.1 pin.
- **Failure mode:** Any audit finding that cannot be reconciled triggers an immediate deletion PR per the Unalignable Policy.
---
+443 -1
View File
@@ -1,6 +1,448 @@
# Changelog
## v3.12.0 (2026-04-25)
## 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.
### TUI dead-code / footgun cleanup
- **A1 — removed inert entrypoint-env path** (`lib/tui/session.mjs`): deleted `resolveTuiEntrypointEnv()` and the redundant env-strip block in `runTuiTurn`. The `{env}` object passed to `spawnSync` (tmux itself) was the wrong target — tmux does NOT forward the spawning process's environment to the pane; the pane's `claude` gets its env exclusively from the `env` prefix string built inside `buildTuiCmd` (verified live 2026-06-01). The spawnSync env is now intentionally minimal (`HOME` only). Behavior is unchanged: `buildTuiCmd` already handled all claude-specific env vars via its prefix string.
- **A2 — removed test-only transcript helpers** (`lib/tui/transcript.mjs`): deleted `encodeCwd()` and `transcriptPath()` exports and the tests that pinned them. Production resolves transcripts exclusively via `findTranscriptPath()` (glob by session-id), which is immune to the exact path-encoding rule. No non-test importers existed (grep confirms). A `// TODO` comment near `findTranscriptPath()` notes that a CI fixture-contract test would make claude-schema drift fail loudly.
- **A3 — removed headless-unusable `--dangerously-skip-permissions` branch** (`lib/tui/session.mjs` + `README.md`): `OCP_TUI_FULL_TOOLS=1` now always takes the `--allowedTools` path. The removed branch pushed `--dangerously-skip-permissions` when `CLAUDE_SKIP_PERMISSIONS=true`; on claude v2.1.x this triggers an interactive bypass-acceptance screen that a headless tmux pane cannot answer → the turn hangs to the wallclock cap and bricks the pane. The working path is `--allowedTools` + scratch-home `settings.json` `additionalDirectories`. `CLAUDE_SKIP_PERMISSIONS` for the `-p` path is unchanged (still used in `server.mjs`).
### Docs
- **Client-tools boundary** (README `§ How It Works`): OCP is a text-prompt bridge only — it does not pass OpenAI `tools`/`functions` or Anthropic `tool_use` blocks to the client. Clients receive assistant TEXT only; client-local tool execution is not supported by design (bypassing `cli.js` = out of scope per `ALIGNMENT.md`).
- **ToS honesty** (README `§ Deployment model & security`): pooling one Claude subscription across multiple distinct people may violate Anthropic's Consumer ToS and risk account suspension by the abuse classifier. The defensible framing is "one person, your own devices" — friends/team sharing is not. The prior language ("account terms are your call") was accurate but understated the risk.
- **"Why OCP" posture** (README `§ Why OCP?`): new bullet making explicit that OCP drives the official `claude` CLI as-is — no OAuth token extraction, no binary patching, no protocol invention — so traffic looks like genuine Claude Code (`cc_entrypoint=cli`).
- **Promotion plan** (`docs/PROMOTION.md`): "stable & visible" strategy covering goal (polish + low-key OSS visibility, NOT growth-hacking given the live ToS/billing risk), pre-requisites (stability first), honest ToS disclosure requirement, items explicitly skipped (multi-backend routing → OLP; gateway model-discovery; raw API passthrough → ALIGNMENT.md scope), TUI toggle as billing-split insurance, and low-key visibility actions. Framed as a recommendation for the maintainer to review, not a committed plan.
### Previously shipped (v3.20.x) — documented here for completeness
- **Default `-p` spawn-home isolation** (v3.20.0 / PR-A): per-request `claude` spawns run in a credential-free minimal scratch HOME (`$HOME/.ocp/spawn-home`, no `.credentials.json`/`settings.json`/plugins) with a neutral cwd and the env token, cutting per-request latency (measured ~1028s → ~37s). Kill-switch: `OCP_SPAWN_REAL_HOME=1`. Active mode shown at startup and on `/health.spawn`.
- **Bounded concurrency wait-queue** (v3.20.0 / PR-B): excess `-p` requests queue (up to `CLAUDE_MAX_QUEUE`, default 16) instead of being rejected; a full queue returns `HTTP 429` + `Retry-After` (not an opaque 500). New env vars: `CLAUDE_MAX_QUEUE`, `CLAUDE_QUEUE_RETRY_AFTER`. Surfaced on `/health.concurrency` + `/health.stats.queueRejections`.
- **`ocp restart`** macOS `bootout`+`bootstrap` (v3.20.0 / PR-B): safe restart command that forces launchd to re-read the plist (unlike `kickstart -k` which reuses the cached env).
- **`/ocp` plugin OpenClaw-2026.5.27 compat** (v3.20.0 / PR-C): gateway plugin updated for the current OpenClaw API version.
## v3.20.1 — 2026-06-13
TUI-mode auth hardening: fixes the recurring `Please run /login · API Error: 401` (the PI231 incident) and reaps leaked defunct `claude` sessions. ([#141](https://github.com/dtzp555-max/ocp/pull/141))
### Fixed
- **TUI 401 / credential corruption (#141)** — interactive `claude` prefers `~/.claude/.credentials.json` over the `CLAUDE_CODE_OAUTH_TOKEN` env var (unlike `-p` mode, where the env token wins). OCP TUI's per-request spawn + `kill-session` cycle raced claude's single-use refresh-token rotation, corrupting the refresh token to an empty string → permanent 401 that `claude /login` couldn't fix (each new spawn re-corrupted it). This bit Linux/file-based hosts specifically (macOS reads credentials from the Keychain, so Mac mini was immune). **Fix:** when `CLAUDE_CODE_OAUTH_TOKEN` is set, the TUI claude now runs in a credential-free scratch HOME (`<HOME>/.ocp-tui/home`, overridable by `OCP_TUI_HOME`) seeded with onboarding + cwd-trust but **no `.credentials.json`**, so the env token is the only credential and claude never runs the refresh path. Recurrence-proof — a later `claude login` can no longer break TUI. Also: `buildTuiCmd` passes `CLAUDE_CODE_OAUTH_TOKEN` to the spawn, and `reapStaleTuiSessions` reaps defunct `claude` sessions (tmux-server-owned zombies) via `kill-server` when no foreign session remains, plus a 15-min idle-gated periodic reap. When the env token is unset, behaviour is byte-for-byte unchanged (real-home + credentials.json). Two independent fresh-context reviewers (Iron Rule 10) + a live PI231 portability test (works with a corrupt credentials.json present). Authorized by the ADR 0007 PR-D amendment (Class B).
### Environment variables
- `CLAUDE_CODE_OAUTH_TOKEN` — when set on a TUI host, TUI authenticates via this long-lived token in a credential-isolated home (recommended; immune to credentials.json corruption).
- `OCP_TUI_HOME` — overrides the TUI scratch home; if you previously pointed it at your real home, unset it to get the credential-isolated default.
## v3.20.0 — 2026-06-10
TUI-mode billing-safety hardening for the 2026-06-15 Anthropic billing split. A 5-dimension multi-agent audit (adversarial verification + live tests on all three hosts — PI231 / Oracle / Mac mini, claude 2.1.104 / 2.1.114 / 2.1.170) found the TUI subscription-pool path could silently bill the metered Agent SDK pool or poison the cache under realistic failure modes. Three PRs, each with a fresh-context reviewer (Iron Rule 10) and CI; the default path (`CLAUDE_TUI_MODE` unset) is byte-for-byte unchanged.
### TUI — honesty & cache correctness (#137)
- **C-1** — `callClaudeTui` now throws on a claude-CLI auth-failure banner (e.g. `Please run /login · API Error: 401 …`, `Failed to authenticate. API Error: 401 …`) instead of returning it as a real answer, so it is never cached, singleflight-shared, or counted as a model success. Conservative detector (whole trimmed text ≤100 chars + `API Error: 4xx` + auth keyword + no code/quote char); overridable via `CLAUDE_TUI_ERROR_PATTERNS`. Live-reproduced on PI231.
- **C-2** — `readTuiTranscript` distinguishes a complete turn from a wallclock-truncated partial (`truncated` flag); `callClaudeTui` throws `tui_wallclock_truncated` so a partial is never cached or counted as success.
- **C-3** — `verifyEntrypoint` reads the `entrypoint` field from any transcript line, not just `{system, turn_duration}` — some claude builds emit zero turn_duration lines (live-confirmed on Oracle's claude 2.1.114), which previously left the billing-drift assertion blind on those builds.
- **C-4 (paste)** — short prompts (e.g. `hi`) could never pass paste-landing detection; threshold lowered. Live-reproduced on PI231.
### TUI — concurrency & observability (#139)
- **Concurrency** — `OCP_TUI_MAX_CONCURRENT` (default 2) bounds concurrent interactive `claude` boots via a queuing semaphore (`lib/tui/semaphore.mjs`); the slot is released on throw so honesty-gate / spawn failures never leak it; bounded wait-queue → `tui_queue_full` (503). Independent of the global `MAX_CONCURRENT` (8) — a TUI turn is a heavy per-request cold-boot of tmux+claude + up to 120s wallclock.
- **Observability** — additive `/health` `tui` block (`enabled` / `entrypointMode` / `lastEntrypoint` / `entrypointMismatches` / `inflight` / `maxConcurrent`) so an operator can poll for a silent `sdk-cli` metered-pool drift (the audit's top risk) instead of grepping journald. Authorized by the ADR 0007 PR-B amendment under the ALIGNMENT grandfather provision (additive, behaviour-preserving — every pre-existing `/health` field unchanged).
### Operations (#138)
- `docs/runbooks/615-canary.md` — the 2026-06-15 credit-balance canary: quiesce, read the Agent SDK credit balance (manual — no programmatic API exists for that pool; OCP's `/usage` headers are subscription rate-limit data, not the credit pool), one TUI canary turn, confirm `entrypoint:cli` in the transcript, green/red decision tree, periodic auto-mode self-classification mini-canary.
- `docs/runbooks/tui-flip-rollback.md` — flip/rollback per deployment (systemd `daemon-reload`; launchd `bootout`/`bootstrap`, not `kickstart -k`).
- `setup.mjs` auth quick-test gated behind `OCP_SKIP_AUTH_TEST=1` (the `claude -p` probe draws from the metered Agent SDK pool after 6/15).
### New environment variables
- `OCP_TUI_MAX_CONCURRENT` — max concurrent interactive TUI turns (default 2) (#139).
- `OCP_SKIP_AUTH_TEST` — skip the `claude -p` auth probe in `setup.mjs` (default off) (#138).
## v3.19.0 — 2026-06-02
TUI-mode reliability + proxy-purity release. Two fixes diagnosed and verified live on both test hosts (PI231 / Oracle, claude 2.1.104 / 2.1.114), each its own PR with a fresh-context reviewer (Iron Rule 10), then an adversarial multi-host test battery (0 hangs / 0 crashes / 0 injection / 0 leaks). The default path (`CLAUDE_TUI_MODE` unset) is byte-for-byte unchanged.
### TUI
- **#130** — Fixed the "stuck typing" hang on large multi-line prompts. Three root causes: (1) terminal-turn detection only recognized `{system, turn_duration}`, which older claude builds (e.g. 2.1.114) don't emit → the reader ran to the wallclock and returned partial text; now also accepts an `assistant` line with a final `stop_reason` (`end_turn`/`stop_sequence`/`max_tokens`), while `tool_use` stays non-terminal. (2) Large prompts pasted via `send-keys -l` delivered embedded newlines as separate Enter events → the prompt never landed; now uses `tmux load-buffer` + `paste-buffer -p` (bracketed paste, atomic). (3) The paste-landed check false-positived on claude's empty curly-quote placeholder → Enter fired into an empty box; now positive-signal-only (`[Pasted text]` / prompt text) with a readiness/paste-verify poll + fast-fail (deterministic ~5s error instead of a 120s wallclock hang).
- **#4** — TUI-mode never injects the host's `CLAUDE.md` / auto-memory into proxied turns. OCP is a proxy: the proxied client (OpenClaw / an IDE) owns its own context and memory. `buildTuiCmd` now always sets `CLAUDE_CODE_DISABLE_CLAUDE_MDS` + `CLAUDE_CODE_DISABLE_AUTO_MEMORY` (unconditional — proxy purity is not an opt-in). Verified live with a marker `CLAUDE.md`: obeyed by the proxied turn before the fix, blocked after, on both hosts. Residual host-context vectors (managed-policy / `settings.json` / output-styles) tracked in #133. The env is delivered via an `env`-prefix on the tmux pane command (tmux does not forward the spawning process's environment, and `new-session -e` requires tmux ≥3.2 while the cloud host runs 2.7).
## v3.18.0 — 2026-06-01
Hardening release from a multi-agent code audit (1 P0 + 14 P2 + 2 P3 findings, each adversarially verified and independently reviewed) plus three follow-ups (#123#125). Every change shipped as its own PR with a fresh-context reviewer (Iron Rule 10). The single-user default path (`AUTH_MODE=none`, no TUI) is behavior-identical **except** the `/health` change in #109.
### Security
- **#109 (P0)** — `/health` no longer advertises `PROXY_ANONYMOUS_KEY` to remote callers by default. The `anonymousKey` field is gated behind a new `PROXY_ADVERTISE_ANON_KEY=1` opt-in env var; localhost callers are always exempt. Prevents any LAN-reachable device from harvesting a working, quota-spending bearer credential from the unauthenticated `/health` endpoint. **Behavior change:** `ocp-connect` zero-config Path A now requires the server to set `PROXY_ADVERTISE_ANON_KEY=1`; otherwise pass `--key` or use anonymous access.
- **#114** — Dashboard escapes all DB-sourced strings (key names, usage rows) before `innerHTML`; the revoke button uses a `data-` attribute + listener instead of an inline `onclick` a quote could break out of; `POST /api/keys` validates key names server-side (`[A-Za-z0-9 ._-]{1,64}`).
- **#124** — Dashboard status/plan summary cards escaped too (uniform defense-in-depth over all `innerHTML` sinks).
- **#111** — Streaming error paths strip filesystem paths from claude error text / stderr before sending them to clients (`sanitizeError`), matching the non-streaming path.
### Reliability / correctness
- **#110** — Non-array `messages` is rejected with a 400 (was silently hanging the connection until socket timeout); OpenAI array `content` is flattened into the prompt instead of dumped as raw JSON; a streamed upstream error now emits an SSE `error` frame instead of a success-looking `finish_reason:"stop"`.
- **#111** — `res.on("close")` escalates SIGTERM→SIGKILL on client disconnect (closes a narrow re-occurrence of the #37 concurrency-slot leak on the hottest exit path); `overallTimer` is cleared on semantic completion so a slow-exiting child can't record a spurious post-success timeout; per-key quota is documented as best-effort (bounded overshoot ≤ `MAX_CONCURRENT`, cache hits uncounted).
- **#113** — CLI/installer hardening: `ocp-plugin` restart uses the live uid + `dev.ocp.proxy`/`ocp-proxy` labels and drops the unsafe `pkill` fallback; `ocp-connect` quotes + `chmod 600`s the persisted key; `setup.mjs` XML-escapes and newline-validates injected service-unit secrets.
### Alignment / governance
- **#112** — OAuth token-refresh host (`platform.claude.com/v1/oauth/token`) re-verified against the compiled cli.js v2.1.154 (`strings`, no live probe) and recorded in `ALIGNMENT.md`; usage-probe and default request model now derive from `models.json` (ADR 0003 SPOT) instead of hardcoded IDs.
- **#123** — The legacy `console.anthropic.com/v1/oauth/token` host is pinned in the `alignment.yml` blacklist so a future OAuth-host drift hard-fails CI; the blacklist now documents its dual purpose (known hallucinations + pinned wrong-host variants of a verified Class A endpoint).
### TUI
- **#115** — The TUI LAN gate refuses any non-loopback bind (not just literal `0.0.0.0`); the achieved `cc_entrypoint` is asserted each turn and a `tui_entrypoint_mismatch` warning is logged on a silent degrade to the metered sdk-cli pool.
### Refactor
- **#125** — `isLoopbackBind` extracted to `lib/net.mjs`, shared by `server.mjs` and the test suite (was duplicated via a copy-paste mirror).
### New environment variables
- `PROXY_ADVERTISE_ANON_KEY` — opt-in (default off); advertise `PROXY_ANONYMOUS_KEY` on the public `/health` body for remote zero-config discovery (#109).
## v3.17.1 — 2026-05-31
### Fix — code-audit P1/P2 hardening
Fixes from a multi-agent code audit (3 P1 + 5 P2, adversarially verified). The single-user default path (`AUTH_MODE=none`, no TUI) is behavior-identical.
**Availability / correctness (P1):**
- Guard `proc.stdin` against EPIPE — a fast-failing spawned `claude` (auth error, bad model, large prompt) no longer crashes the single-process daemon.
- Add `unhandledRejection`/`uncaughtException`/`clientError` safety nets + wrap all request-body read loops — a client aborting mid-upload no longer crashes the daemon.
- TUI transcript reader: only `turn_duration` is terminal (was also `tool_use`), which silently truncated any TUI turn that used a built-in tool.
**Security gates / cache integrity (P2):**
- `AUTH_MODE=multi`: the default spawn now passes `--disallowedTools` (Bash/Read/Write/Edit/…) so a guest prompt cannot drive operator-filesystem tools. Single-user path unchanged.
- `/sessions` (DELETE), `/settings` (PATCH), `/logs`, `/usage`, `/status` are now admin-gated (were dispatched before the admin check).
- Streaming path no longer caches an `is_error` response as success (cache-poisoning fix).
- TUI fail-loud guard extended to `none`+`0.0.0.0` (unless `OCP_TUI_ALLOW_LAN=1`) and `+ PROXY_ANONYMOUS_KEY`.
- TUI `send-keys` paste uses `-l` (literal) so a prompt equal to a tmux key token (e.g. `C-c`) is typed, not interpreted.
---
## v3.17.0 — 2026-05-31
### Provider — default claude invocation ported to stream-json + `--system-prompt` (Phase 6c)
OCP's default (non-TUI) claude spawn moves from `claude -p --output-format text` to `claude --output-format stream-json --verbose --no-session-persistence --system-prompt <wrapper>` (no `-p`). The NDJSON event stream is parsed into the assembled response. Benefits: ~64% per-request cost reduction and anti-hallucination via `--system-prompt` tool-use suppression. Clients see no API change — the OpenAI-compatible request/response shapes are identical. Faithful port of OLP's production-verified implementation; covered by 17 new stream-json parser tests.
⚠️ **Billing note:** from 2026-06-15 this default path carries `cc_entrypoint=sdk-cli` and bills against the Agent SDK credit pool. Use the new opt-in `CLAUDE_TUI_MODE` (below) to keep traffic on the Pro/Max subscription pool.
---
### feat(tui): opt-in CLAUDE_TUI_MODE — serve via interactive claude (cc_entrypoint=cli / subscription pool), single-user only; default stream-json path unchanged
From 2026-06-15 Anthropic routes `claude -p` / `--output-format` invocations to the Agent SDK credit pool (`cc_entrypoint=sdk-cli`). This feature adds an opt-in bridge: when `CLAUDE_TUI_MODE=true`, OCP serves each request via a real interactive `claude` session (no `-p`, no `--output-format`) so it carries `cc_entrypoint=cli` and bills against the Pro/Max subscription.
The complete string response is read from claude's native JSONL session transcript and replayed to callers as a normal OpenAI completion or chunked SSE. Clients see no API change. The default stream-json path is byte-for-byte unchanged when `CLAUDE_TUI_MODE` is unset.
**Security:** single-user / single-operator only. Never enable on a multi-user OCP. See ADR 0007 and README § "Subscription-pool (TUI) mode".
New env vars: `CLAUDE_TUI_MODE`, `CLAUDE_TUI_WALLCLOCK_MS`, `OCP_TUI_CWD`, `OCP_TUI_HOME`.
New ADR: `docs/adr/0007-tui-interactive-mode.md`.
New modules: `lib/tui/transcript.mjs`, `lib/tui/session.mjs` (shipped in preceding commits on this branch).
---
### Model — add claude-opus-4-8
Add `claude-opus-4-8` as the newest Opus to `models.json` (index 0, newest first). Repoint `aliases.opus` from `claude-opus-4-7` to `claude-opus-4-8`. `claude-opus-4-7` remains in the list callable by literal id. `legacyAliases.claude-opus-4` left pointing at `claude-opus-4-7` (no change — legacy alias tracks the prior generation). README Available Models table and model-count references updated accordingly.
---
## v3.16.4 — 2026-05-13
### Refactor — port-literal SPOT + CI guardrail
Closes the structural side of the port-drift cascade addressed by v3.16.2
and v3.16.3. Those two releases reverted plist / plugin / scripts back to
3456 line-by-line, but the underlying invitation to drift — a hardcoded
port literal scattered across six source files — was still intact.
Changes:
- **New `lib/constants.mjs`** — single source of truth for shared literals.
Exports `DEFAULT_PORT = 3456`, `LOCAL_HOST = "127.0.0.1"`,
`OPENAI_API_BASE = "/v1"`, `LOCAL_PROXY_URL`.
- **`server.mjs:127`, `setup.mjs:36`, `scripts/upgrade.mjs:137`,
`scripts/doctor.mjs:84` + `:205`, `scripts/sync-openclaw.mjs:73`** —
all replaced with imports from `lib/constants.mjs`. Behavior is
identical; the literal `3456` now exists in exactly one place per
language (`lib/constants.mjs` for `.mjs`, `ocp` + `ocp-connect` for
bash, `test-features.mjs` for pinned historical-port tests).
- **`.github/workflows/alignment.yml`** — extended the path filter to
`setup.mjs`, `scripts/**`, `lib/**`, `ocp`, `ocp-connect`. Added a new
`port-spot` hard-fail job that greps for any hardcoded `3478` or `3456`
literal in `.mjs/.js/.ts/.json` outside the EXEMPT_REGEX (which lists
`lib/constants.mjs`, `test-features.mjs`, the bash CLIs, docs, and the
workflow itself). Any future PR re-introducing a hardcoded port
literal will be blocked at CI before it can cascade.
- Doc comments in `server.mjs` env-var summary and `setup.mjs` usage
banner reworded so the literal `3456` no longer appears as
documentation text (CI grep is intentionally aggressive — it does not
parse comments — so doc strings reference `DEFAULT_PORT from
lib/constants.mjs` instead).
No behavior change for any user. `CLAUDE_PROXY_PORT` env var remains
the runtime override; the only difference is the unset-env fallback
now flows through one shared constant.
ALIGNMENT.md hard-requirements: this PR modifies `server.mjs` (one-line
import + one literal swap, mechanical). No cli.js operation changed;
the citation requirement does not apply. SPOT principle (Rule 2 spirit)
is the entire motivation.
## v3.16.3 — 2026-05-13
### Fixes — completes v3.16.2 port-drift revert
v3.16.2 reverted the plugin / `openclaw.plugin.json` / README / Mac mini
plist back to `3456` (the historical source default since `593d0dc`), but
missed three places in `scripts/` that still defaulted to `3478`. Those
three lines were the residual cascade source: every time `ocp doctor` or
`ocp upgrade` ran without `CLAUDE_PROXY_PORT` in the env, they probed
`3478`, reported "OCP not responding" against a healthy 3456 instance,
and (in the case of OpenClaw sync follow-ups on the maintainer's host)
re-introduced 3478 into downstream config.
Changes:
- `scripts/upgrade.mjs:137` — default port `3478``3456`.
- `scripts/doctor.mjs:84` — default port `3478``3456`.
- `scripts/doctor.mjs:205` — default port `3478``3456`.
No behavior change for users who set `CLAUDE_PROXY_PORT` explicitly; env
still takes precedence. The fix only affects the unset-env fallback,
which now matches `server.mjs:126` and the rest of the codebase.
Test plan: existing `test-features.mjs` cases that pin
`CLAUDE_PROXY_PORT=3478` continue to pass — they use the env path, not
the default.
## v3.16.2 — 2026-05-12
### Fixes — corrects v3.16.1
The v3.16.1 fix was directionally correct (plugin now reads env first, falls back to a hardcoded default) but **the narrative and the hardcoded default were both wrong**.
What v3.16.1 said: "OCP server moved to 3478 default in v3.14+; plugin lagged at 3456."
What is actually true:
- **OCP server source default has been `3456` since `593d0dc` (initial release) and has never changed.** Every line in `server.mjs`, `setup.mjs`, and the `ocp` CLI still uses `3456` as the documented and code-level default.
- The single OCP installation observed on `3478` is the maintainer's Mac mini, whose plist was rewritten with `--port 3478` during a PR #71 dogfood smoke-test accident on 2026-05-08 (see `~/.cc-rules/memory/learnings/subagent_setup_mjs_prod_host_collision.md`). The plist drift was never reconciled back to source default, and v3.16.1 incorrectly canonised the post-accident value as if it had been a release decision.
This release:
- Restores the plugin fallback to `http://127.0.0.1:3456` to match server source default.
- Updates `openclaw.plugin.json` `configSchema.proxyUrl.default` back to `3456`.
- Restores README §"Environment Variables" `CLAUDE_PROXY_PORT` default to `3456`.
- Plugin reads `OCP_PROXY_URL` env (full URL) first, then `CLAUDE_PROXY_PORT` env (port only), then falls back to `3456`. Hosts whose OCP plist injects a non-default port must also inject the same `CLAUDE_PROXY_PORT` into the OpenClaw plist for the plugin to follow.
- Maintainer's Mac mini plist was reverted from `3478` to `3456` as part of this release deploy (no source change reflects this; it was a one-host correction).
### Governance
- No `cli.js` citation needed (no `server.mjs` change). ALIGNMENT.md Rule 2 not engaged.
## v3.16.1 — 2026-05-12 (superseded — narrative incorrect; see v3.16.2 erratum)
### Fixes (as shipped — note erratum above)
- **OCP plugin port lag** — `ocp-plugin/index.js` hard-coded `http://127.0.0.1:3456`. ~~While OCP server moved to 3478 in v3.14+,~~ **(corrected v3.16.2: no such move ever happened.)** The Mac mini's plist was on `3478` only as residue from a dogfood accident. Result: `/ocp` slash commands from the home Telegram bot returned "OCP error: fetch failed". v3.16.1 changed the plugin default to `3478` (wrong direction; v3.16.2 reverts to `3456`).
### Governance
- No `cli.js` citation needed (no `server.mjs` change). ALIGNMENT.md Rule 2 not engaged.
## v3.16.0 — 2026-05-10
### Features
- **`ocp doctor --check oauth`** (PR #93) — fast path that runs only the OAuth check, skipping
version detection / from-version / git operations / models endpoint. ~50ms vs. full doctor's
~200-500ms. Use cases: AI agent repair loops, post-`claude auth login` verify, quick health
gates. Help text in `cmd_doctor_help` now reflects working behaviour.
- **`ocp update --rollback --gc`** — manually garbage-collect old upgrade snapshots.
Retention policy: keep last 5 snapshots OR snapshots newer than 30 days OR the single most
recent (always-keep safety net). `--dry-run` previews. Successful `ocp update` runs auto-GC
at the end of the full path; light path does not (no snapshot created there).
### Behavior changes
- After a successful cross-minor `ocp update`, the auto-GC emits `[gc] removed N old snapshots`
to stderr if any were collected. Safe to ignore; manual gc is `ocp update --rollback --gc`.
### Governance
- No `cli.js` citation needed (no `server.mjs` change). ALIGNMENT.md Rule 2 not engaged.
- PR #93 (--check oauth) merged separately; this release bundles it with the GC feature.
## v3.15.1 — 2026-05-10
### Fixes
- **doctor: dynamic `latest_version` from `origin/main:package.json`** — v3.15.0 doctor used a hard-coded `latest = "v3.14.0"` fallback, which made any v3.15.0+ install report `kind = upgrade` (against a stale value). `ocp update` would then attempt `git checkout v3.14.0` — a downgrade. Doctor now fetches `git -C ~/ocp show origin/main:package.json` to determine the actual latest version; on failure (offline, fresh clone with no remote), falls back to `currentVersion` so `kind = noop` instead of recommending a downgrade.
## v3.15.0 — 2026-05-10
### Features
- **`ocp doctor`** — health & upgrade-readiness check; primary entry for AI-driven debugging.
`--json` mode emits a `next_action` with `ai_executable[]` for agents to run verbatim
and `human_required[]` for steps requiring the user (typically only OAuth).
- **`ocp update` cross-version path** — for cross-minor jumps (e.g. v3.10 → v3.14),
`ocp update` now runs doctor → snapshot → `setup.mjs` (with the plist env-merge from
PR #90) → service restart → post-flight `/health` + `/v1/models` verification.
Same-patch updates retain the existing light path; users see no change for routine
patch bumps.
- **`ocp update --rollback`** — restore the most recent (or specified) upgrade snapshot.
Snapshots are saved to `~/.ocp/upgrade-snapshot-<ISO-ts>/` and never auto-deleted.
- **Fresh-install routing** — `ocp update` on installations < v3.4.0 routes to a fresh-install
flow (with `--yes` to skip confirmation; AI agents pass this). OAuth survives via Claude
Code's credential store; users do not re-OAuth unless their token was independently broken.
- **AI prompt blocks in README** — §Installation, §Upgrading, and §Troubleshooting each
start with a copy-paste prompt for Claude Code / Cursor / Copilot, so users can drive
install / setup / upgrade through their existing AI assistant.
### Behavior changes
- `ocp update` may take 1030s longer when a cross-minor jump triggers the full path
(snapshot + post-flight). Patch bumps are unchanged.
- Pre-v3.4.0 installs are routed to fresh-install rather than failing silently or
half-migrating.
### Governance
- No `cli.js` citation needed (no `server.mjs` change). ALIGNMENT.md Rule 2 not engaged.
- Depends on PR #90 (plist env merge bug fix; merged before this release).
## v3.14.0 — 2026-05-10
### Features (security hardening)
- **Per-key session isolation** (PR #86, S1) — the `sessions` Map in `server.mjs` is now keyed by `${keyName}|${conversationId}` instead of bare `conversationId`. Before this fix, two clients using distinct API keys but the same `session_id` value (e.g. both defaulting to `"default"`) would share the same `cli.js` subprocess and conversation history, creating a cross-tenant leak path. Post-fix each (key, session) pair is isolated end-to-end, extending the per-key cache isolation shipped in v3.13.0 D1 to the session layer.
- **On-disk credential file modes 0700/0600** (PR #87, S2) — `setup.mjs` now creates `~/.ocp` at mode 0700 and both `admin-key` and `ocp.db` at mode 0600. An idempotent `reconcileFileModes()` call in `server.mjs` startup tightens any existing installation to these modes automatically on every launch, so existing prod boxes fix themselves without manual `chmod`. Before this fix, all three files were created at the process's default umask (typically world-readable 0644 / 0755), leaving plaintext credentials readable by other local users.
- **`/api/usage` default scope = self; admin all-keys requires `?all=true`** (PR #88, S3) — the usage endpoint now applies a least-privilege default: anonymous callers receive only their own rows, non-admin authenticated callers receive only their own rows, and admin callers receive only their own rows unless they explicitly pass `?all=true`. When `?all=true` is used, an audit log line is emitted. Before this fix, any admin-token holder could silently enumerate usage data for every key on the server.
### Behavior changes
- **Breaking change for admin tooling**: `/api/usage` no longer returns all-keys data by default. Existing cron jobs, dashboards, or scripts that rely on the admin token seeing all-keys output must add `?all=true` to their request URL after upgrading to v3.14.0.
- **File mode reconcile at server startup** logs a one-line notice per path when mode is tightened (e.g. `[security] tightened ~/.ocp/ocp.db → 0600`). No action is required from the operator; the reconcile is idempotent and silent when modes are already correct.
- **`sessions` Map key is now `${keyName}|${conversationId}` internally.** No client-visible wire change — the `session_id` field in request/response is unchanged.
### Verification
- Stress-test pass: 11/11 phases including S1/S2/S3 security regression checks (Phase E, I, J). 35-minute sustained run, 60 calls, 0 errors, 0 timeouts. RSS dropped 51→47 MB across the window. Per-key cache isolation, singleflight, cache_control bypass, quota enforcement, file-mode reconcile, and scope guard against escalation all verified against running code.
### Governance
- All three PRs (#86, #87, #88) include the explicit `cli.js`-citation-not-applicable disclaimer (per PR #75 pattern) since they are OCP-internal access-control, session-state, and file-permission changes with no corresponding `cli.js` operation to cite.
### No new env vars / no public API surface change beyond the documented breaking change
This release adds no new env vars or endpoints. The only externally visible change is the `/api/usage` scope guard (breaking for admin all-keys consumers; see Behavior changes above).
## v3.13.0 — 2026-05-07
### Features (cache layer hardening)
- **Per-key cache isolation** (D1) — the cache key now includes the API key id, so distinct keys never share cache entries. Anonymous/unauthenticated callers share one `anon` pool. Hash format upgraded to `v2`; legacy v1-format rows orphan and are reaped by the existing TTL cleanup interval (no migration script).
- **`cache_control` bypass** (D2) — when a request carries an Anthropic `cache_control` annotation (top-level or nested in a content array), OCP skips its own cache entirely. The caller is using Anthropic-side prompt caching deliberately, and OCP must not interfere. A `cache_skipped{reason: cache_control_present}` log line is emitted on bypass.
- **Chunked stream replay** (D3) — when a streaming request hits the cache, the cached content is now emitted as multiple SSE chunks (80 codepoints/chunk, codepoint-safe via `Array.from()`) instead of a single large delta. Multibyte characters (CJK / emoji) stay intact.
- **Singleflight stampede protection** (D4) — concurrent identical cache-miss requests now share one upstream `cli.js` spawn instead of spawning N processes. Followers receive byte-identical responses to what the leader returns. All-or-nothing failure semantics: if the leader errors, all followers receive the same error. Streaming-path singleflight is explicitly out of scope (TODO left for follow-up).
### Behavior changes
- `/cache/stats` response now includes additive fields `inflight` and `requesters` (current in-flight singleflight entries and total waiting callers). Existing fields `entries`, `totalHits`, `sizeBytes` are preserved unchanged.
### Governance
- New ADR [`docs/adr/0005-no-multi-provider.md`](docs/adr/0005-no-multi-provider.md): OCP stays single-provider (Anthropic via `cli.js` spawn). Multi-provider gateway refactor explicitly out of scope; cache improvements are explicitly in scope.
- Design spec for this release: [`docs/superpowers/specs/2026-05-07-cache-upgrade-design.md`](docs/superpowers/specs/2026-05-07-cache-upgrade-design.md).
### No new env vars / no public API surface change
This release adds no new env vars or endpoints. All four improvements are internal correctness/concurrency upgrades to the existing `CLAUDE_CACHE_TTL`-gated cache layer. No client-observable wire shape change.
## v3.12.0 — 2026-04-25
### Features
+1 -1
View File
@@ -22,7 +22,7 @@
Every PR that modifies `server.mjs` must satisfy all three of the following. A PR missing any one of them is blocked from merge.
1. **`cli.js` citation.** The commit message and PR body declare the corresponding `cli.js` function name and line number range, using the format `cli.js:NNNN` or `cli.js vE4 <functionName>`. If `cli.js` does not perform the operation, the PR must state this explicitly and justify scope under `ALIGNMENT.md` Rule 2 (in practice, this almost always means the PR should be closed).
2. **CI blacklist pass.** The `alignment.yml` workflow must pass. The workflow greps `server.mjs` for known-hallucinated tokens (including `api/oauth/usage` and `api/usage`) and fails the build on any hit. Do not suppress the workflow. Do not add allowlist entries without an amendment PR to `ALIGNMENT.md`.
2. **CI blacklist pass.** The `alignment.yml` workflow must pass. The workflow greps `server.mjs` for known-hallucinated tokens (currently blocking `api.anthropic.com/api/oauth/usage`) and fails the build on any hit. New tokens are added via PR amendment to `alignment.yml`; removing entries requires an `ALIGNMENT.md` amendment PR. Do not suppress the workflow.
3. **Independent reviewer (Iron Rule 10).** The implementation author may not self-approve. A separate reviewer — human or a subagent spawned with a fresh context — must read the diff, verify the `cli.js` citation by opening `cli.js` at the cited lines, and explicitly approve. A review comment that does not confirm the `cli.js` citation was checked is not a valid approval.
---
-14
View File
@@ -1,14 +0,0 @@
FROM node:20-alpine
WORKDIR /app
COPY server.mjs ./
COPY setup.mjs ./
COPY package.json ./
ENV CLAUDE_SESSION_TOKEN="" \
CLAUDE_COOKIES=""
EXPOSE 3456
CMD ["node", "server.mjs"]
+412 -429
View File
@@ -1,7 +1,13 @@
# OCP — Open Claude Proxy
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![GitHub release](https://img.shields.io/github/v/release/dtzp555-max/ocp)](https://github.com/dtzp555-max/ocp/releases) [![Buy Me a Coffee](https://img.shields.io/badge/Buy_Me_a_Coffee-ffdd00?logo=buy-me-a-coffee&logoColor=black)](https://buymeacoffee.com/dtzp555)
> **Already paying for Claude Pro/Max? Use your subscription as an OpenAI-compatible API — $0 extra cost.**
*Open source from day one, used daily by my family, maintained on nights and weekends. If OCP saves you money too, you can [☕ buy me a coffee](https://buymeacoffee.com/dtzp555) — [full story below](#support-ocp).*
*If OCP saves you a setup, a ⭐ helps other folks discover it. Issue reports are even more useful — that's the highest-quality feedback this project gets.*
OCP turns your Claude Pro/Max subscription into a standard OpenAI-compatible API on localhost. Any tool that speaks the OpenAI protocol can use it — no separate API key, no extra billing.
```
@@ -14,6 +20,54 @@ OpenClaw ───┘
One proxy. Multiple IDEs. All models. **$0 API cost.**
## Contents
- [Why OCP?](#why-ocp) · [Supported Tools](#supported-tools)
- [Quickstart](#quickstart)
- [How It Works](#how-it-works)
- Reference: [Available Models](#available-models) · [API Endpoints](#api-endpoints) · [Environment Variables](#environment-variables)
- Modes & operations: [LAN & multi-user](#lan--multi-user) → [`docs/lan-mode.md`](docs/lan-mode.md) · [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) → [`docs/tui-mode.md`](docs/tui-mode.md) · [Upgrading](#upgrading) → [`docs/upgrading.md`](docs/upgrading.md)
- [Built-in Usage Monitoring](#built-in-usage-monitoring) · [Response Cache](#response-cache) · [Structured Outputs](#structured-outputs-openai-response_format) · [Images / Multimodal](#images--multimodal-vision) · [OpenClaw Integration](#openclaw-integration)
- [Troubleshooting](#troubleshooting) → [`docs/troubleshooting.md`](docs/troubleshooting.md)
- [Repository Layout](#repository-layout) · [Security](#security) · [Governance](#governance) · [Support OCP](#support-ocp) · [License](#license)
## Why OCP?
There are several Claude proxy projects. OCP picks a specific lane: **align tightly with what `cli.js` actually does, observe + multiplex what's already there, don't extend the protocol.** What you get:
- **LAN multi-user keys** (v3.7.0) — reach one Claude Pro/Max subscription from your own devices across the LAN. Each device gets a per-key API token (no OAuth session leak), with independent usage tracking and one-line revocation. Pro/Max are **per-user** accounts — see [Sharing with family / a team — honest limits](docs/lan-mode.md#deployment-model--security-read-this) before extending access to other **people**.
- **`ocp-connect` one-shot client setup** — one command on the client machine auto-configures OpenClaw, and detects Cursor, Cline, Continue.dev, and opencode to print ready-to-paste setup hints for each. No hunting for where each tool keeps its `OPENAI_BASE_URL`.
- **Response cache with per-key isolation + singleflight** (v3.13.0). Optional SHA-256 prompt cache, isolated per API key (cross-user pollution is impossible by hash construction, not by application logic), with stampede protection on concurrent identical prompts. Off by default. ([PR #65](https://github.com/dtzp555-max/ocp/pull/65), [PR #66](https://github.com/dtzp555-max/ocp/pull/66))
- **Per-key request quotas** (v3.8.0). Daily / weekly / monthly limits per key — set a kid's iPad to 20/day, a partner's laptop to 100/week. ([PR #18](https://github.com/dtzp555-max/ocp/pull/18))
- **SSE heartbeat for long reasoning** ([v3.12.0](https://github.com/dtzp555-max/ocp/releases/tag/v3.12.0), opt-in). If you've ever watched your IDE die at the 60s idle mark during a long Claude tool-use pause — that's nginx/Cloudflare default behavior. OCP emits an SSE comment frame to keep the connection alive without polluting the response. ([PR #49](https://github.com/dtzp555-max/ocp/pull/49))
- **`cli.js` alignment + CI guardrail.** LLM-assisted code drifts easily — it's tempting to invent plausible-looking endpoints that `cli.js` doesn't actually use. [`ALIGNMENT.md`](./ALIGNMENT.md) is binding: every endpoint OCP exposes must cite a `cli.js` line. The [`alignment.yml`](./.github/workflows/alignment.yml) CI workflow blocks PRs that introduce known-hallucinated tokens. The payoff is boring: your setup keeps working when `cli.js` ships its next minor.
- **`models.json` single source of truth** (v3.11.0). Adding a model is one file edit; both `/v1/models` and the OpenClaw bootstrap derive from it. ([PR #30](https://github.com/dtzp555-max/ocp/pull/30))
- **Drives the official CLI as-is, no binary patching.** OCP spawns the official `claude` CLI (or hosts it in an interactive tmux pane for TUI mode) — it does not extract OAuth tokens from memory, patch the binary, or invent protocol extensions. Traffic therefore looks like genuine Claude Code to Anthropic's classifiers (`cc_entrypoint=cli`). See `ALIGNMENT.md` for why this constraint is load-bearing.
### Comparison
OCP and the alternatives serve adjacent but distinct needs. Pick the one that fits your use case:
| Feature | OCP | claude-code-router | anthropic-proxy |
|---|---|---|---|
| Forwards Claude Code subscription as OpenAI API | yes | yes | yes |
| Routes to multiple model backends (OpenAI, Gemini, etc.) | no | yes | partial |
| SSE heartbeat for long reasoning | yes (opt-in) | no | no |
| Per-key quota + LAN multi-user keys | yes | no | no |
| Response cache | yes (opt-in) | no | no |
| OpenClaw / IDE auto-config | yes | no | no |
| Model-routing rules / model-switching | no | yes | no |
| GitHub stars / ecosystem size | small | large | mid |
| Governance discipline (CI-enforced alignment with cli.js) | yes | n/a | n/a |
**Plain English**: `claude-code-router` is the routing-and-switching power tool — pick it if you want to mix Anthropic, OpenAI, Gemini, and local models behind one endpoint. `anthropic-proxy` is the minimal forwarder. **OCP focuses on disciplined `cli.js`-aligned forwarding plus subscription multiplexing** — pick it if you want to reach one Claude Pro/Max subscription from your own IDEs and devices, with LAN auth, quotas, and a governance contract that prevents endpoint drift.
### Related: OLP — Open LLM Proxy
OCP is Claude-only by design. If you want to spread across **multiple LLM providers** (not just Claude), see the sibling project **[OLP — Open LLM Proxy](https://github.com/dtzp555-max/olp)**: the same spawn-the-provider-CLI approach, but across several provider CLIs behind one OpenAI-compatible endpoint, with intelligent fallback chains. It grew out of OCP in response to Anthropic's 2026-06-15 billing split — the idea being to spread subscription/quota risk across more than one provider. OCP remains the focused, Claude-only option; OLP is the multi-provider one.
OCP is single-maintainer + LLM-assisted, currently pre-1.0. It runs the maintainer's daily Claude Code workflow. If something breaks, [open an issue](https://github.com/dtzp555-max/ocp/issues).
## Supported Tools
Any tool that accepts `OPENAI_BASE_URL` works with OCP:
@@ -24,283 +78,233 @@ Any tool that accepts `OPENAI_BASE_URL` works with OCP:
| **OpenCode** | `OPENAI_BASE_URL=http://127.0.0.1:3456/v1` |
| **Aider** | `aider --openai-api-base http://127.0.0.1:3456/v1` |
| **Continue.dev** | config.json → `apiBase: "http://127.0.0.1:3456/v1"` |
| **OpenClaw** | `setup.mjs` auto-configures |
| **OpenClaw** [^openclaw] | `setup.mjs` auto-configures |
| **Any OpenAI client** | Set base URL to `http://127.0.0.1:3456/v1` |
## Installation
[^openclaw]: **OpenClaw** is an IDE-agnostic AI coding agent (sibling project to OCP). When OCP runs on the same machine, OpenClaw can use it as a local provider — see `scripts/sync-openclaw.mjs` and ADR 0004.
OCP has two roles: **Server** (runs the proxy, needs Claude CLI) and **Client** (connects to a server, zero dependencies).
## Quickstart
```
┌─ Server (always-on device) ─────────────────────────────┐
│ Mac mini / NAS / Raspberry Pi / Desktop │
│ Claude CLI + OCP server → bound to 0.0.0.0:3456 │
└───────────────────────┬─────────────────────────────────┘
│ LAN
┌───────────────────┼───────────────────┐
▼ ▼ ▼
Laptop Phone/Tablet Pi / Server
(client) (browser) (client)
```
The simplest path: ask your AI.
---
Paste this prompt to Claude Code / Cursor / Copilot:
### Server Setup
```
Install OCP for me. Read README §Quickstart and follow it.
Tell me when I need to run `claude auth login`.
```
> **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.
The AI will run `git clone`, `npm install`, `node setup.mjs`, and tell you when to OAuth.
**Prerequisites:**
- Node.js 18+
- [Claude CLI](https://docs.anthropic.com/en/docs/claude-cli) installed and authenticated (`claude auth login`)
**Prerequisites:** macOS or Linux (Windows is not supported), Node.js 22.5+ (Node 23+ recommended), `git`, and the [Claude CLI](https://docs.anthropic.com/en/docs/claude-cli), authenticated:
```bash
npm install -g @anthropic-ai/claude-code
claude auth login # prints a URL + code — open on any browser, sign in, paste code back
```
**Install** (Server role — runs the proxy):
```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)
4. Symlink `ocp` to `/usr/local/bin` for CLI access
`setup.mjs` verifies the Claude CLI, starts the proxy on port 3456, and installs auto-start (launchd on macOS, systemd on Linux). The `ocp` CLI lands at `~/ocp/ocp` — symlink it onto your PATH (`sudo ln -sf ~/ocp/ocp /usr/local/bin/ocp`, or `ln -sf ~/ocp/ocp ~/.local/bin/ocp`) or alias it (`alias ocp=~/ocp/ocp`); the rest of the docs assume `ocp` is on your PATH.
**Verify** — should list 6 models:
```bash
curl http://127.0.0.1:3456/v1/models
# claude-opus-4-8, claude-opus-4-7, claude-opus-4-6, claude-sonnet-5, claude-sonnet-4-6, claude-haiku-4-5-20251001
```
**Connect one IDE** — point any OpenAI-compatible tool at the proxy, then reload your shell and start a tool (Cline / Continue / Cursor / OpenCode):
**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** — share with other devices on your network:
See [Supported Tools](#supported-tools) for per-tool config.
**LAN / multi-user** — reach OCP from your own devices, with per-key auth, quotas, and anonymous access:
```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
export OCP_ADMIN_KEY=your-secret-admin-key
The full LAN server + client handbook, headless (Pi / NAS / VPS) OAuth, key/quota/anonymous-access management, AI-assisted install prompts, and the deployment/security model live in **[docs/lan-mode.md](docs/lan-mode.md)**. Claude Pro/Max are per-user accounts — read the [honest limits of sharing](docs/lan-mode.md#deployment-model--security-read-this) before extending access to other people.
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.
**Verify:**
```bash
curl http://127.0.0.1:3456/v1/models
# Returns: claude-opus-4-7, claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5-20251001
```
---
### 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).
**One-command setup** — download the lightweight `ocp-connect` script:
### Uninstall
```bash
curl -fsSL https://raw.githubusercontent.com/dtzp555-max/ocp/main/ocp-connect -o ocp-connect
chmod +x ocp-connect
./ocp-connect <server-ip>
# From the cloned repo
node uninstall.mjs
```
**Zero-config** — when the server admin has set `PROXY_ANONYMOUS_KEY` (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:
Removes the launchd (macOS) or systemd (Linux) auto-start entry. Handles both legacy (`ai.openclaw.proxy` / `openclaw-proxy`) and current (`dev.ocp.proxy` / `ocp-proxy`) service names. Does not delete `~/.openclaw/`, `~/.ocp/`, or the cloned repo — remove those manually if desired.
## How It Works
```
Your IDE → OCP (localhost:3456) → claude --output-format stream-json CLI → Anthropic (via subscription)
```
OCP translates OpenAI-compatible `/v1/chat/completions` requests into `claude --output-format stream-json` CLI calls. Anthropic sees normal Claude Code usage — no API billing, no separate key needed.
> **Billing-policy status (as of 2026-07).** Anthropic announced (2026-05-14) that from 2026-06-15 the `claude -p` / Agent SDK path would move to a separate metered credit pool — then **paused the change on its 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)). So the default path above currently bills your subscription. Anthropic has said it will give notice before any future change; if the split re-lands, OCP's opt-in [subscription-pool (TUI) mode](docs/tui-mode.md#subscription-pool-tui-mode) is the ready-made hedge — see the billing table there.
### Client-tools boundary
OCP is a **text-prompt bridge** to the official `claude` CLI. It does **not** pass through OpenAI `tools`/`functions` payloads or Anthropic `tool_use` blocks to the client. Clients (Cline, Cursor, OpenClaw, etc.) pointed at OCP receive **assistant TEXT only** — they never get `tool_calls` to execute locally.
Any tool use happens server-side, under the `--allowedTools` set configured on the OCP host. In default mode (no `CLAUDE_NO_CONTEXT`), the `claude` CLI's own built-in tools are available to the model; in TUI mode, the operator controls the tool surface via `OCP_TUI_FULL_TOOLS`. Either way, the tools run under the operator's credentials on the server, and the client sees only the final text output. Note that on the `-p` path OCP prepends a system-prompt wrapper telling the model it has **no** local access (right for a shared gateway) — a single-user loopback instance whose model *should* use its tools can flip this with `OCP_LOCAL_TOOLS=1` (see Environment Variables).
**Client-local tool execution is not supported by design.** Supporting it would require bypassing the `claude` CLI to call the raw Anthropic API directly — that is a different product, and is out of scope per `ALIGNMENT.md` (every OCP endpoint must correspond to something `cli.js` actually does).
**What this means for choosing OCP (workload fit).** LAN/multi-device OCP is built for **chat-class** workloads — Q&A, translation, scripting against the API, chat frontends, home-automation backends — where text in/text out is the whole job. It is **not** the right tool for a coding agent running on a *client* machine that needs the AI to read and edit *that machine's* files: tools execute on the OCP host, so the model can never touch the client's filesystem. For that workload, run `claude` (or a local OCP) directly on the machine where the code lives.
## Available Models
| Model ID | Notes |
|----------|-------|
| `claude-opus-4-8` | Most capable (default for `opus` alias) |
| `claude-opus-4-7` | Previous Opus, retained for pinning |
| `claude-opus-4-6` | Older Opus, retained for pinning |
| `claude-sonnet-5` | Latest Sonnet (default for `sonnet` alias) |
| `claude-sonnet-4-6` | Previous Sonnet, retained for pinning |
| `claude-haiku-4-5-20251001` | Fastest, lightweight (default for `haiku` alias) |
The canonical list lives in [`models.json`](./models.json) — the single source of truth as of v3.11.0. Both `server.mjs` (the `/v1/models` endpoint) and `setup.mjs` (the OpenClaw registration) derive from it. Adding a new model is now a one-file edit:
```bash
./ocp-connect <server-ip>
# 1. Edit models.json — add an entry
# 2. Bump version, commit, tag, push
# 3. Users get it on next `ocp update`:
# - OpenClaw: auto-synced via scripts/sync-openclaw.mjs
# - Cline / Aider / Cursor / opencode: live /v1/models, picks up immediately
# - Continue.dev: user edits their own config.json
```
If the server requires a key, pass it with `--key`:
```bash
./ocp-connect <server-ip> --key <your-api-key>
## API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/v1/models` | GET | List available models |
| `/v1/chat/completions` | POST | Chat completion (streaming + non-streaming) |
| `/health` | GET | Comprehensive health check (includes a `tui` block for TUI-mode drift/concurrency monitoring) |
| `/usage` | GET | Plan usage limits + per-model stats |
| `/status` | GET | Combined overview (usage + health) |
| `/settings` | GET/PATCH | View or update settings at runtime |
| `/logs` | GET | Recent log entries (`?n=20&level=error`) |
| `/sessions` | GET/DELETE | List or clear active sessions |
| `/dashboard` | GET | Web dashboard (always public) |
| `/api/keys` | GET/POST | List or create API keys (admin only) |
| `/api/keys/:id` | DELETE | Revoke an API key (admin only) |
| `/api/keys/:id/quota` | GET/PATCH | View or set per-key quota (admin only) |
| `/api/usage` | GET | Per-key usage stats (`?since=&until=&hours=&limit=`); returns self only by default — pass `?all=true` (admin only) for all-keys data |
| `/cache/stats` | GET | Cache statistics (admin only) |
| `/cache` | DELETE | Clear response cache (admin only) |
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `CLAUDE_PROXY_PORT` | `3456` | Listen port (server-side). Also consumed by the OpenClaw `ocp-plugin` to dial the local proxy. |
| `OCP_PROXY_URL` | *(unset)* | Plugin-side full URL override (e.g. `http://10.0.0.5:3456`). Wins over `CLAUDE_PROXY_PORT` when both are set. Read by `ocp-plugin/index.js` only — server ignores it. |
| `CLAUDE_BIND` | `127.0.0.1` | Bind address (`0.0.0.0` for LAN access) |
| `CLAUDE_AUTH_MODE` | `none` | Auth mode: `none`, `shared`, or `multi` |
| `OCP_ADMIN_KEY` | *(unset)* | Admin key for key management (multi mode) |
| `CLAUDE_BIN` | *(auto-detect)* | Path to claude binary |
| `CLAUDE_TIMEOUT` | `600000` | Request timeout (ms, default: 10 min) |
| `CLAUDE_HEARTBEAT_INTERVAL` | `0` | Streaming SSE keepalive interval (ms). `0` = disabled. See ["Streaming heartbeat"](#streaming-heartbeat) below. |
| `CLAUDE_MAX_CONCURRENT` | `8` | Max concurrent claude processes (`-p`/stream-json path) |
| `CLAUDE_MAX_QUEUE` | `16` | Max requests **waiting** for a `-p` concurrency slot. Beyond `CLAUDE_MAX_CONCURRENT`, requests queue (up to this cap) instead of being rejected; when the queue is **also** full, the request gets `HTTP 429` + `Retry-After` (not an opaque 500). Surfaced on `/health.concurrency` + `/health.stats.queueRejections`. |
| `CLAUDE_QUEUE_RETRY_AFTER` | `5` | Seconds advertised in the `Retry-After` header on a `-p` concurrency-overflow `429`. |
| `CLAUDE_MAX_PROMPT_CHARS` | *(derived)* | Prompt truncation limit in chars. Default derives from the models.json SPOT: `max(contextWindow) × 3` — currently **600,000** (≈150200k tokens). Setting this env var (or the runtime settings API) overrides the derivation absolutely. See [ADR 0009](docs/adr/0009-spot-derived-prompt-budget.md). Note: very large prompts burn subscription-window quota quickly and slow TTFT; the TUI-mode paste path is untested beyond ~hundreds of KB. Applies to **text only** — image bytes bypass this budget (see [Images / Multimodal](#images--multimodal-vision)). |
| `OCP_STRUCTURED_MAX_ATTEMPTS` | `3` | Max attempts (initial + retries) to coerce a schema-valid JSON reply when a request uses OpenAI `response_format`. Fail-closed: a non-numeric value keeps the default. See [Structured Outputs](#structured-outputs-openai-response_format). |
| `CLAUDE_SESSION_TTL` | `3600000` | Session expiry (ms, default: 1 hour) |
| `CLAUDE_CACHE_TTL` | `0` | Response cache TTL (ms, 0 = disabled). Set to e.g. `300000` for 5-min cache. See [Response Cache](#response-cache). |
| `CLAUDE_ALLOWED_TOOLS` | `Bash,Read,...,Agent` | Comma-separated tools to pre-approve |
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass all permission checks |
| `CLAUDE_MCP_CONFIG` | *(unset)* | Path to an MCP server config JSON, passed to the spawned `claude` as `--mcp-config` (both the `-p` path and TUI `OCP_TUI_FULL_TOOLS` panes) |
| `CLAUDE_MAX_BODY_SIZE` | `5242880` | Max request body size (bytes, default 5 MB). Base64 image payloads inflate ~33%; raise this to admit larger multimodal requests. Fail-closed parsing: a garbage value keeps the default. |
| `CLAUDE_IMAGE_ALLOW_URL` | `false` | Allow remote `http(s)` image URLs in `image_url` parts. **Off by default** (v1 supports base64 `data:` URIs only). When on, the URL is passed through to Anthropic as a `url` image source — **OCP does not fetch it** (no OCP-side SSRF surface); unreachable/blocked URLs surface as an API error. |
| `CLAUDE_MAX_IMAGE_BYTES` | `5242880` | Per-image decoded-byte cap (default 5 MB). Over-cap images get `HTTP 413`. |
| `CLAUDE_MAX_IMAGES` | `20` | Max image parts per request. Over-cap gets `HTTP 413`. |
| `CLAUDE_MAX_IMAGE_TOTAL_BYTES` | `20971520` | Aggregate decoded-byte cap across all images in a request (default 20 MB). Over-cap gets `HTTP 413`. |
| `CLAUDE_SYSTEM_PROMPT` | *(unset)* | Operator-wide system-prompt text appended (last) to every request's composed system prompt on the default `-p` path. TUI-mode panes are unaffected (they keep the interactive CLI's own system prompt). Echoed truncated on `/health.systemPrompt`. Note: changing this value and restarting auto-invalidates the response cache (the key carries a boot-config epoch, #177). |
| `OCP_LOCAL_TOOLS` | *(unset)* | **Single-user, loopback only.** `=1` swaps the default *"you have no local filesystem/shell access"* system-prompt wrapper for a positive one telling the model it **may** use its tools. These are the **server-side `claude` tools** OCP spawns via `-p` (`--allowedTools`) — which, on a loopback instance, run on the operator's own machine, i.e. *local* tools. For a personal instance (e.g. an **OpenClaw** agent on its own local OCP) the default wrapper otherwise makes the model refuse to use tools it legitimately has. Changes **only the prompt**, never the tool surface (governed by `--allowedTools`/`--disallowedTools`; multi-tenant still `--disallowedTools` the whole FS surface). **Does not** enable client-side `tool_calls` for OpenClaw/Cline/etc. — that remains unsupported by design (see § How tools work). Fail-closed: OCP **refuses to boot** if `=1` is combined with `CLAUDE_AUTH_MODE=multi`, a non-loopback bind, or `PROXY_ANONYMOUS_KEY` (mirrors `OCP_TUI_FULL_TOOLS`, ADR 0007). **Inert in TUI mode** (the `-p` wrapper is unused there; the TUI tool surface is `OCP_TUI_FULL_TOOLS`) — a warning is logged. Off by default → the default path is byte-for-byte unchanged. Toggling it auto-invalidates the standard response cache (boot-config epoch, #177). |
| `CLAUDE_NO_CONTEXT` | `false` | Suppress CLAUDE.md and auto-memory injection (pure API mode) |
| `PROXY_API_KEY` | *(unset)* | Bearer token for shared-mode authentication |
| `PROXY_ANONYMOUS_KEY` | *(unset)* | Well-known anonymous key (multi mode) — this exact string bypasses `validateKey()` and grants public access. Exposed via `/health.anonymousKey` only to localhost, or to all callers when `PROXY_ADVERTISE_ANON_KEY=1`. Full setup + security notes: [docs/lan-mode.md § Anonymous Access](docs/lan-mode.md#anonymous-access-optional). |
| `PROXY_ADVERTISE_ANON_KEY` | *(unset)* | When `=1`, advertise `PROXY_ANONYMOUS_KEY` in the public `/health` body for remote zero-config discovery. Default off — `/health` is unauthenticated, so this exposes the shared key to any LAN-reachable device (issue #109). Localhost always sees it regardless. |
| `CLAUDE_TUI_MODE` | `false` | **Opt-in, single-user only.** Set to `"true"` to serve requests via interactive `claude` (`cc_entrypoint=cli`, subscription pool). Refuses to boot under `AUTH_MODE=multi`. See [Subscription-pool (TUI) mode](docs/tui-mode.md#subscription-pool-tui-mode). |
| `CLAUDE_CODE_OAUTH_TOKEN` | *(unset)* | OAuth bearer token — highest-precedence credential for the `-p` path, and the **recommended** credential for TUI-mode hosts (when set with `OCP_TUI_HOME` unset, OCP runs the TUI `claude` in a credential-isolated home). See [docs/tui-mode.md](docs/tui-mode.md#tui-other-vars) and the [permanent-401 fix](docs/troubleshooting.md#tui-401). |
| `OCP_SPAWN_REAL_HOME` | *(unset)* | Kill-switch for the default `-p`/stream-json **spawn-home isolation** (latency fix). When unset and an OAuth token is resolvable, OCP runs the per-request `claude` spawn in a **credential-free minimal scratch home** (`$HOME/.ocp/spawn-home`, no `.credentials.json`/`settings.json`/plugins) with a neutral cwd and the env token — so it loads none of the operator's heavy global `~/.claude` (plugins/skills/hooks) or the project `CLAUDE.md`, cutting per-request latency (measured ~1028s → ~37s). Set to `"1"` to force the legacy real-`HOME` spawn (no cwd override) even when a token exists. With **no** resolvable token, OCP falls back to the real `HOME` automatically (zero regression). Active mode is shown at startup and on `/health.spawn`. |
| `CLAUDE_TUI_WALLCLOCK_MS` | `120000` | (TUI-mode) Maximum time in ms to wait for the native transcript to signal turn completion. Increase for long Opus thinking turns. |
| `OCP_TUI_CWD` | `$HOME/.ocp-tui/work` | (TUI-mode) Scratch working directory where interactive claude sessions run. Transcripts land under `<HOME>/.claude/projects/<encoded-cwd>/`. Created automatically. |
| `OCP_TUI_HOME` | *(auto)* | (TUI-mode) `HOME` claude runs under. When unset, OCP auto-picks a credential-isolated scratch home (env token set) or the real home (no token). Full home/credential strategy: [docs/tui-mode.md](docs/tui-mode.md#tui-other-vars). |
| `OCP_TUI_ENTRYPOINT` | `cli` | (TUI-mode) Billing-classifier labeling: `cli` pins `cc_entrypoint=cli`; `auto` self-classifies via TTY; `off` leaves inherited env untouched. See [docs/tui-mode.md](docs/tui-mode.md#tui-entrypoint). |
| `OCP_TUI_EFFORT` | `low` | (TUI-mode) `--effort` level for the interactive spawn (`low`/`medium`/`high`/`xhigh`/`max`/`inherit`). Explicit `low` cuts TTFT p50 ~40% vs an inherited `xhigh`; invalid values fall back to `low`. See [docs/tui-mode.md](docs/tui-mode.md#tui-other-vars). |
| `OCP_TUI_STREAM` | `0` (off) | (TUI-mode) `=1` emits real SSE `delta.content` chunks (block-level) from claude's `MessageDisplay` hook instead of buffering; transcript stays authoritative and divergent turns are refused. Caveats (tool-using turns, zero-delta detection) in [docs/tui-mode.md § `OCP_TUI_STREAM`](docs/tui-mode.md#ocp-tui-stream). |
| `OCP_TUI_STREAM_HOLDBACK` | `100` | (TUI-mode, streaming) Characters withheld before the first chunk — keeps the auth-banner gate alive and is the knob for tool-using turns. See [docs/tui-mode.md § `OCP_TUI_STREAM_HOLDBACK`](docs/tui-mode.md#ocp-tui-stream-holdback). |
| `OCP_TUI_STREAM_DIR` | `$HOME/.ocp-tui/stream` | (TUI-mode, streaming) Directory for the hook script/settings + per-session delta sink (one sink per session-id, so concurrent turns never interleave). See [docs/tui-mode.md](docs/tui-mode.md#ocp-tui-stream). |
| `OCP_TUI_STREAM_POLL_MS` | `100` | (TUI-mode, streaming) Interval at which OCP drains the delta sink; the hook fires at block granularity so a finer poll buys nothing. See [docs/tui-mode.md](docs/tui-mode.md#ocp-tui-stream). |
| `OCP_TUI_MAX_CONCURRENT` | `2` | (TUI-mode) Max concurrent interactive TUI turns, independent of `CLAUDE_MAX_CONCURRENT`. Excess turns queue (bounded); a full queue yields 503. See [docs/tui-mode.md](docs/tui-mode.md#tui-other-vars). |
| `OCP_TUI_POOL_SIZE` | `0` (off) | (TUI-mode) Number of pre-booted warm `claude` panes (max `4`) so a request skips the cold boot — measured p50 `10.17s` → `6.00s`. Each warm pane is a live idle process; panes are single-use. See [docs/tui-mode.md § `OCP_TUI_POOL_SIZE`](docs/tui-mode.md#ocp-tui-pool-size). |
| `OCP_SKIP_AUTH_TEST` | *(unset)* | When `=1`, skip the `claude -p` auth probe during `setup.mjs`. Under the announced (currently **paused**) 2026-06-15 billing split this probe would draw from the metered Agent SDK credit pool; set this to avoid burning a probe on re-installs or `ocp update` runs. Auth is validated at the first real request. |
| `OCP_TUI_FULL_TOOLS` | *(unset)* | (TUI-mode, **single-user only**) `=1` grants the interactive session the same tool surface as the `-p` path (`--allowedTools` + optional `--mcp-config`) so a trusted single operator can run a tool-using / MCP agent on the subscription pool. Safe because TUI refuses to boot under `AUTH_MODE=multi`. See [docs/tui-mode.md § `OCP_TUI_FULL_TOOLS`](docs/tui-mode.md#ocp-tui-full-tools). |
### Streaming heartbeat
When `CLAUDE_HEARTBEAT_INTERVAL` is set to a positive integer (milliseconds), OCP emits an SSE comment frame (`: keepalive\n\n`) on streaming responses whenever the stream has been idle for that duration. The timer resets on every real chunk, so heartbeats only fire during genuine silent windows (for example, Claude CLI tool-use pauses of 30s5min, or a long "processing large contexts" delay before the first token).
Use cases: downstream HTTP clients or load balancers with idle-connection timeouts that would otherwise abort a slow-but-alive request. `CLAUDE_HEARTBEAT_INTERVAL=30000` (30s) is a reasonable starting value if your downstream has a 60s idle timeout.
Heartbeats are inert SSE comment lines — conforming SSE clients ignore them. If your downstream client's SSE parser crashes on comment frames, leave this disabled (the default) and file an issue so we can consider an alternate frame format.
OCP also sends `X-Accel-Buffering: no` on SSE responses so nginx-default proxy buffering does not hold heartbeats in an upstream buffer.
### Runtime settings (no restart needed)
Many tunables can be changed live via `ocp settings <key> <value>` (or `PATCH /settings`) without restarting:
```
$ ocp settings maxPromptChars 200000
✓ maxPromptChars = 200000
$ ocp settings maxConcurrent 4
✓ maxConcurrent = 4
```
Or as a one-liner (no file saved):
```bash
curl -fsSL https://raw.githubusercontent.com/dtzp555-max/ocp/main/ocp-connect | bash -s -- <server-ip>
```
## LAN & multi-user
Example:
```
$ ./ocp-connect 192.168.1.100
OCP Connect v1.3.0
─────────────────────────────────────
Remote: http://192.168.1.100:3456
Checking connectivity...
✓ Connected
Remote OCP v3.11.0 (auth: multi)
ⓘ Using server-advertised anonymous key: ocp_publ...n_v1
(set by admin via PROXY_ANONYMOUS_KEY; see issue #12 §14 Path A)
Testing API access...
✓ API accessible (4 models available)
Shell config:
✓ .bashrc
✓ .zshrc
OPENAI_BASE_URL=http://192.168.1.100:3456/v1
System-level (launchctl):
✓ OPENAI_BASE_URL set for GUI apps and daemons
IDE Configuration
─────────────────────────────────────
Detected: OpenClaw (~/.openclaw/openclaw.json)
Configure OpenClaw to use this OCP? [Y/n] y
Provider name (models show as <name>/model-id) [ocp]: ocp
How should OCP models be configured?
1) Primary — use OCP by default, keep existing models as backup
2) Backup — keep current primary, add OCP as additional option
Choice [1]: 1
Writing OpenClaw config...
✓ Per-agent auth profile seeded (2):
• ~/.openclaw/agents/main/agent/auth-profiles.json
• ~/.openclaw/agents/macbook_bot/agent/auth-profiles.json
✓ OpenClaw configured
Provider: ocp
Models:
• ocp/claude-opus-4-7
• ocp/claude-opus-4-6
• ocp/claude-sonnet-4-6
• ocp/claude-haiku-4-5-20251001
Priority: PRIMARY (default model)
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+)
- 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)
Run OCP as a server on an always-on device and reach your one Claude Pro/Max subscription from your own laptops, phones, and Pis across the LAN — with per-key API tokens, per-key usage tracking + quotas, response-cache isolation, and one-command client setup (`ocp connect` / `ocp-connect`). A shared **anonymous key** covers simple trusted-family sharing.
```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
node setup.mjs --bind 0.0.0.0 --auth-mode multi
ocp keys add laptop # then: ocp lan → prints the LAN IP + connect command
```
**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.
⚠️ The per-key modes give usage tracking, quotas, and cache separation — **not** a security isolation boundary. The spawned `claude` runs with the operator's filesystem access and is not sandboxed per key, so only share with people you fully trust, on a trusted network. Pro/Max are per-user accounts; pooling across distinct people may violate Anthropic's ToS.
![OCP Dashboard](docs/images/dashboard.png)
Full server + client handbook, headless OAuth, AI-assisted install prompts, key/quota/anonymous-access management, monitoring dashboard, and the [deployment/security model & honest limits](docs/lan-mode.md#deployment-model--security-read-this): **[docs/lan-mode.md](docs/lan-mode.md)**.
### Auth Modes
## Subscription-pool (TUI) mode
| Mode | Env | Use Case |
|------|-----|----------|
| `none` | `CLAUDE_AUTH_MODE=none` | Trusted home network, no auth needed |
| `shared` | `CLAUDE_AUTH_MODE=shared` + `PROXY_API_KEY=xxx` | Everyone shares one key |
| `multi` | `CLAUDE_AUTH_MODE=multi` + `OCP_ADMIN_KEY=xxx` | Per-person keys with usage tracking (recommended) |
**Opt-in, single-user only.** `CLAUDE_TUI_MODE=true` serves requests through interactive `claude` (no `-p`, `cc_entrypoint=cli`) so they bill the Pro/Max **subscription pool** instead of the metered Agent SDK path. Because `claude` runs with the operator's filesystem access, it is **single-operator only** — never enable it on a multi-user OCP (it refuses to boot under `AUTH_MODE=multi`).
### Anonymous Access (optional)
> **⚠️ Status (as of 2026-07): a hedge, not a necessity.** The 2026-06-15 billing split that made this matter was announced, then **paused on its effective date** — the default `-p` path currently bills your subscription. TUI-mode is kept ready for if/when a reworked change lands (Anthropic has promised advance notice).
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.
Setup, the ~6-second latency floor, real-SSE streaming (`OCP_TUI_STREAM`), the warm-pane pool (`OCP_TUI_POOL_SIZE`), full-tool mode (`OCP_TUI_FULL_TOOLS`), `/health` drift monitoring, and the flip/canary runbooks: **[docs/tui-mode.md](docs/tui-mode.md)**.
**Enable**:
## Upgrading
```bash
export PROXY_ANONYMOUS_KEY=ocp_public_anon # or any string of your choice
ocp start # or however you start the server
```
Run **`ocp update`** — it smart-picks the path. A **patch bump** (e.g. `v3.21.0 → v3.21.1`) takes the light path (git pull + npm install + restart); a **cross-minor** jump (e.g. `v3.18 → v3.22`) takes the full path (pre-flight, snapshot, `setup.mjs` with plist env-merge, restart, post-flight `/health` + `/v1/models` verification). `ocp update --check` shows available updates without applying.
**Client side**: the anonymous key value is exposed via `GET /health` as the field `anonymousKey` (null when not set). 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:
```bash
# Set a daily limit of 50 requests for a key
curl -X PATCH http://127.0.0.1:3456/api/keys/wife-laptop/quota \
-H "Authorization: Bearer $OCP_ADMIN_KEY" \
-d '{"daily": 50}'
# Set multiple limits at once
curl -X PATCH http://127.0.0.1:3456/api/keys/son-ipad/quota \
-H "Authorization: Bearer $OCP_ADMIN_KEY" \
-d '{"daily": 20, "weekly": 100}'
# Check current quota + usage
curl http://127.0.0.1:3456/api/keys/wife-laptop/quota
# → { "daily": { "limit": 50, "used": 12 }, "weekly": { "limit": null, "used": 34 }, ... }
# Remove a limit (set to null)
curl -X PATCH http://127.0.0.1:3456/api/keys/wife-laptop/quota \
-d '{"daily": null}'
```
When a key exceeds its quota, OCP returns HTTP 429 with a structured error:
```json
{
"error": {
"message": "Quota exceeded: 50/50 requests (daily). Resets 6h 12m.",
"type": "quota_exceeded",
"quota": { "period": "daily", "limit": 50, "used": 50, "resetsIn": "6h 12m" }
}
}
```
- `null` = unlimited (default for all keys)
- Only successful requests count toward quota
- Admin and anonymous users are never subject to quotas
- PATCH is a partial update — omitted fields are left unchanged
### 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
Manual flags, rollback (`ocp update --rollback`), snapshots, and the OpenClaw model auto-sync (v3.11.0+): **[docs/upgrading.md](docs/upgrading.md)**.
## Built-in Usage Monitoring
@@ -328,6 +332,8 @@ Total 23
Proxy: up 6h 32m | 23 reqs | 0 err | 0 timeout
```
**Web Dashboard:** open `http://<host>:3456/dashboard` in any browser for real-time per-key usage, request history, plan utilization, and system health (screenshot + details in [docs/lan-mode.md § Monitoring](docs/lan-mode.md#monitoring-server-side)).
### All Commands
```
@@ -339,6 +345,7 @@ ocp keys List all API keys (multi mode)
ocp keys add <name> Create a new API key
ocp keys revoke <name> Revoke an API key
ocp connect <ip> One-command LAN client setup
ocp doctor Health & upgrade-readiness check; primary entry for AI-driven debugging. --json produces a next_action for AI agents.
ocp lan Show LAN connection info & IP
ocp settings View tunable settings
ocp settings <k> <v> Update a setting at runtime
@@ -353,149 +360,142 @@ ocp update --check Check for updates without applying
ocp --help Command reference
```
### Install the CLI
```bash
# Symlink to PATH (recommended)
sudo ln -sf $(pwd)/ocp /usr/local/bin/ocp
# Verify
ocp --help
```
> **Cloud/Linux servers:** If `ocp: command not found`, the binary isn't in PATH. Full path: `~/.openclaw/projects/ocp/ocp`
### Self-Update
```bash
# Check if a new version is available
ocp update --check
# Pull latest, sync plugin, restart proxy — one command
ocp update
```
`ocp update` runs (in order): `git pull``npm install` → plugin sync → **OpenClaw model registry sync** (v3.11.0+) → proxy restart → health check.
### 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`
- `agents.defaults.models["claude-local/*"]` aliases
**Safety**:
- Timestamped backup written before every change: `~/.openclaw/openclaw.json.bak.<ms>`
- Idempotent — already-in-sync runs are a no-op (no backup, no rewrite)
- Non-fatal — sync failure does NOT abort `ocp update`; `/v1/models` still works
- Skips silently if OpenClaw is not installed (`~/.openclaw/openclaw.json` missing)
**Manual trigger** (e.g. after fixing a hand-edited config, or for the one-time v3.10.0→v3.11.0 bootstrap quirk):
```bash
node ~/ocp/scripts/sync-openclaw.mjs
node ~/ocp/scripts/sync-openclaw.mjs --quiet # silent unless changes
```
**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.
**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.
### Runtime Settings (No Restart Needed)
```
$ ocp settings maxPromptChars 200000
✓ maxPromptChars = 200000
$ ocp settings maxConcurrent 4
✓ maxConcurrent = 4
```
> **Note:** Terminal CLI uses `ocp <command>`; the OpenClaw gateway plugin exposes the same as `/ocp <command>` in Telegram/Discord (see [OpenClaw Integration](#openclaw-integration)).
## Response Cache
OCP can cache responses to avoid redundant Claude CLI calls for identical prompts. This is useful during development when the same prompt is sent repeatedly.
OCP can cache responses to avoid redundant Claude CLI calls for identical prompts useful during development when the same prompt is sent repeatedly.
**Enable** by setting `CLAUDE_CACHE_TTL` (in milliseconds):
**Enable** by setting `CLAUDE_CACHE_TTL` (ms), or update at runtime with `ocp settings cacheTTL 300000`:
```bash
# Cache responses for 5 minutes
export CLAUDE_CACHE_TTL=300000
# Or update at runtime (no restart)
ocp settings cacheTTL 300000
export CLAUDE_CACHE_TTL=300000 # cache responses for 5 minutes
```
**How it works:**
- Cache key = SHA-256 of `model` + `messages` + `temperature` + `max_tokens` + `top_p`
- Cache hits return instantly — no Claude CLI process spawned
- Works for both streaming and non-streaming requests
- Multi-turn conversations (with `session_id`) are never cached
- Expired entries are cleaned up automatically every 10 minutes
- Cache key = SHA-256 of `v2|<keyId or "anon">|model + messages + temperature + max_tokens + top_p`
- **Per-key isolation** — different API keys never share cache entries; anonymous callers share one `anon` pool
- Cache hits return instantly — no Claude CLI process spawned. **Streaming hits** are replayed as multiple SSE chunks (80 codepoints each), not one large delta, so incremental render is preserved
- **`cache_control` bypass** — a request carrying an Anthropic `cache_control` annotation (top-level or nested in `content[]`) skips OCP's cache entirely, so it doesn't interfere with Anthropic-side prompt caching
- **Singleflight stampede protection** — concurrent identical cache-miss requests share one upstream `cli.js` spawn; followers receive byte-identical responses (non-streaming path only; streaming-path singleflight is a known TODO)
- Multi-turn conversations (with `session_id`) are never cached; expired entries are reaped automatically every 10 minutes
**Management:**
```bash
# View cache stats
curl http://127.0.0.1:3456/cache/stats
# → { "entries": 42, "totalHits": 156, "sizeBytes": 284000 }
# Clear all cached responses
curl -X DELETE http://127.0.0.1:3456/cache
# Disable cache at runtime
ocp settings cacheTTL 0
curl http://127.0.0.1:3456/cache/stats # { "entries": 42, "totalHits": 156, "sizeBytes": 284000, "inflight": 0, "requesters": 0 }
curl -X DELETE http://127.0.0.1:3456/cache # clear all cached responses
ocp settings cacheTTL 0 # disable at runtime
```
Cache is **disabled by default** (`CLAUDE_CACHE_TTL=0`). All data is stored locally in `~/.ocp/ocp.db`.
Cache is **disabled by default** (`CLAUDE_CACHE_TTL=0`). All data is stored locally in `~/.ocp/ocp.db`. **Hash format upgrade in v3.13.0:** legacy `v1` cache rows don't match new `v2`-format lookups; they orphan and are reaped by the TTL cleanup interval within one window — no migration script required.
## How It Works
## Structured Outputs (OpenAI `response_format`)
```
Your IDE → OCP (localhost:3456) → claude -p CLI → Anthropic (via subscription)
```
`/v1/chat/completions` honors OpenAI's [`response_format`](https://platform.openai.com/docs/api-reference/chat/create#chat-create-response_format) parameter so OpenAI-SDK clients that require machine-parseable JSON (Home Assistant AI Tasks, Honcho, BYO scripts) get JSON in `choices[].message.content` — not prose.
OCP translates OpenAI-compatible `/v1/chat/completions` requests into `claude -p` CLI calls. Anthropic sees normal Claude Code usage — no API billing, no separate key needed.
Supported shapes:
## Available Models
- `response_format: { "type": "json_schema", "json_schema": { "name", "strict", "schema" } }`
- `response_format: { "type": "json_object" }`
- `json_mode: true` — non-standard top-level alias honored by several OpenAI-compatible clients; treated as `json_object`.
| Model ID | Notes |
|----------|-------|
| `claude-opus-4-7` | Most capable (default for `opus` alias) |
| `claude-opus-4-6` | Previous Opus, retained for pinning |
| `claude-sonnet-4-6` | Good balance of speed/quality (default for `sonnet` alias) |
| `claude-haiku-4-5-20251001` | Fastest, lightweight (default for `haiku` alias) |
When a structured request is detected, OCP:
The canonical list lives in [`models.json`](./models.json) — the single source of truth as of v3.11.0. Both `server.mjs` (the `/v1/models` endpoint) and `setup.mjs` (the OpenClaw registration) derive from it. Adding a new model is now a one-file edit:
1. Appends a strict JSON-only steering instruction to the request (no Markdown, no fences, no prose, must begin with `{` or `[`).
2. Extracts the JSON from the model reply (unwraps a stray code fence / prose via a string-aware balanced slice).
3. For `json_schema`, validates the result against the supplied schema (types, `required`, `enum`, `const`, `additionalProperties`, nullability, `items`, `min/maxItems`, and `$ref`/`$defs` + `allOf`/`anyOf`/`oneOf` composition — the shapes the official OpenAI SDK emits via `zodResponseFormat` / `client.beta.chat.completions.parse`). For `json_object`, the whole reply must parse as a single JSON value (a stray brace inside prose is not served as the answer).
4. On a parse/validation miss, retries with a stronger instruction that names the failure, up to `OCP_STRUCTURED_MAX_ATTEMPTS` (default 3).
5. If no valid JSON can be produced, returns OpenAI's assistant **`refusal`** field (`HTTP 200`, `message.content: null`, `message.refusal: "<reason>"`, `finish_reason: "stop"`) — the spec's own mechanism for "the model would not produce the required output" — rather than an invented error type or passing prose through. SDK clients take their written `refusal` branch.
A reply that carries **more than one** top-level JSON value (e.g. `Schema: {…}` then `Answer: {…}`) is rejected as ambiguous rather than silently serving the first — OCP never serves an unvalidated or arbitrarily-chosen extraction.
`message.content` for a structured request is the raw JSON string only — no fences, no reasoning, no wrapper. Non-structured requests are completely unaffected (normal conversational behaviour, streaming included). This is a Class B.1 endpoint extension authorized by ADR 0006; the pure logic lives in [`lib/structured-output.mjs`](./lib/structured-output.mjs) and is unit-tested in `test-features.mjs`.
**Caching & cost.** A structured request can cost up to `OCP_STRUCTURED_MAX_ATTEMPTS` metered `claude` spawns — each retry is a fresh spawn, burning subscription-window quota today and metered credits if the (currently **paused**) 2026-06-15 billing split re-lands (see the billing-policy status note in [How It Works](#how-it-works)) — so this feature adds cost-attack surface. Two guards bound it: (a) identical **concurrent** structured requests share one flight (single-flight dedup, so N callers ≠ N× spawns), and (b) when `CLAUDE_CACHE_TTL > 0`, a **validated** result is cached on a **structured-keyed** hash (the `response_format`/schema is folded into the key, so a JSON reply never collides with the conversational answer and different schemas never share a slot). A refusal is never cached. Operators concerned about cost can lower `OCP_STRUCTURED_MAX_ATTEMPTS` to `1` (no retries) or gate the surface behind per-key quotas (`/api/keys/:id/quota`).
## Images / Multimodal (Vision)
`POST /v1/chat/completions` accepts OpenAI-style multimodal `content` parts, so a
message can carry images alongside text and Claude will actually see them. This
follows OpenAI's [vision](https://platform.openai.com/docs/guides/vision) /
[chat-completions `image_url`](https://platform.openai.com/docs/api-reference/chat/create#chat-create-messages)
request shape — no OCP-invented fields. (Class B.1 endpoint; see ADR 0006.)
Under the hood, when a request carries an image OCP feeds the conversation to the
Claude CLI as Anthropic image blocks over `--input-format stream-json`. Text-only
requests are completely unaffected (unchanged code path).
### Supported input
- **Base64 data URIs** (default, recommended):
`data:image/png;base64,<...>`. Media types: `image/jpeg`, `image/png`,
`image/gif`, `image/webp`.
- **Remote `http(s)` URLs** — **off by default**. Set `CLAUDE_IMAGE_ALLOW_URL=1`
to enable; the URL is passed through to Anthropic (OCP never fetches it itself,
so there is no OCP-side SSRF surface).
- Images may appear in **any** message in the history (multi-turn), not just the
last one.
- Non-image, non-text parts (audio, files) are **not** yet supported and are
replaced with a `[non-text content omitted]` placeholder (deferred to a future
version).
### Example (base64 data URI)
```bash
# 1. Edit models.json — add an entry
# 2. Bump version, commit, tag, push
# 3. Users get it on next `ocp update`:
# - OpenClaw: auto-synced via scripts/sync-openclaw.mjs
# - Cline / Aider / Cursor / opencode: live /v1/models, picks up immediately
# - Continue.dev: user edits their own config.json
curl -X POST http://127.0.0.1:3456/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{
"model": "claude-sonnet-4-6",
"messages": [{
"role": "user",
"content": [
{ "type": "text", "text": "What is in this image?" },
{ "type": "image_url",
"image_url": { "url": "data:image/png;base64,iVBORw0KGgoAAA..." } }
]
}]
}'
```
## API Endpoints
### Not supported in TUI mode
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/v1/models` | GET | List available models |
| `/v1/chat/completions` | POST | Chat completion (streaming + non-streaming) |
| `/health` | GET | Comprehensive health check |
| `/usage` | GET | Plan usage limits + per-model stats |
| `/status` | GET | Combined overview (usage + health) |
| `/settings` | GET/PATCH | View or update settings at runtime |
| `/logs` | GET | Recent log entries (`?n=20&level=error`) |
| `/sessions` | GET/DELETE | List or clear active sessions |
| `/dashboard` | GET | Web dashboard (always public) |
| `/api/keys` | GET/POST | List or create API keys (admin only) |
| `/api/keys/:id` | DELETE | Revoke an API key (admin only) |
| `/api/keys/:id/quota` | GET/PATCH | View or set per-key quota (admin only) |
| `/api/usage` | GET | Per-key usage stats (`?since=&until=&hours=&limit=`) |
| `/cache/stats` | GET | Cache statistics (admin only) |
| `/cache` | DELETE | Clear response cache (admin only) |
Multimodal images require the default `-p` spawn path. In **TUI / subscription-pool
mode** (`CLAUDE_TUI_MODE=true`) the CLI is driven interactively and cannot carry
image blocks, so a request with an `image_url` part returns **`400
images_unsupported_in_tui_mode`** rather than silently dropping the image and
answering about something the model never saw. Remove the images, or run OCP
without TUI mode, to use vision.
Images must also live in a **user or assistant** message, not a `system` message
(system content is not forwarded to the CLI as image blocks). An `image_url` part
present only in a system message returns **`400 images_unsupported_in_system_messages`**
for the same reason — fail loudly rather than answer about an unseen image. This matches
the OpenAI vision spec, which does not place images in the system role.
### Limits
Images bypass the text `CLAUDE_MAX_PROMPT_CHARS` budget and are instead bounded by
their own byte/count caps. The **text** in a multimodal request is still subject to
`CLAUDE_MAX_PROMPT_CHARS` (older text is truncated exactly as on the text-only
path — only the image bytes are exempt). All numeric caps are parsed **fail-closed**:
a malformed value (e.g. `CLAUDE_MAX_BODY_SIZE=unlimited` or `=5MB`) is rejected with
a startup warning and the safe default is kept — a misconfigured cap can never
silently disable the guard. Requests that violate a cap get a clear `4xx` (never a
silent drop):
| Cap | Env var | Default | Error |
|-----|---------|---------|-------|
| Request body | `CLAUDE_MAX_BODY_SIZE` | 5 MB | `413` request body too large |
| Per-image bytes | `CLAUDE_MAX_IMAGE_BYTES` | 5 MB | `413` `image_too_large` |
| Total image bytes | `CLAUDE_MAX_IMAGE_TOTAL_BYTES` | 20 MB | `413` `images_too_large` |
| Image count | `CLAUDE_MAX_IMAGES` | 20 | `413` `too_many_images` |
| Unsupported media type | — | — | `400` `unsupported_image_type` |
| Malformed data URI | — | — | `400` `invalid_data_uri` |
| Remote URL while disabled | `CLAUDE_IMAGE_ALLOW_URL` | off | `400` `remote_url_disabled` |
Base64 payloads are large: a 5 MB image is ~6.7 MB as a data URI, so raise
`CLAUDE_MAX_BODY_SIZE` (and, if needed, `CLAUDE_MAX_IMAGE_BYTES`) to admit big
images. Vision support depends on the target model — request a current
vision-capable Claude model.
## OpenClaw Integration
@@ -507,13 +507,13 @@ OCP was originally built for [OpenClaw](https://github.com/openclaw/openclaw) an
- **Multi-agent** — 8 concurrent requests sharing one subscription
- **No conflicts** — uses neutral service names (`dev.ocp.proxy` / `ocp-proxy`) that don't trigger OpenClaw's gateway-like service detection
### Install the Gateway Plugin
**Install the gateway plugin:**
```bash
cp -r ocp-plugin/ ~/.openclaw/extensions/ocp/
```
Add to `~/.openclaw/openclaw.json`:
Add to `~/.openclaw/openclaw.json`, then `openclaw gateway restart`:
```json
{
"plugins": {
@@ -523,96 +523,49 @@ Add to `~/.openclaw/openclaw.json`:
}
```
Restart: `openclaw gateway restart`
### Telegram / Discord Usage
After installing the gateway plugin, use `/ocp` slash commands in your chat:
```
/ocp status — Quick overview
/ocp usage — Plan usage limits & model stats
/ocp models — Available models
/ocp health — Proxy diagnostics
/ocp keys — List all API keys (multi mode)
/ocp keys add <name> — Create a new key
/ocp keys revoke <name> — Revoke a key
```
> **Note:** Terminal CLI uses `ocp <command>`, Telegram/Discord uses `/ocp <command>`.
After installing, use `/ocp` slash commands in your chat: `/ocp status`, `/ocp usage`, `/ocp models`, `/ocp health`, `/ocp keys`, `/ocp keys add <name>`, `/ocp keys revoke <name>`.
## Troubleshooting
### Requests fail or agents stuck
The simplest path: ask your AI — paste `Run `ocp doctor` and follow its `next_action`. Tell me if you hit anything that needs human input.` The doctor emits a JSON `next_action` with `ai_executable[]` (commands to run verbatim) and `human_required[]` (usually just OAuth).
```bash
# Clear sessions and restart
ocp clear
ocp restart
**Most common issues:**
# If using OpenClaw gateway
openclaw gateway restart
```
- **`EADDRINUSE: port 3456 already in use`** — an old OCP instance is bound. Find it (`lsof -nP -iTCP:3456 -sTCP:LISTEN`) and stop it (`launchctl bootout gui/$(id -u)/dev.ocp.proxy` on macOS, `systemctl --user stop ocp-proxy` on Linux). There is no `ocp stop` — the proxy is a service; `ocp restart` bounces it.
- **`node: command not found` / version error** — OCP needs Node.js 22.5+ (`node --version`).
- **`claude: command not found`** — install the Claude CLI, run `claude auth login`, then re-run `node setup.mjs`.
- **Usage shows "unknown" / 401** — usually an expired Claude CLI session: `claude auth login && ocp restart`. For the *permanent* TUI-mode `Please run /login · API Error: 401` that re-login can't fix, see [docs/troubleshooting.md § permanent TUI-mode 401](docs/troubleshooting.md#tui-401).
### Usage shows "unknown"
**Bootstrap quirks (one-time migrations):**
Usually caused by an expired Claude CLI session. Fix:
```bash
claude auth login
ocp restart
```
- **A TUI session vanished right after upgrading OCP** — if a pre-3.21.1 and a post-3.21.1 instance ran 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 returns under the new instance's port-scoped naming.
- **OpenClaw shows old models after `ocp update` (v3.10→v3.11 only)** — the running shell had the old `cmd_update` cached, so the sync hook doesn't fire on that single jump. Run once: `node ~/ocp/scripts/sync-openclaw.mjs && openclaw gateway restart`. Every future update syncs automatically.
### Startup log warns "OpenClaw registry out of sync"
Full manual — setup failures, env-var-not-taking-effect-after-restart (launchd bootout+bootstrap vs `kickstart -k`), stuck sessions, "OpenClaw registry out of sync", and the two-layer TUI-mode 401 root cause + fix: **[docs/troubleshooting.md](docs/troubleshooting.md)**.
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:
## Repository Layout
```bash
node ~/ocp/scripts/sync-openclaw.mjs
```
Top-level files a contributor or operator may need to know:
This is read-only at startup; the warning never blocks the gateway from running.
### 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
```
Future `ocp update` invocations sync automatically.
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `CLAUDE_PROXY_PORT` | `3456` | Listen port |
| `CLAUDE_BIND` | `127.0.0.1` | Bind address (`0.0.0.0` for LAN access) |
| `CLAUDE_AUTH_MODE` | `none` | Auth mode: `none`, `shared`, or `multi` |
| `OCP_ADMIN_KEY` | *(unset)* | Admin key for key management (multi mode) |
| `CLAUDE_BIN` | *(auto-detect)* | Path to claude binary |
| `CLAUDE_TIMEOUT` | `600000` | Request timeout (ms, default: 10 min) |
| `CLAUDE_HEARTBEAT_INTERVAL` | `0` | Streaming SSE keepalive interval (ms). `0` = disabled. See "Streaming heartbeat" section. |
| `CLAUDE_MAX_CONCURRENT` | `8` | Max concurrent claude processes |
| `CLAUDE_MAX_PROMPT_CHARS` | `150000` | Prompt truncation limit (chars) |
| `CLAUDE_SESSION_TTL` | `3600000` | Session expiry (ms, default: 1 hour) |
| `CLAUDE_CACHE_TTL` | `0` | Response cache TTL (ms, 0 = disabled). Set to e.g. `300000` for 5-min cache |
| `CLAUDE_ALLOWED_TOOLS` | `Bash,Read,...,Agent` | Comma-separated tools to pre-approve |
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass all permission checks |
| `CLAUDE_NO_CONTEXT` | `false` | Suppress CLAUDE.md and auto-memory injection (pure API mode) |
| `PROXY_API_KEY` | *(unset)* | Bearer token for shared-mode authentication |
| `PROXY_ANONYMOUS_KEY` | *(unset)* | Well-known anonymous key allowlist (multi mode). When set, this exact string bypasses `validateKey()` and grants public access. Exposed via `/health.anonymousKey` so clients auto-discover. See [Anonymous Access](#anonymous-access-optional). |
### Streaming heartbeat
When `CLAUDE_HEARTBEAT_INTERVAL` is set to a positive integer (milliseconds), OCP emits an SSE comment frame (`: keepalive\n\n`) on streaming responses whenever the stream has been idle for that duration. The timer resets on every real chunk, so heartbeats only fire during genuine silent windows (for example, Claude CLI tool-use pauses of 30s5min, or a long "processing large contexts" delay before the first token).
Use cases: downstream HTTP clients or load balancers with idle-connection timeouts that would otherwise abort a slow-but-alive request. `CLAUDE_HEARTBEAT_INTERVAL=30000` (30s) is a reasonable starting value if your downstream has a 60s idle timeout.
Heartbeats are inert SSE comment lines — conforming SSE clients ignore them. If your downstream client's SSE parser crashes on comment frames, leave this disabled (the default) and file an issue so we can consider an alternate frame format.
OCP also sends `X-Accel-Buffering: no` on SSE responses so nginx-default proxy buffering does not hold heartbeats in an upstream buffer.
| Path | Role |
|------|------|
| `server.mjs` | The proxy itself; every request path lives here. Governed by `ALIGNMENT.md`. |
| `setup.mjs` | First-time installer — verifies Claude CLI, patches OpenClaw config, installs auto-start. |
| `uninstall.mjs` | Reverses the launchd / systemd auto-start install. |
| `keys.mjs` | API-key management module (multi-mode auth: create/list/revoke, quotas, usage tracking). |
| `models.json` | Single source of truth for model IDs, aliases, context windows. See ADR 0003. |
| `ocp` / `ocp-connect` | User-facing CLI wrappers (server-side / client-side respectively). |
| `dashboard.html` | Static dashboard served from `/dashboard`. |
| `scripts/sync-openclaw.mjs` | Idempotent OpenClaw registry sync invoked by `ocp update`. See ADR 0004. |
| `.claude/skills/` | Project-specific Claude Code skills. |
| `ocp-plugin/` | OpenClaw gateway plugin (optional installation). |
| `docs/lan-mode.md` | LAN & multi-user operations manual (server/client setup, keys, quotas, anonymous access, security model). |
| `docs/tui-mode.md` | Subscription-pool (TUI) mode: setup, latency, streaming, warm-pane pool, drift monitoring. |
| `docs/troubleshooting.md` | Full troubleshooting manual, including the permanent TUI-mode 401 root cause + fix. |
| `docs/upgrading.md` | Upgrade manual (`ocp update` paths, rollback, OpenClaw auto-sync). |
| `docs/adr/` | Architecture Decision Records. Read these before proposing governance or SPOT changes — see [`docs/adr/README.md`](docs/adr/README.md). |
| `ALIGNMENT.md` | The constitution. Binding for any `server.mjs` change. |
| `AGENTS.md` / `CLAUDE.md` | Agent and Claude-Code-specific session instructions. |
## Security
@@ -625,6 +578,36 @@ OCP also sends `X-Accel-Buffering: no` on SSE responses so nginx-default proxy b
- **Keys stored locally** — `~/.ocp/ocp.db` (SQLite), never sent to external services
- **Auto-start** — launchd (macOS) / systemd (Linux)
## Governance
OCP runs under a small set of binding documents so contributions stay aligned with what `cli.js` actually does, not what an LLM thinks it does:
- **[`ALIGNMENT.md`](./ALIGNMENT.md)** — the constitution. Every endpoint OCP exposes must correspond to something `cli.js` actually does, with a line-number citation. Background in [ADR 0002](./docs/adr/0002-alignment-constitution.md).
- **[`.github/workflows/alignment.yml`](./.github/workflows/alignment.yml)** — CI guardrail. Greps `server.mjs` for known-hallucinated tokens and fails the build on any hit. Not suppressible without an `ALIGNMENT.md` amendment PR.
- **[`AGENTS.md`](./AGENTS.md)** — guidelines any AI coding agent (Claude Code / Cursor / Copilot / Codex / Gemini) should read before touching this repo.
- **[`models.json`](./models.json)** — single source of truth for the model registry. See [ADR 0003](./docs/adr/0003-models-json-spot.md).
- **[`docs/adr/`](./docs/adr/)** — architecture decision records explaining why current structure exists.
If you want to contribute: read `ALIGNMENT.md` first, search `cli.js` for the operation you're proposing, and cite the line number in your PR.
## Support OCP
OCP has been **open source from day one** — not a freemium tool, not a commercial product turned open, just open. It will stay that way forever. No paid tiers, no premium features, no "Pro" version locked behind a paywall.
I built it because my family and I needed it. We use OCP every day across our own machines and IDEs — keeping one Claude Pro/Max subscription powering everything, saving the per-token API cost we'd otherwise pay. It's been quietly heartwarming to hear from users online who say OCP has saved them money the same way it saves ours. That's the whole point.
Behind every version are hundreds of hours that don't show up in commits: building it from scratch, adding new features as the Claude Code ecosystem evolves, debugging across Mac / Windows / Linux machines, validating against half a dozen IDEs (Claude Code, Cursor, Cline, OpenCode, Aider, Continue.dev, OpenClaw), tracking down `cli.js` drift, OAuth refresh edge cases, SSE streaming quirks, concurrency leaks, and the occasional incident that turns into a multi-day investigation (the [2026-04-11 alignment drift](./docs/adr/0002-alignment-constitution.md), the [v3.11.1 concurrency leak](./CHANGELOG.md), the v3.12 SSE replay regression).
**The commitment**: this project will keep being updated, keep getting new features, and will stay open source as long as I'm able to maintain it.
**Please try it.** If something breaks or could be better, [open an issue](https://github.com/dtzp555-max/ocp/issues) — feedback is genuinely what keeps the project moving.
And if OCP saves you (or your team, or your family) real money and you'd like to chip in toward the next debugging session:
-**[Buy me a coffee](https://buymeacoffee.com/dtzp555)**
Donations directly fund the time it takes to keep OCP saving the community money.
## License
MIT
MIT — see [`LICENSE`](LICENSE).
+43 -17
View File
@@ -55,7 +55,13 @@
</div>
<div class="section">
<h2>Usage by Key</h2>
<div class="flex" style="justify-content: space-between; align-items: center;">
<h2 style="margin: 0;">Usage by Key</h2>
<label id="usage-scope-toggle" class="flex" style="display:none; gap: 0.4rem; font-size: 0.8rem; color: #94a3b8; cursor: pointer;">
<input type="checkbox" id="usage-show-all" style="cursor: pointer;">
<span>Show all keys</span>
</label>
</div>
<table id="key-usage-table">
<thead><tr><th>Key</th><th>Requests</th><th>OK</th><th>Err</th><th>Avg Time</th><th>Last Request</th></tr></thead>
<tbody></tbody>
@@ -126,6 +132,10 @@ function fmtChars(n) {
return n > 1000 ? (n/1000).toFixed(0) + "K" : String(n);
}
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
function barColor(pct) {
if (pct >= 80) return "bar-red";
if (pct >= 50) return "bar-amber";
@@ -138,8 +148,8 @@ async function refreshStatus() {
const r = data.requests || {};
document.getElementById("status-cards").innerHTML = `
<div class="card"><div class="label">Status</div><div class="value"><span class="tag ${p.status === 'ok' ? 'tag-ok' : 'tag-err'}">${p.status || '?'}</span></div><div class="sub">v${p.version || '?'}</div></div>
<div class="card"><div class="label">Uptime</div><div class="value">${p.uptime || '?'}</div></div>
<div class="card"><div class="label">Status</div><div class="value"><span class="tag ${p.status === 'ok' ? 'tag-ok' : 'tag-err'}">${escapeHtml(p.status || '?')}</span></div><div class="sub">v${escapeHtml(p.version || '?')}</div></div>
<div class="card"><div class="label">Uptime</div><div class="value">${escapeHtml(p.uptime || '?')}</div></div>
<div class="card"><div class="label">Requests</div><div class="value">${r.total || 0}</div><div class="sub">${r.active || 0} active</div></div>
<div class="card"><div class="label">Errors</div><div class="value">${r.errors || 0}</div><div class="sub">${r.timeouts || 0} timeouts</div></div>
<div class="card"><div class="label">Sessions</div><div class="value">${p.activeSessions || 0}</div></div>
@@ -154,15 +164,15 @@ async function refreshStatus() {
document.getElementById("plan-cards").innerHTML = `
<div class="card">
<div class="label">Session (5h)</div>
<div class="value">${s.percent || '?'}</div>
<div class="value">${escapeHtml(s.percent || '?')}</div>
<div class="bar-bg"><div class="bar-fill ${barColor(sPct)}" style="width:${sPct}%"></div></div>
<div class="sub">Resets in ${s.resetsIn || '?'}</div>
<div class="sub">Resets in ${escapeHtml(s.resetsIn || '?')}</div>
</div>
<div class="card">
<div class="label">Weekly (7d)</div>
<div class="value">${w.percent || '?'}</div>
<div class="value">${escapeHtml(w.percent || '?')}</div>
<div class="bar-bg"><div class="bar-fill ${barColor(wPct)}" style="width:${wPct}%"></div></div>
<div class="sub">Resets in ${w.resetsIn || '?'}</div>
<div class="sub">Resets in ${escapeHtml(w.resetsIn || '?')}</div>
</div>
`;
}
@@ -170,25 +180,26 @@ async function refreshStatus() {
async function refreshUsage() {
try {
const data = await api("/api/usage");
const showAll = localStorage.getItem("ocp_usage_show_all") === "1";
const data = await api(showAll ? "/api/usage?all=true" : "/api/usage");
const tbody = document.querySelector("#key-usage-table tbody");
tbody.innerHTML = (data.byKey || []).map(k => `
<tr>
<td>${k.key_name}</td>
<td>${escapeHtml(k.key_name)}</td>
<td>${k.requests}</td>
<td>${k.successes}</td>
<td>${k.errors}</td>
<td>${fmtTime(k.avg_elapsed_ms)}</td>
<td class="mono">${k.last_request || '-'}</td>
<td class="mono">${escapeHtml(k.last_request || '-')}</td>
</tr>
`).join("") || '<tr><td colspan="6" style="color:#475569">No usage data yet</td></tr>';
const rtbody = document.querySelector("#recent-table tbody");
rtbody.innerHTML = (data.recent || []).slice(0, 20).map(r => `
<tr>
<td class="mono">${r.created_at?.slice(11, 19) || '?'}</td>
<td>${r.key_name}</td>
<td>${r.model}</td>
<td class="mono">${escapeHtml(r.created_at?.slice(11, 19) || '?')}</td>
<td>${escapeHtml(r.key_name)}</td>
<td>${escapeHtml(r.model)}</td>
<td>${fmtChars(r.prompt_chars)}</td>
<td>${fmtChars(r.response_chars)}</td>
<td>${fmtTime(r.elapsed_ms)}</td>
@@ -204,16 +215,21 @@ async function refreshKeys() {
try {
const data = await api("/api/keys");
document.getElementById("key-mgmt-section").style.display = "";
// Admin-only "Show all keys" toggle for /api/usage scope.
document.getElementById("usage-scope-toggle").style.display = "flex";
const tbody = document.querySelector("#keys-table tbody");
tbody.innerHTML = (data.keys || []).map(k => `
<tr>
<td>${k.name}</td>
<td class="mono">${k.keyPreview}</td>
<td class="mono">${k.created_at}</td>
<td>${escapeHtml(k.name)}</td>
<td class="mono">${escapeHtml(k.keyPreview)}</td>
<td class="mono">${escapeHtml(k.created_at)}</td>
<td><span class="tag ${k.revoked ? 'tag-err' : 'tag-ok'}">${k.revoked ? 'revoked' : 'active'}</span></td>
<td>${k.revoked ? '' : `<button class="btn btn-sm btn-danger" onclick="revokeKeyUI('${k.name}')">Revoke</button>`}</td>
<td>${k.revoked ? '' : `<button class="btn btn-sm btn-danger" data-revoke="${escapeHtml(k.name)}">Revoke</button>`}</td>
</tr>
`).join("");
tbody.querySelectorAll("button[data-revoke]").forEach(btn =>
btn.addEventListener("click", () => revokeKeyUI(btn.getAttribute("data-revoke")))
);
} catch(e) { /* not admin */ }
}
@@ -240,6 +256,16 @@ async function refreshAll() {
document.getElementById("refresh-indicator").textContent = `Updated ${new Date().toLocaleTimeString()}`;
}
// Wire "Show all keys" toggle (visibility gated to admin via refreshKeys()).
(function setupUsageScopeToggle() {
const cb = document.getElementById("usage-show-all");
cb.checked = localStorage.getItem("ocp_usage_show_all") === "1";
cb.addEventListener("change", () => {
localStorage.setItem("ocp_usage_show_all", cb.checked ? "1" : "0");
refreshUsage();
});
})();
refreshAll();
setInterval(refreshAll, 30000);
</script>
-7
View File
@@ -1,7 +0,0 @@
services:
claude-proxy:
build: .
ports:
- "3456:3456"
env_file: .env
restart: unless-stopped
+100
View File
@@ -0,0 +1,100 @@
# OCP Promotion Strategy — "Stable & Visible"
> **This document is a recommendation for the maintainer to review and adjust, not a committed plan.**
> It reflects the project's current posture (post-v3.21.0) and should be revisited whenever
> the Anthropic billing / ToS environment changes significantly.
---
## 1. Goal: Polish + Low-Key OSS Visibility
The goal is **stability and quiet discoverability**, not growth-hacking. OCP is a personal power tool
that has been open-sourced because others can benefit from it. The right audience finds it via GitHub
search, issue threads in related projects, and word of mouth — not viral posts.
**Explicitly avoid:**
- HN / Reddit front-page pushes, influencer outreach, or any campaign that would attract a large
influx of users before the ToS/billing situation has settled. Anthropic is actively tightening
billing and enforcement on subscription-sharing (the June-15 Agent-SDK billing split is
*paused*, not cancelled — and consumer-ToS enforcement on multi-person sharing is a live risk).
A high-traffic spotlight right now would draw scrutiny that a low-profile project avoids.
- Promising features that require bypassing the `claude` CLI (raw API calls, OAuth extraction, etc.)
— that would violate `ALIGNMENT.md` and the ToS simultaneously.
---
## 2. Pre-Requisite: Stability First
Do not promote until the house is in order:
- [x] The concurrency / latency perf fixes are shipped (v3.20.xv3.21.0).
- [x] Docs honesty is complete (client-tools boundary, ToS sharing disclosure, this doc).
- [ ] The June-15 Agent-SDK billing split is either confirmed cancelled or OCP has a confirmed
stable path (TUI toggle as insurance — see §5 below).
Promoting a project that has known rough edges in docs or stability only generates support burden
and negative first impressions.
---
## 3. Honest ToS Disclosure on Sharing
Any promotion materials must carry the same disclosure as `README.md § "Deployment model & security"`:
> Pooling a single Claude subscription across **multiple distinct people** may violate Anthropic's
> Consumer Terms of Service and risk account suspension. The defensible framing is "one person,
> your own devices". Friends/team sharing is not.
This framing should appear in any README badge, linked blog post, or issue comment that mentions
LAN sharing. It is not a disclaimer that discourages usage — it is honest positioning that protects
both the project and its users.
---
## 4. What to Explicitly Skip
These items are **not gaps in OCP** — they are deliberate stance decisions:
- **Multi-backend routing** (routing to OpenAI, Gemini, Llama, etc.) — that is the sibling [OLP
project](https://github.com/dtzp555-max/olp)'s role. OCP stays Claude-only by design.
- **Gateway model-discovery** (auto-detecting which models a remote server offers) — not needed
for OCP's single-provider, single-subscription model. `models.json` is the SPOT.
- **Raw Anthropic API passthrough** (bypassing the `claude` CLI) — out of scope per `ALIGNMENT.md`.
Do not add these to OCP roadmaps or respond to feature requests for them with "planned" — the
correct answer is "that's OLP territory" or "out of scope per ALIGNMENT.md".
---
## 5. TUI Toggle as Insurance
The `CLAUDE_TUI_MODE` opt-in is the primary mitigation if the June-15 billing split reactivates
and makes the default `-p` path draw from the metered Agent SDK credit pool.
Keep the TUI toggle:
- Functional and tested across the three deployment hosts.
- Documented in the README, including the security constraints (single-user only).
- Easily discoverable for users who get unexpectedly metered.
If the split reactivates, the recommended operator path is: set `CLAUDE_TUI_MODE=true` +
`CLAUDE_CODE_OAUTH_TOKEN` → credential-isolated scratch home → subscription pool. That path is
already shipped and documented.
---
## 6. Low-Key Visibility Actions (when §2 pre-requisites are met)
- Keep the GitHub README polished and honest — it is the primary landing page.
- Respond promptly to issues and PRs — the project's reputation is built on reliability, not
marketing.
- Add OCP to the `awesome-claude` / `awesome-llm-tools` lists if they exist and allow self-PRs
— low-effort, targeted, reaches the right audience.
- When related projects (Cline, OpenCode, OpenClaw, Continue.dev) post about local Claude proxies,
a short factual comment linking to OCP is appropriate — not spam.
- Maintain the `CHANGELOG.md` with clear, honest summaries — users who are already running OCP
are the best vector for word-of-mouth.
---
*Last updated: v3.21.0 cleanup cycle. Maintainer should re-read before any external promotion.*
+79
View File
@@ -0,0 +1,79 @@
# 0005 — OCP Stays Single-Provider; No Multi-Provider Refactor
- **Date**: 2026-05-06
- **Status**: Accepted
- **Authors**: project maintainer (with AI advisory drafting)
- **Related**: ADR 0002 (Alignment Constitution), ADR 0003 (`models.json` SPOT)
## Context
OCP's `server.mjs` reached 1667 lines and now provides response cache, per-key quota, session tracking, model-level stats, and SSE heartbeat — all targeting a single backend path: `spawn` the locally installed `cli.js` and let it transact with `api.anthropic.com`. This architecture is the source of OCP's only real differentiator: **`cli.js` behavior-level alignment** (session create-vs-resume semantics, tool_use id reuse, SSE quirks, etc.) — none of which a generic LLM gateway has, because none of them speak this protocol.
The maintainer evaluated extending OCP to support OpenAI / Gemini / OpenRouter / Together / Groq / Ollama — i.e., turning OCP into a multi-provider gateway resembling Helicone, LiteLLM, OpenRouter, or Portkey. The motivation for that extension: reduce dependency on Anthropic, broaden OCP's commercial surface, and stop being grayscale-positioned (the `cli.js` spawn pattern depends on the local Pro/Max subscription, which Anthropic could fingerprint and disable).
The honest engineering estimate for that extension:
| Phase | Net New LOC | Calendar Time (part-time) |
|---|---|---|
| Provider abstraction + OpenAI | ~1230 + schema migration | 2 weeks |
| Add Gemini | ~550 | +1.5 weeks |
| OpenAI-compatible family (OpenRouter / Together / Groq) | ~300 | +1 week |
| Tests, docs, hardening | — | +1.5 weeks |
| **Multi-provider v1** | ~2080 | **~7 weeks focused** |
That number is not the real cost. The real cost is **strategic**:
1. **Loss of unique value.** `cli.js` behavior alignment is meaningless for OpenAI / Gemini / Ollama traffic. Going multi-provider means OCP's only moat applies to ~30% of its surface; the other 70% is generic gateway code already done better by Helicone / LiteLLM.
2. **Hybrid architecture awkwardness.** A multi-provider OCP would have two paths: `spawn(cli.js)` for Claude (still grayscale, depends on Pro subscription), and direct API call for everyone else (clean, BYOK). Customers asking "what is OCP?" would hear two different answers depending on which model they pick. This is worse than either pure path.
3. **Direct competition with funded incumbents.** Helicone (~$5M raised, YC W23), OpenRouter (~$1B valuation), LiteLLM (significant enterprise revenue), Portkey, Langfuse, Cloudflare AI Gateway — all already do multi-provider gateway with mature dashboards, audit logs, SOC2, and team features. OCP would enter that market 2+ years late with one engineer.
4. **The grayscale problem isn't solved by adding providers.** As long as OCP keeps the `cli.js` spawn path for Anthropic, it remains grayscale for that path; adding OpenAI alongside doesn't make the Anthropic path any less dependent on a Pro/Max subscription that wasn't licensed for proxying.
The maintainer's separate decision (recorded in personal notes, not this repo) is that **OCP itself will not be commercialized**; it will remain a personal power tool plus open-source contribution. Any commercial gateway work, if pursued, will start from a clean codebase with BYOK from day one — not from OCP.
Given that, the multi-provider extension would buy OCP nothing: not a moat, not commercial readiness, not even meaningfully better personal utility (the maintainer overwhelmingly uses Claude).
## Decision
OCP stays single-provider. Specifically:
1. **No new providers added to `server.mjs`.** The dispatch path remains `spawn(cli.js) → api.anthropic.com`. Pull requests that introduce a `providers/` directory or a model-to-provider router are declined on the basis of this ADR.
2. **`models.json` schema stays Anthropic-only.** No `provider` field, no per-model cost/capability metadata that anticipates other providers. If non-Anthropic models ever need to be referenced (e.g., for OpenClaw provider list completeness), they live in a separate file or in OpenClaw's own config — not in OCP's SPOT.
3. **Cache improvements are in scope.** The existing response cache (in `keys.mjs`: `cacheHash` / `getCachedResponse` / `setCachedResponse` / `clearCache`) is acceptable to upgrade with stream replay, stampede protection (singleflight), per-key isolation, and Anthropic `cache_control` awareness. These reinforce the single-provider position; they do not create provider-extension surface area.
4. **Anthropic alignment work continues to be encouraged.** Anything that deepens `cli.js` behavior alignment — session lifetime, tool_use id semantics, SSE behavior, multi-account routing, model-tier observability — is the project's actual value and should be prioritized over generic-gateway features.
5. **Commercial work, if pursued, starts elsewhere.** A separate repository, separate name, BYOK from day one, no `cli.js` spawn. That repo is out of scope for OCP and is not bound by this ADR.
## Consequences
**Positive**
- Project scope stays bounded. The maintainer can keep evolving OCP at part-time pace without the multi-provider maintenance burden (every provider's API breaks at some point and demands attention).
- The unique value (`cli.js` alignment) is preserved and continues to compound — every new alignment fix increases OCP's distance from generic gateways.
- Future contributors reading the code see one architecture, not a hybrid; debugging stays tractable.
- Decisions about commercialization are decoupled from OCP's technical evolution. OCP can stay grayscale-personal-tool indefinitely without that being a blocker for any future commercial product.
**Negative**
- OCP cannot serve any user who needs OpenAI / Gemini / local LLM access. Those users must route through a different gateway (Helicone, LiteLLM, OpenRouter) or call providers directly.
- If Anthropic substantially changes `cli.js` (e.g., adds client attestation, removes the spawn-and-forward pattern, or migrates `claude` to a non-CLI form factor), OCP's core architecture breaks and there is no second backend to fall back to.
- The maintainer must resist a recurring temptation: "while I'm in here, let me just add OpenAI." The whole point of this ADR is to make that temptation cost a documented amendment, not a quiet PR.
**Neutral**
- This ADR records a non-decision in code: nothing in `server.mjs` changes today. Its purpose is to make future contributors (including the maintainer) explain themselves before going against it. Per the project's PR template and Iron Rule 11, an amendment to this ADR is the gating step before any provider-extension PR.
## Trigger conditions for revisiting this ADR
This ADR should be revisited (and possibly amended or superseded) if any of the following occur:
1. Anthropic ships a feature that breaks the `cli.js` spawn pattern OCP depends on, and the maintainer wants to keep OCP useful.
2. The maintainer makes a deliberate decision to commercialize OCP (rather than start a separate codebase). This requires explicit re-scoping; "let me try" is not enough.
3. A genuine user need emerges — e.g., the maintainer themselves starts using OpenAI / Gemini frequently from Claude Code workflows — that single-provider OCP cannot serve.
In all three cases, the response is **first amend this ADR**, then write code. Order is not optional.
+132
View File
@@ -0,0 +1,132 @@
# 0006 — OpenAI Shim Scope: Class A vs Class B Endpoints
- **Date**: 2026-05-20
- **Status**: Proposed — owner reviewing
- **Authors**: project maintainer (with AI drafting assistance)
- **Related**: `ALIGNMENT.md` (the constitution); ADR 0002 (Alignment Constitution provenance, PR #20, commit 2853088); PR #99 by external contributor (triggering incident — OpenAI `response_format` honoring on `/v1/chat/completions`)
## Context
`ALIGNMENT.md` was drafted in the aftermath of the 2026-04-11 drift (commit `b87992f` — fabricated `/api/oauth/usage` endpoint) and ratified in PR #20 / commit 2853088. Its five Rules are written in the language of a one-to-one proxy: Rule 1 (Grep First), Rule 2 (No Invention), Rule 3 (Match the Implementation), Rule 4 (Unalignable Features Are Deleted), Rule 5 (Cite Line Numbers in Commits). All five anchor explicitly on `cli.js` as the golden reference. This is correct and binding for the endpoints OCP was originally designed to forward — `/v1/messages`, `/api/oauth/*`, and the rate-limit-header extraction path that backs `/usage` — because for those, `cli.js` is the literal wire authority and any deviation is a drift risk.
OCP also exposes a second class of endpoint that the constitution does not currently distinguish: **OpenAI-compatible surface** that exists so non-Claude-Code clients (Honcho, OpenWebUI, OpenAI SDK consumers, BYO scripts) can talk to claude via OCP. The flagship is `/v1/chat/completions`, which translates between OpenAI's request/response schema and `cli.js`'s native protocol. `cli.js` never speaks OpenAI's wire format — by construction it cannot, because OpenAI and Anthropic are different vendors with different protocols. There is no `cli.js:NNNN` to cite for OpenAI's `messages[].role` field handling, OpenAI's streaming `delta` shape, OpenAI's `stop` event names, or OpenAI's `response_format` parameter. The protocol authority for these is OpenAI's published specification, not `cli.js`.
The structural gap surfaced when PR #99 (external contributor `jaekwon-park`) added support for the OpenAI `response_format` request field on `/v1/chat/completions`. A strict reading of Rule 2 ("OCP must not introduce request fields that are not present in `cli.js`") blocks the PR. But the same strict reading also blocks the existence of `/v1/chat/completions` itself — every OpenAI-shaped field on that endpoint is, by definition, not in `cli.js`. The endpoint has been in OCP since before the constitution was written and is used by real downstream consumers. The constitution and the endpoint cannot both be correct under the current reading.
The 2026-04-11 drift remains the cautionary tale that drove the constitution and remains binding. The drift was not "OCP exposed an endpoint that wasn't in `cli.js`" — it was specifically "OCP claimed to forward `cli.js`'s `/api/oauth/usage` call when no such call exists in `cli.js`." That is a Class A failure mode: a forwarding endpoint that lied about what it was forwarding. The fix to that failure mode (Rules 1, 2, 3, 5; CI blacklist; reviewer gate) was correct then and is correct now. This ADR does not relitigate that decision and does not soften Rules 15 for the class of endpoint they were designed to discipline.
What this ADR does is acknowledge that OCP has two classes of endpoint, and that the discipline that fits Class A does not fit Class B without distortion. Class B needs its own anchor (OpenAI's specification) and its own authorization gate (an ADR per endpoint), so contributors know exactly which rule set applies to their PR and so Class B never becomes a backdoor for "OCP can do anything OpenAI-shaped."
## Decision
Introduce an explicit two-class taxonomy of OCP endpoints:
- **Class A — `cli.js`-mirror endpoints.** Endpoints that exist because `cli.js` performs the equivalent operation and OCP forwards, observes, or multiplexes that operation. Rules 15 of `ALIGNMENT.md` apply verbatim. The citation requirement is `cli.js:NNNN` (or `cli.js vE4 <functionName>`).
- **Class B — OCP-owned compatibility endpoints.** Endpoints that exist because OCP itself surfaces them, with no `cli.js` analogue. They fall into two sub-buckets:
- **B.1 — OpenAI-compatibility surface.** Endpoints implementing OpenAI's published API contract so non-Anthropic clients can use OCP. The protocol authority is OpenAI's specification.
- **B.2 — OCP-administrative surface.** Endpoints that exist purely to operate the proxy itself (health, dashboard, key management, cache control). The authority for these is the ADR that authorized the endpoint's existence.
For Class B endpoints, the citation requirement shifts from `cli.js:NNNN` to **(a)** the relevant specification section (OpenAI spec section for B.1, or the authorizing ADR for B.2) **and (b)** the ADR that authorized the endpoint's existence in the first place.
### Grandfather provision for existing B.2 inventory
ADR 0006 retroactively authorizes the existing B.2 endpoints listed in the inventory table below, **frozen at their current behaviour as of v3.16.4**. This is a one-time grandfather provision intended to avoid a 12-ADR back-fill burden for endpoints that have existed in OCP since before any constitutional governance was written.
The grandfather provision is narrowly scoped:
- It covers only the B.2 endpoints enumerated in the inventory table as of this ADR's merge date.
- It freezes those endpoints at their **current behaviour**. Any change to the request shape, response shape, or semantics of a grandfathered B.2 endpoint is treated as a new authorization request and requires either (a) a behaviour-preserving refactor PR with no contract change, or (b) its own ADR.
- It does **not** authorize new B.2 endpoints. Any new B.2 endpoint, or any new method on a grandfathered B.2 endpoint, requires its own ADR before merge.
- It does **not** extend to B.1 (OpenAI-compat) endpoints. B.1 endpoints are bounded by OpenAI's published specification, not by a behaviour snapshot — there is no grandfather equivalent for them.
The structural intent is: take the one-time hit of declaring "current B.2 surface is authorized" cleanly, then make every future addition pay the ADR-per-endpoint cost. This prevents Class B from becoming a backdoor for general OCP-owned-surface invention while not blocking the present ADR on twelve back-fill PRs.
### Current Class B inventory (enumerated from `server.mjs`)
The following endpoints exist today in `server.mjs` and are Class B (no `cli.js` analogue):
| Endpoint | Method | Sub-bucket | Authorizing ADR |
|---|---|---|---|
| `/v1/chat/completions` | POST | B.1 (OpenAI-compat) | ADR 0006 |
| `/v1/models` | GET | B.1 (OpenAI-compat) | ADR 0006; content sourced from `models.json` per ADR 0003 |
| `/health` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/dashboard` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/sessions` | GET, DELETE | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/logs` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/status` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/settings` | GET, PATCH | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/api/keys` | GET, POST | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/api/keys/:id` | DELETE | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/api/keys/:id/quota` | GET, PATCH | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/api/usage` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/cache/stats` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
| `/cache` | DELETE | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
For Class A reference, the current Class A inventory is `/v1/messages` (forwarded directly to `api.anthropic.com/v1/messages`) and the OAuth bearer / rate-limit-header machinery used by `handleUsage()` (which calls `https://api.anthropic.com/v1/messages` to extract `anthropic-ratelimit-unified-*` headers, per the in-file comment at line 845849). The `GET /usage` endpoint surface itself is Class B (administrative augmentation: it adds `proxy:` and `models:` blocks not present in any upstream API), but the data fetch underlying it is Class A — see "Hybrid endpoints" below.
### Hybrid endpoints
`/usage` is a hybrid: the wire call out to `api.anthropic.com/v1/messages` is Class A and must continue to cite `cli.js`; the local synthesis on top (`proxy:` stats block, `models:` snapshot, response shape) is Class B and is authorized by this ADR. Any future change strictly to the wire-call layer is Class A; any change strictly to the synthesis layer is Class B. A PR touching both must satisfy both citation requirements.
## What does NOT change
The following continue to apply verbatim and are not weakened by this ADR:
- **Rules 1, 2, 3, 4, 5 of `ALIGNMENT.md`** for all Class A endpoints. The 2026-04-11 drift discipline is unchanged. Class A PRs still require `cli.js:NNNN` citations, still must match `cli.js`'s wire format byte-for-byte, and still face the Unalignable Policy if the citation cannot be produced.
- **CI blacklist** (`.github/workflows/alignment.yml`). The known-hallucinated token list (currently `api.anthropic.com/api/oauth/usage`) continues to be greppable-and-failable on every PR.
- **Reviewer gate** (CLAUDE.md hard requirements + Iron Rule 10). Implementation author may not self-approve; a fresh-context reviewer opens `cli.js` at the cited lines for Class A PRs.
- **Annual Alignment Audit** on 11 April. The Class A audit (re-verify each `server.mjs` Class A reference against the pinned `cli.js` SHA-256) continues unchanged.
- **Unalignable Policy.** A Class A endpoint that cannot be traced to a `cli.js` reference is still deleted, not deprecated.
- **Historical Lesson section in `ALIGNMENT.md`.** The 2026-04-11 drift remains the named cautionary incident, with commit SHAs intact.
## What additionally applies to Class B
The following are new and apply only to Class B endpoints:
1. **OpenAI specification as protocol authority (B.1).** The OpenAI compatibility surface follows OpenAI's published `/v1/chat/completions` specification (https://platform.openai.com/docs/api-reference/chat/create) — not OCP imagination, not "OpenAI probably does X," not generalization from adjacent OpenAI endpoints. The same anti-invention discipline that Rule 2 imposes for `cli.js` applies, with OpenAI's spec substituted as the reference.
2. **ADR-authorized endpoint existence.** Any new Class B endpoint, or any new Class B endpoint method, requires its own ADR before merge. The grandfather provision above covers existing B.2 inventory only. An "ADR-less" Class B endpoint added after this ADR merges is itself an alignment finding and is subject to deletion under a Class B equivalent of the Unalignable Policy (see Rule 4 mapping in `ALIGNMENT.md`'s new section).
3. **Class B citation format.** Class B PRs cite (a) the relevant specification section and (b) the authorizing ADR. Example for B.1: "OpenAI `chat/completions` API, `response_format` parameter (https://platform.openai.com/docs/api-reference/chat/create), authorized by ADR 0006." Example for B.2: "Authorized by ADR 0006 (grandfathered)" for grandfathered endpoints, or "Authorized by ADR 00NN" for endpoints with their own ADR.
4. **Class B audit cadence.** Class B endpoints are audited annually alongside the Class A audit. B.1 endpoints are audited against OpenAI's current `/v1/chat/completions` specification snapshot. B.2 endpoints (grandfathered or ADR-specific) are audited against their authorizing ADR — for grandfathered endpoints, the audit verifies the endpoint behaviour still matches its v3.16.4 snapshot; for ADR-specific endpoints, the audit verifies behaviour still matches the ADR. The B.1 specification pin lives in `docs/openai-compat-pin.md` (to be created alongside the first B.1 audit; not a prerequisite for this ADR to land).
5. **Reviewer expectation.** The fresh-context reviewer for a Class B PR opens the cited OpenAI spec section (B.1) or the authorizing ADR (B.2) instead of opening `cli.js`. The "I am not the commit author" rule and the "explicit approval comment naming the verified reference" rule continue.
## Consequences
**Positive**
- PR #99 becomes mergeable with a one-line scope declaration ("Class B — extends `/v1/chat/completions` per ADR 0006") plus the existing alignment-evidence section adapted to Class B citation format. The structural ambiguity that blocked it is removed.
- Future Class B contributors have a clear template and a defensible scope: "extend `/v1/chat/completions` for an OpenAI-spec field that's already in OpenAI's spec" is a well-formed PR; "add a new OCP-invented field that looks OpenAI-shaped" is not, and the same anti-invention discipline that protects Class A protects Class B.
- The Class A surface is structurally unchanged. Reviewers reading the new `ALIGNMENT.md` see a clean Class A regime with all five Rules intact, plus an enumerated and explicitly scoped Class B carve-out.
- The administrative endpoint surface (B.2) is no longer in a "is this even allowed under the constitution?" limbo. The grandfather provision cleanly authorizes the current inventory; new B.2 endpoints must earn their ADR.
**Negative**
- OCP now maintains a second alignment surface. OpenAI's `/v1/chat/completions` specification is also a moving target (OpenAI ships changes more than once per year, including breaking ones), so the B.1 audit has real work attached.
- The grandfather provision freezes the current B.2 behaviour. If a grandfathered B.2 endpoint has a latent bug or undesirable behaviour, "fixing" it is a contract change and now requires an ADR (or a behaviour-preserving refactor). This is intentional friction to prevent silent contract drift.
- Contributors must now choose Class A or Class B on every PR. Some will misclassify. The PR template's required Class A/B radio (see PR template update) and the reviewer's spec-or-cli verification step are the structural counter-measures.
**Mitigations**
- The Class B inventory is small (currently 14 endpoints) and is enumerated explicitly in `ALIGNMENT.md`. New entries require an ADR per item 2 above, so the inventory cannot grow silently.
- Anthropic-side change frequency (which drives Class A audit cost) is structurally higher than OpenAI's `chat/completions` shape, which has been stable across multiple OpenAI API versions. The marginal B.1 audit cost is low. The grandfathered B.2 audit cost is also low — most of those endpoints have not changed in months.
- The B.1 specification pin in `docs/openai-compat-pin.md` lets the audit anchor on a specific OpenAI spec snapshot, the same way the Class A pin anchors on a specific `cli.js` SHA-256. Drift detection then works the same way for both classes.
## Historical Lesson — explicit non-relitigation
This ADR does not relitigate the 2026-04-11 drift. The drift commit `b87992f` was Class A — it claimed `cli.js` forwarded a call that `cli.js` did not in fact make. The fix (constitution + CI blacklist + reviewer gate) was correct and remains binding for Class A. This ADR carves out Class B because the discipline that fits Class A does not fit a class of endpoint where `cli.js` is not the wire authority — not because the discipline was wrong, and not because the drift lesson is any less load-bearing.
A reviewer or future maintainer reading this ADR should not infer: "OCP relaxed its alignment rules." The Class A regime is structurally identical to the version that shipped in PR #20. What changed is that the constitution now names the scope of that regime precisely (the class of endpoint for which `cli.js` is the wire authority) instead of implicitly applying it to every endpoint, including ones the regime was never designed for.
## Alternatives considered
**(a) Refuse Class B as a category; close PR #99 and delete `/v1/chat/completions`.** This would resolve the structural ambiguity by enforcing Rule 2 maximally — if `cli.js` doesn't speak OpenAI's protocol, neither does OCP. Rejected: there is an existing user base on the OpenAI-compat surface, the surface is genuinely useful (it is OCP's bridge to non-Claude-Code agents), and deletion would be a load-bearing user-facing breakage in service of a doctrinal point that the constitution was never designed to make. The constitution was a response to the 2026-04-11 forwarding drift, not a charter against any OCP-owned surface.
**(b) Soften Rule 2 to "OCP must not introduce surface area not present in `cli.js` OR not authorized by an ADR."** This is the obvious diff and would unblock PR #99 with the smallest possible textual change. Rejected because it loses the precision that Class A needs. A combined Rule 2 means a Class A reviewer has to read the PR description twice to figure out which authority applies. The Class A/B split makes the question explicit at the PR-template level (the author picks the class) and at the reviewer level (the reviewer opens the appropriate reference). The cost of the split is one new section in `ALIGNMENT.md`; the benefit is no ambiguity in either class.
**(c) Move `/v1/chat/completions` and all OpenAI-compat surface out of OCP into a separate "ocp-openai-shim" repository.** This would cleanly resolve the scope question by moving Class B out of OCP entirely. Rejected as premature: the maintainer is one person, the OpenAI-compat surface today is a single endpoint plus its support, and the operational cost of two repositories (separate releases, separate CI, separate version coordination) exceeds the cost of one constitution with two named classes. If the OpenAI-compat surface ever grows to the size where a separate repo is justified, ADR 0006 is the natural pivot point — at that future date, the carve-out becomes a separation.
**(d) Twelve-ADR back-fill for the existing B.2 inventory before this ADR can merge.** Considered and rejected on cost grounds. Each back-fill ADR would be a short paragraph explaining what an existing endpoint does and why it's allowed; the educational value is low and the merge friction is high (12 PRs through the reviewer gate). The grandfather provision above achieves the same authorization outcome in one paragraph, while still requiring an ADR for any future B.2 endpoint. The trade-off: grandfathered endpoints are not individually documented to ADR-depth. Mitigation: the inventory table in `ALIGNMENT.md` lists every grandfathered endpoint by path and method, so the audit surface remains explicit.
+393
View File
@@ -0,0 +1,393 @@
# ADR 0007 — TUI Interactive Mode (subscription-pool bridge)
**Date:** 2026-05-31
**Status:** Accepted — amended by PR-4 (entrypoint hardening), PR-B (observability + concurrency), PR-C (env-token auth + defunct-reaping), PR-D (credential-isolated home — corrects PR-C)
**Deciders:** project maintainer
**Authority:** claude CLI v2.1.158 interactive mode — verified live on the test host that sessions launched without `-p` / `--output-format` carry `cc_entrypoint=cli` (subscription pool), not `cc_entrypoint=sdk-cli` (Agent SDK credit pool). Mechanism verified on cli.js v2.1.104; live-confirmed on v2.1.158.
---
## Context
On 2026-05-14 Anthropic announced (effective 2026-06-15) a billing split that routes requests by `cc_entrypoint`:
| `cc_entrypoint` value | Billing pool |
|-----------------------|-------------|
| `cli` | Pro/Max subscription pool |
| `sdk-cli` | Agent SDK credit pool (~$20/mo on Pro = easily exhausted) |
OCP's existing path (`claude --output-format stream-json -p`) sets `cc_entrypoint=sdk-cli`. After 2026-06-15 every OCP request will draw from the Agent SDK pool rather than the subscription.
The structural response: add an opt-in mode that drives a real **interactive** `claude` session (no `-p`, no `--output-format`), which carries `cc_entrypoint=cli` and therefore bills against the subscription. The response text is read from claude's native JSONL transcript instead of from `stdout`.
This is a personal-use A-path feature (single-user, single-subscription host). It is **not** a multi-tenant isolation layer.
### Source-verified entrypoint mechanism (PR-4 amendment)
Claude CLI's `main()` calls a startup function (`t$A` in the compiled bundle) that sets
`process.env.CLAUDE_CODE_ENTRYPOINT` **only if unset** to:
```
(argv has -p/--print/--init-only/--sdk-url OR !process.stdout.isTTY) ? "sdk-cli" : "cli"
```
The billing header reads `cc_entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT ?? "unknown"`.
The `"unknown"` branch is dead code for any real `main()` spawn — the startup function always
sets a value on unset env. The **real risk** is not `"unknown"`: it is a **lost TTY** (e.g. stdout
redirected or a non-PTY spawn) silently flipping the self-classification to `"sdk-cli"` and
drawing from the metered pool.
`cc_entrypoint` is one of ~6 upstream run-mode signals. The **dominant discriminator** is the
system-prompt identity block ("official CLI" vs "Claude Agent SDK"), which is driven by genuine
interactivity (no `-p`, no `--output-format`, real PTY) and is overridable by no env var. This
is the real reason the tmux/no-`-p` approach works: the spawn is genuinely interactive, not just
labelled as such.
---
## Decision
Add `CLAUDE_TUI_MODE=true` as an opt-in flag in `server.mjs`.
### How it works
1. Each request spawns a fresh tmux session running `claude --model <M> --session-id <UUID> --strict-mcp-config --disallowedTools 'mcp__*'` (no `-p`, no `--output-format`).
2. The spawn result is checked immediately: if `tmux new-session` returns a non-zero exit status (or a falsy result), the request is aborted with `tui_spawn_failed: tmux session not created` **before** the boot sleep. This is the spawn/PTY gate — OCP must not issue a billing request without a verified interactive session.
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"). **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.**
### Billing-classifier labeling (`OCP_TUI_ENTRYPOINT`, PR-4)
`CLAUDE_CODE_ENTRYPOINT` on the spawn env is managed by `resolveTuiEntrypointEnv(env, mode)`
(exported from `lib/tui/session.mjs`, pure, testable). The function **always deletes any
inherited value first** so a stray env var from OCP's own parent process can never leak in and
mislabel the billing header. Then:
| `OCP_TUI_ENTRYPOINT` | Behaviour |
|----------------------|-----------|
| `cli` (default) | Sets `CLAUDE_CODE_ENTRYPOINT=cli` deterministically — subscription-pool classification. **Honest only because the spawn is a genuine interactive PTY** (tmux pane, no `-p`, stdout not redirected, `new-session` verified). |
| `auto` | Deletes the key → claude self-classifies via `t$A` (TTY → `cli`). Use to observe/diagnose the real TTY-derived value. |
| `off` | Leaves the env exactly as inherited — diagnostics / honesty audit only. |
**Governing rule (verbatim):** *OCP may make a true value deterministic; it may never assert a
value the spawn's real state contradicts. When it cannot make the claim true (e.g. cannot
guarantee a PTY), it fails/drops the request — it does not force the signal.*
This is why the spawn/PTY gate (step 2 above) is load-bearing for `mode="cli"`: if `new-session`
fails, there is no PTY, so asserting `cli` would be dishonest. Abort rather than lie.
OCP never suppresses the billing header (anti-fingerprinting: we do not mask the spawn).
### 2026-06-15 verification protocol
Run one quiesced canary request in TUI-mode and watch the **Agent SDK credit balance** (not the
request header). If the balance drops, the subscription pool is unreachable via spawn. Per the
constitution (`ALIGNMENT.md`), the response is to **drop the Anthropic provider** rather than
escalate spoofing.
Version caveat: mechanism verified on cli.js v2.1.104 + live on v2.1.158. Re-verify after any
major cli.js upgrade.
### Default behaviour is unchanged
When `CLAUDE_TUI_MODE` is unset (the default), no code path touches `callClaudeTui` or `runTuiTurn`. `upstreamCall === callClaude` and streaming uses `callClaudeStreaming` — byte-for-byte identical to the pre-TUI code path.
### Kill-switch
Unset `CLAUDE_TUI_MODE` (or set it to any value other than `"true"`) → stream-json path restored immediately on next restart.
### Home strategy
> **Superseded by the PR-D amendment below for the env-token case.** As of PR-D, `TUI_HOME`
> is computed by `resolveTuiHome()`: when `CLAUDE_CODE_OAUTH_TOKEN` is set (and `OCP_TUI_HOME`
> is unset) the default is a **credential-free scratch home**, not the real home. The
> descriptions below remain accurate for the **no-env-token** case and the **explicit
> `OCP_TUI_HOME` override** case.
- **Real-home (default when NO env token, `OCP_TUI_HOME` unset):** claude runs with the operator's own `~/.claude/` — shared credentials, existing onboarding, no OAuth fork risk. `ensureTuiCwdTrusted` seeds the trust record for the scratch cwd in the real `~/.claude.json` (atomic write).
- **Scratch-home opt-in (`OCP_TUI_HOME=<path>`, no env token):** a dedicated `HOME` that symlinks `~/.claude/.credentials.json` from the real home (token is never copied) and seeds a stripped `~/.claude.json` (no project history, trusts only the scratch cwd). **Caveat:** claude rewrites `.credentials.json` on OAuth token refresh, replacing the symlink with a regular file — this forks the credentials. Use this legacy symlink mode only with a dedicated OAuth or for ephemeral testing. (The PR-D env-token mode avoids this caveat entirely — no credentials file to fork.)
### Working directory
`TUI_CWD = OCP_TUI_CWD || $HOME/.ocp-tui/work` (dedicated scratch cwd). Transcripts land under `<HOME>/.claude/projects/<encoded-cwd>/` — a stable, single location separate from the operator's real project histories. The directory is created automatically on first request.
### MCP hard-disable
`--strict-mcp-config` (no `--mcp-config` argument) prevents account-attached managed MCP servers from connecting. Belt-and-braces: `--disallowedTools 'mcp__*'` blocks any MCP tool invocation even if a server were somehow loaded. Built-in tools (Bash, Read, etc.) are left enabled on the A-path (single-user, acceptable).
### Session namespace
All tmux sessions use the prefix `ocp-tui-`. The prefix-scoped reaper (`reapStaleTuiSessions`) kills only `ocp-tui-*` sessions, never `olp-tui-*` or any other prefix. A stale-session cleanup runs once at OCP boot when `TUI_MODE` is on.
---
## SECURITY — PROMINENT WARNING
**TUI-mode is SINGLE-USER / SINGLE-OPERATOR ONLY.**
`claude` runs as the OCP process owner with full filesystem access regardless of `HOME` setting. Home selection is **not** user isolation. If OCP is serving multiple users or guest API keys:
- A guest prompt would run `claude` with the **operator's** filesystem access.
- An adversarial prompt could exfiltrate files, run shell commands, or exhaust the subscription.
**Never enable `CLAUDE_TUI_MODE=true` on an OCP instance that serves untrusted callers or multiple users.**
The B-path (multi-tenant isolation) requires:
1. `--tools ""` (no built-in tools)
2. Per-key ephemeral `HOME` (isolated credentials + no cross-key project pollution)
3. Sandbox runtime (e.g. `@anthropic-ai/sandbox-runtime`)
B-path is **deferred** and is not implemented in this ADR. Until B-path lands, TUI-mode must only be enabled on a personal single-user OCP.
---
## Observability and concurrency (PR-B amendment)
**Date:** 2026-06-10
**Status:** Accepted — amends ADR 0007.
**Motivation:** the post-PR-A code audit, findings C-4 (P1) and C-5 (P1).
### C-4 — independent concurrency bound for the TUI path
The global `MAX_CONCURRENT` gate lives in `spawnClaudeProcess()` (the `-p` / stream-json
path). `callClaudeTui()` never calls `spawnClaudeProcess` — it calls `runTuiTurn()`, which
cold-boots a full interactive `claude` inside a fresh tmux session. So the TUI path had **no**
concurrency bound: N concurrent TUI requests spawned N simultaneous cold-boot tmux+claude
processes. On a small host (e.g. a Pi 4 serving a family) a burst of ~5 is an OOM risk and
also multiplies subscription rate-limit pressure.
PR-B adds an **independent** limiter for the TUI path (`lib/tui/semaphore.mjs`,
`TuiSemaphore`):
- **`OCP_TUI_MAX_CONCURRENT`, default `2`.** Rationale: a TUI turn is heavy — a per-request
cold-boot of tmux+claude plus up to `CLAUDE_TUI_WALLCLOCK_MS` (120 s) of wallclock — so a
small host cannot run many at once. `2` is the conservative default that keeps a Pi-class
host alive under a family burst while still allowing some overlap. It is deliberately **not**
the same knob as `MAX_CONCURRENT` (default 8): the two pools have different shapes (a
stream-json spawn is cheap and fast; a TUI turn is a heavy cold-boot + long wallclock), so
coupling them would mis-size one of the two paths.
- **Queue, don't reject.** The limiter **queues** (awaits a slot), mirroring the spirit of
`MAX_CONCURRENT` — requests are not dropped on contention. To bound memory against a runaway
client, the wait queue itself is capped (`maxQueue`, default 32× the limit); when the queue
is full `run()` rejects with `tui_queue_full`, surfaced as a 503 — deterministic backpressure
rather than silent OOM.
- **Slot released in a `finally`.** `TuiSemaphore.run(fn)` releases the slot in a `finally`, so
any throw — PR-A's honesty gates (`tui_wallclock_truncated`, `tui_upstream_error`), a
`tui_paste_not_landed`, or a `tui_spawn_failed` — can never leak a slot.
This limiter has **zero effect when `TUI_MODE` is off**: `callClaudeTui` is never reached, so
the semaphore is never entered. The default stream-json path is untouched.
### C-5 — operator-visible drift surface on `/health` (additive)
The `tui_entrypoint_mismatch` warning only reached journald. After the 2026-06-15 flip, a
silent `sdk-cli` drift (the documented top risk in this ADR — a lost TTY flipping the
self-classification to the metered Agent SDK pool) would drain metered credits **invisibly**.
PR-B adds a `tui` block to the `/health` JSON response so an operator can poll it:
```
tui: {
enabled: <TUI_MODE>,
entrypointMode: <OCP_TUI_ENTRYPOINT>, // cli | auto | off
lastEntrypoint: <last observed cc_entrypoint, e.g. "cli", or null>,
entrypointMismatches: <count of cli-expected-but-got-other turns>,
inflight: <current concurrent TUI turns>,
queued: <turns waiting for a slot>,
maxConcurrent: <OCP_TUI_MAX_CONCURRENT>
}
```
`lastEntrypoint` is recorded and `entrypointMismatches` incremented inside `callClaudeTui` in
the same mismatch branch that already emits the journald warning (via `recordTuiEntrypoint`).
`inflight` / `queued` / `maxConcurrent` come from the C-4 semaphore. When `TUI_MODE` is off the
block still appears with `enabled:false` (cheap, harmless) so the response shape is stable for
consumers regardless of mode.
### ALIGNMENT authorization for the `/health` change
`/health` is a **grandfathered B.2 endpoint** under ADR 0006, frozen at its v3.16.4 behaviour.
`ALIGNMENT.md`'s grandfather provision states: *"Any change to the contract (request shape,
response shape, semantics) of a grandfathered B.2 endpoint is treated as a new authorization
request and requires either a behaviour-preserving refactor PR or its own ADR."*
This amendment **is** that authorization. The argument:
- The change is **additive**: it adds one new top-level field (`tui`) containing only new
sub-fields. **No existing `/health` field is changed, renamed, removed, or re-typed**, and no
existing semantics change. Existing `/health` consumers (the dashboard, `ocp-connect`,
monitoring) read the fields they already read and are unaffected — the change is
**behaviour-preserving** for them, which is exactly the bar the grandfather provision sets for
a non-ADR contract change.
- The TUI observability surface is an **intrinsic part of the TUI feature** whose authorizing
authority is **this ADR (0007)**, not a brand-new B.2 endpoint. We are not adding a new B.2
endpoint or a new method (which would each require their own fresh ADR under the New Class B
endpoint procedure) — we are extending the response of an existing grandfathered endpoint with
fields that report state owned by an ADR-0007 feature. ADR 0007 is the natural home for that
authority, and this amendment records it explicitly.
- `cli.js` does not perform this operation — `/health` is OCP-owned (Class B), so no `cli.js`
citation applies; the citation is this ADR + ADR 0006 (grandfathered B.2) per
`ALIGNMENT.md`'s Class B citation requirement.
### `OCP_TUI_MAX_CONCURRENT` summary
| Env var | Default | Meaning |
|---|---|---|
| `OCP_TUI_MAX_CONCURRENT` | `2` | Max concurrent interactive TUI turns. Independent of `CLAUDE_MAX_CONCURRENT` (the stream-json path). Excess turns queue (bounded); a full queue yields a 503. |
---
## Authentication + defunct-reaping (PR-C amendment)
**Date:** 2026-06-13
**Status:** Accepted — amends ADR 0007.
**Motivation:** the PI231 production incident — TUI-mode returned `Please run /login · API Error: 401` for days; re-login never stuck.
### How the TUI `claude` authenticates
The spawned interactive `claude` obtains its OAuth bearer in one of two ways, in this order of preference:
1. **`CLAUDE_CODE_OAUTH_TOKEN` in env (PREFERRED).** If the env var is set on the OCP process, `buildTuiCmd` adds `CLAUDE_CODE_OAUTH_TOKEN=<shq-escaped token>` to the pane command's `env` prefix. claude then authenticates via this long-lived token and **never touches the credentials-refresh path**. This is the stable mode — it is exactly how the oracle and Mac-mini hosts already run (and how `server.mjs`'s own `getOAuthCredentials()` takes the same env at highest precedence). cli.js is **not** the authority here: this is a Class B, OCP-owned TUI spawn — see the Class B citation below.
2. **`<HOME>/.claude/.credentials.json` (FALLBACK).** When the env var is unset, claude falls back to the credentials file and its short-lived access token, renewing via the single-use refresh token.
The token MUST be set explicitly in `buildTuiCmd` because **tmux does not forward the parent process's environment to the pane** (verified live 2026-06-01 — the same reason the whole env is delivered as an `env` prefix). A token sitting in the OCP process env is invisible to the pane unless `buildTuiCmd` re-emits it.
### Why the fallback path corrupts (the PI231 incident)
When the env token is absent, every per-request spawn drives claude through the credentials.json refresh path. OAuth refresh tokens are **single-use / rotating**: a refresh consumes the old refresh token and writes a new one. The per-request `kill-session` teardown can race / interrupt claude mid-rotation, and over many spawn+kill cycles the refresh token ended up an **empty string** — at which point renewal is impossible and the host returns a permanent 401. Re-login writes a fresh token, but the next spawn re-corrupts it. **Proof the env-token fix works:** on the broken PI231 host, `CLAUDE_CODE_OAUTH_TOKEN=<oat01 token> claude -p ...` returned a real answer *despite* the corrupt credentials.json (control without the env token = 401).
**Operator guidance:** set `CLAUDE_CODE_OAUTH_TOKEN` on any TUI-mode host. The credentials.json fallback is retained only for hosts that intentionally rely on it; it is not recommended for a long-running TUI deployment.
**Security note:** with the token in the pane command, it is visible in `ps`. This is acceptable for the **single-user A-path** (it mirrors the existing plaintext-token practice for `server.mjs`), and the **multi-user B-path is already refused at boot** (`CLAUDE_TUI_MODE=true` + `AUTH_MODE=multi` is a hard FATAL), so a guest can never reach this spawn.
### Defunct `<claude>` reaping
The connected leak: the pane's `claude` process is a child of the long-lived **tmux server** daemon, not of the OCP node process (`tmux new-session -d` returns the instant the server forks the pane). Node can therefore never `waitpid()`/reap it — a SIGKILL still needs the *parent* (the tmux server) to reap. `kill-session` destroys the session but leaves the pane's `claude` (and its grandchildren) as `<defunct>` zombies that only the server reaps; over 30 days on PI231 this accumulated to **25 defunct `<claude>`** (a live `tmux kill-server` dropped it 25→3).
The node-reachable action that *actually reaps* — rather than merely re-signalling — is to stop the tmux server: on server exit the kernel reparents survivors to init (PID 1), which reaps them. `reapStaleTuiSessions` therefore, after killing our own `ocp-tui-*` sessions, issues `kill-server` **only when no foreign session of any prefix remains** (coexistence: never disrupt a co-hosted `olp-tui-*` instance). This runs at boot (existing) and now on a 15-min periodic interval gated on TUI-mode and on the TUI path being idle (`inflight === 0 && queued === 0`) so a live turn's pane is never torn down. Residual: a request whose pane is created in the narrow window between the idle-check and `kill-server` would fail cleanly via the existing honesty gates (rare; documented in the server comment).
### ALIGNMENT authorization (Class B)
Both changes are **Class B** (OCP-owned TUI spawn). `cli.js` does not perform either operation — there is no `cli.js` analogue for "how the TUI pane authenticates" or "reaping tmux-server-owned zombies"; this surface is authorized by **this ADR (0007)** per `ALIGNMENT.md`'s Class B citation requirement. No Class A wire surface, no endpoint shape, no `alignment.yml` blacklist token, and no `models.json` entry is touched.
---
## Credential-isolated home for env-token auth (PR-D amendment)
**Date:** 2026-06-13
**Status:** Accepted — amends ADR 0007. **Corrects** the PR-C rationale and the original "Home strategy" section's scratch-home caveat.
**Motivation:** PR-C's env-token passing alone did **not** fix the PI231 401. Decisive live evidence (claude 2.1.104, PI231):
| Condition | Result |
|---|---|
| env token passed + a broken `~/.claude/.credentials.json` present | **401** (`Please run /login · API Error: 401`) |
| env token passed + `credentials.json` moved aside | **works** (real answer) |
### Corrected root cause
**Interactive `claude` PREFERS `~/.claude/.credentials.json` over the `CLAUDE_CODE_OAUTH_TOKEN` env var.** A stale/corrupt `credentials.json` therefore **shadows** the env token. (This is *unlike* `-p` mode, where the env token wins — which is why `server.mjs`'s own `getOAuthCredentials()` is unaffected and why PR-C's premise looked sufficient.) So passing the token (PR-C, `buildTuiCmd`) is **necessary but insufficient**: the TUI `claude` must additionally run in a HOME that has **no `credentials.json`**, so the env token is the only credential and is authoritative.
This also fixes the original incident at the **root**, more completely than PR-C claimed: with no `credentials.json` in the home, claude never runs the token-refresh path at all, so the single-use refresh token can never be rotated — and therefore never corrupted — by the spawn+`kill-session` cycle. The 25-zombie / empty-refresh-token failure mode becomes structurally impossible, not merely avoided.
### Decision
When `CLAUDE_CODE_OAUTH_TOKEN` is set, the TUI `claude` runs in a **credential-free scratch home** by default:
- `resolveTuiHome({ realHome, configuredHome, envTokenSet })` (exported from `lib/tui/session.mjs`, pure) decides the home:
- **`OCP_TUI_HOME` set** → that path (explicit override, back-compat — an operator who configured it keeps exactly that home).
- **else env token set** → `<realHome>/.ocp-tui/home` — a dedicated scratch home seeded with a minimal `.claude.json` (`hasCompletedOnboarding=true` + trust **only** the scratch cwd) and its own `projects/` dir, and **deliberately NO `.credentials.json`** (no symlink, no copy).
- **else (no env token)** → the operator's real home — **byte-for-byte the pre-fix behaviour** for hosts that intentionally rely on `credentials.json`.
- `prepareTuiHome(realHome, tuiHome, cwd, { envTokenMode })` gates the credential handling: in `envTokenMode` it creates the scratch `projects/` dir and seeds the minimal trusted `.claude.json` but **never** creates the credentials symlink. `runTuiTurn` sets `envTokenMode = !!CLAUDE_CODE_OAUTH_TOKEN && ehome !== realHome`.
- `readTuiTranscript` reads from the **same** home claude runs under (`ehome`), so transcripts land under `<scratch home>/.claude/projects/` and `findTranscriptPath` globs them there — the home is threaded through consistently. (We chose scratch-`HOME` over `CLAUDE_CONFIG_DIR`: the binary supports `CLAUDE_CONFIG_DIR`, but it relocates the transcript root to `<CONFIG_DIR>/projects/` rather than `<HOME>/.claude/projects/`, which would fork the transcript-resolution rule across modes for no benefit. The scratch-HOME lever reuses the existing, tested `prepareTuiHome`/`ehome` plumbing.)
### This RESOLVES — not reintroduces — the scratch-home caveat
The original "Home strategy" section and PR-C's `prepareTuiHome` comment warned that scratch-home is unsafe because *claude rewrites a **symlinked** `.credentials.json` on token refresh → forks/corrupts the OAuth credentials*. **That caveat does not apply to env-token mode**: there is no `credentials.json` in the home to fork, and claude never refreshes (it uses the long-lived env token), so there is no rotation and no corruption. The fork risk was inherent to the *symlink* approach; removing the credentials file entirely removes the risk. The legacy symlink mode is retained **only** for an operator who explicitly sets `OCP_TUI_HOME` without an env token, and its caveat is preserved for exactly that path.
### ALIGNMENT authorization (Class B)
**Class B** (OCP-owned TUI spawn). `cli.js` has no analogue for the TUI pane's auth/home strategy; authorized by **this ADR (0007)** per `ALIGNMENT.md`'s Class B citation requirement. `server.mjs` is touched only to compute `TUI_HOME` via `resolveTuiHome()` (TUI wiring) and to surface the auth mode in the boot log — no Class A wire surface, no endpoint shape, no `alignment.yml` blacklist token, and no `models.json` entry is touched.
---
## Consequences
### Positive
- After 2026-06-15, requests in TUI-mode bill against the Pro/Max subscription pool (`cc_entrypoint=cli`) rather than the Agent SDK credit pool.
- Kill-switch is immediate (unset env var + restart); zero code change required.
- Default stream-json path is untouched — no regression risk for existing deployments.
### Negative / trade-offs
- **No token streaming:** responses are buffered then replayed as chunked SSE. Clients see a delay then the full response arrives; real-time token streaming is not available in TUI-mode.
- **Billing unmeasurable until 2026-06-15:** the `cc_entrypoint=cli` signal is verified, but the credit deduction from the correct pool cannot be confirmed until the billing split activates.
- **tmux dependency:** the host must have `tmux` installed. CI / Docker images that lack tmux cannot use TUI-mode (the default stream-json path is unaffected).
- **Wall-clock cap:** long Opus thinking turns may hit the 120 s cap. Increase `CLAUDE_TUI_WALLCLOCK_MS` if needed (no quiescence heuristic — the reader polls until terminal marker or cap).
- **Grey-area usage:** running an interactive `claude` session headlessly to serve HTTP requests is not an officially documented use case. If Anthropic policy changes to block this pattern, OCP must fall back to the stream-json path (unset `CLAUDE_TUI_MODE`).
### Coexistence
- tmux prefix `ocp-tui-` is registered. Any co-hosted OLP test instance must use `olp-tui-`. Never run two TUI proxies on the same OAuth concurrently — stop one instance during integration testing.
---
## 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 610 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 S1S6 / T1T6 were validated live on the test host against `claude v2.1.158`.
+208
View File
@@ -0,0 +1,208 @@
# ADR 0008 — TUI Warm Pane Pool
**Date:** 2026-07-13
**Status:** Proposed
**Extends:** [ADR 0007](0007-tui-interactive-mode.md) (TUI interactive mode). This ADR does not
change ADR 0007's billing-pool argument, security posture, or kill-switch — it adds a latency
optimization *inside* the TUI spawn machinery ADR 0007 owns.
---
## Context
TUI mode (ADR 0007) serves every request by cold-booting a fresh `tmux` session running an
interactive `claude`, submitting one prompt, reading the native transcript, and killing the
session. That cold boot is paid on **every** request.
[`docs/plans/2026-07-13-tui-latency/`](../plans/2026-07-13-tui-latency/README.md) measured the
TUI path and listed a warm pane pool as backlog item #3, costed at "**~1.0 s**" (the observed
boot-to-input-bar time). Instrumenting the real request path showed that estimate is **~4×
too low**. Phase decomposition of the cold path (n=6 medians, Sonnet 4.6, `--effort low`,
through a real OCP instance):
| Phase | Median |
|---|---|
| prep (trust cwd, write prompt file) | 2 ms |
| `tmux new-session` | 27 ms |
| **boot → input bar ready** | **1232 ms** |
| paste (`load-buffer` + `paste-buffer`) | 8 ms |
| paste-verify poll | 426 ms |
| **submit → transcript terminal** | **8458 ms** |
| teardown | 8 ms |
| **total** | **10162 ms** |
| *claude's own reported `turn_duration`* | *5539 ms* |
| **OCP-side overhead** | **4490 ms** |
The `submit → terminal` phase exceeds claude's own `turn_duration` by **~2.9 s**. That gap is
**post-input-bar initialization inside `claude`** — work that a pane which has merely *sat idle
for a few seconds* has already completed. A direct spike confirmed it: an identical pane, idle
12 s before receiving the same prompt, completed its turn in a median 5537 ms versus 7980 ms
cold.
So a warm pane recovers **~1.26 s of boot *and* ~2.9 s of in-`claude` cold start** — not the
~1.0 s the plan predicted.
The reason this was worth a pool rather than a "keep one session and reuse it" cache is a
hazard already flagged in the code. `lib/tui/transcript.mjs` returns the **last text-bearing
assistant entry in the whole transcript file**, which is correct *only* under OCP's
one-session-per-request model, and it says so:
> *"If a future warm-pool ever reuses a session WITHOUT a fresh session-id / clear, earlier-turn
> text could leak — that author must add user-line scoping here."*
Reusing a pane for a second turn puts two exchanges in one transcript and would leak the earlier
turn's text into the later turn's answer — a **cross-request data leak**, not merely a bug.
---
## Decision
Add an **opt-in pool of pre-booted, single-use `claude` panes**, `OCP_TUI_POOL_SIZE` (default
`0` = off, max `4`). Implementation: `lib/tui/pool.mjs`.
### 1. Panes are SINGLE-USE. This is the load-bearing rule.
A pooled pane serves **exactly one turn**, then is killed and replaced in the background. Each
pane is booted with its **own fresh `--session-id`**, fixed at spawn, and the turn locates its
transcript by that id.
This preserves one-session-per-request exactly, so the `transcript.mjs` hazard above **does not
arise** and no user-line scoping was needed. The warning in `transcript.mjs` is deliberately
left standing, now annotated: it still binds anyone who later wants a pane to serve a second
turn, or to reset a session with `/clear` and reuse it. **Neither is permitted without first
adding user-line scoping to the transcript reader.**
Rejected alternative — *reuse a pane for N turns, `/clear` between* — is strictly cheaper
(no re-boot per request) and was rejected on exactly this basis. The latency win is not worth a
cross-request text-leak surface guarded only by a `/clear` that we cannot verify landed.
### 2. The pool is keyed by model, and a MISS is always safe.
`--model` is fixed at spawn, so a pane can only serve the model it booted with. A pool miss
falls back to the existing cold-boot path with **zero behavioural difference**. There is no
boot-time pre-warm and no configured model: OCP cannot know which model the next caller wants,
so the pool warms the **most recently requested** model. Consequence, stated plainly: **the
first request after start, and the first after any model switch, is always a cold miss.**
### 3. The pool and the session reaper coexist by an explicit invariant.
This is the subtle part. `reapStaleTuiSessions()` kills every session matching this instance's
`ocp-tui-<port>-` prefix, and issues `tmux kill-server` when no foreign session remains (the
only mechanism that can reap `<defunct>` `claude` zombies — the pane's `claude` is a child of
the tmux *server*, not of node). A warm pooled pane **is** one of our own sessions, alive and
idle **by design** — and the periodic sweep runs precisely **when the instance is idle**, i.e.
exactly when the pool is full.
The invariant, stated in a comment above `reapStaleTuiSessions` and pinned by tests:
1. **A live pooled pane is never reaped — including one that is still BOOTING.** The reaper
takes a `spare` set of **exact session names** supplied by the pool's live registry.
2. **An orphaned pooled pane IS still reaped.** Membership is by **exact name from a live
in-memory registry, never by name shape**. A pane the pool no longer owns — handed out,
dropped, cancelled, or left behind by a previous process generation (whose registry died with
it) — is absent from `spare` and is killed like any other stale session. **Fail-safe:
omitting `spare` reaps *more*, never less.** Pool panes are named `ocp-tui-<port>-p<hex>`
purely for operator legibility; that shape is *not* the exemption mechanism.
3. **`kill-server` is suppressed while any pane is spared** (it would kill a live child of the
tmux server). Therefore **the pool is DRAINED immediately before every sweep**, so `spare` is
empty on the normal tick and `kill-server` still fires. Without the drain, a permanently-full
pool would **permanently disable zombie reaping** — the pool would silently break the thing
the sweep exists to do. The drain costs one pane re-boot per tick (15 min).
The `spare` mechanism is belt-and-braces given the drain: it makes it impossible for a reap call
site that *forgets* to drain to kill a live pane.
### 4. 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`, 20 s) for the input bar. So **a pooled tmux session can be live for ~20 s before
its boot resolves.** A pool that tracked in-flight boots as a *count* could not name that
session, and this produced two real bugs (both caught in review, both now regression-tested):
- the periodic sweep **killed the booting pane** (it could not be spared), then left the pool
empty with nothing scheduled, and logged the exact `tui_pool_boot_failed` warning 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 node's
`activeProcesses` set is empty and the "wait for children" path exits immediately), so any
cleanup deferred to a `.then()` never ran.
The pool therefore **mints each pane's identity up front** (`{sessionId, name}`, name derived
from the session-id so `tmux ls` correlates to the transcript file) 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.
### 5. Refills take no concurrency slot, and are serialized.
A refill boot deliberately does **not** take a `TuiSemaphore` slot: those slots bound concurrent
*turns* and belong to real requests, and charging a background pre-boot against them would let
the pool starve the traffic it exists to speed up. It cannot leak a slot either, since it never
holds one. Boots are **serialized** (one at a time): two cold boots racing an in-flight turn were
observed to overrun even the generous pool readiness cap. 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` = 5 × `BOOT_MS`): `BOOT_MS` is
tight because a *client* is blocked on it, which is not true of a pre-boot. Slow ≠ broken.
---
## Consequences
### Cost — standing processes, paid whether or not a request arrives
**A warm pane is a live idle `claude` process.** Peak process count is
`OCP_TUI_POOL_SIZE` + `OCP_TUI_MAX_CONCURRENT` + 1 (booting replacement). This is the whole
reason the pool is **default-off**: an operator must opt into holding processes for traffic that
may never come. Size is clamped to `POOL_MAX_SIZE` = 4; an unparseable value **disables** the
pool rather than guessing.
Panes carry a 10-minute TTL and are health-checked at hand-out; a dead or degraded pane becomes
a **miss** (cold path), never a hung turn.
### Benefit
Measured end-to-end through a real OCP instance (Sonnet 4.6, `--effort low`):
**p50 10.17 s (n=6, pool off) → 6.00 s (n=12 warm hits) — 4.2 s / 41%.**
### The floor is unchanged
The pool does not touch the **~6 s TTFT floor** documented in the latency plan (claude always
prefills the full Claude Code system prompt). TUI mode remains unsuitable for interactive /
real-time consumers; it is for batch and background work. This ADR does not change that
conclusion.
### Observability
`/health`'s `tui` block gains a `pool` sub-object (`null` when off): `size`, `warm`, `booting`,
`model`, `hits`, `misses`, `boots`, `bootFailures`, `cancelled`, `dropped`. 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. A steadily climbing `dropped` is
**normal** (the 15-min sweep drains and re-boots the pool on every tick, by design — see
Decision 3).
### ALIGNMENT authorization
- **Class B / OCP-owned.** The warm pool is process management around the `claude` CLI — the
same category as the existing tmux session lifecycle and the defunct-session reaper it extends.
**`cli.js` does not perform this operation, and no `cli.js` citation applies**; the authority
is ADR 0007 (which owns the TUI spawn machinery) plus this ADR. This is `ALIGNMENT.md` Rule 2's
Class B citation requirement, discharged explicitly rather than by silence.
- **The `/health` extension** adds sub-fields to the `tui` block. That block is **owned by ADR
0007** and post-dates ADR 0006's v3.16.4 grandfather snapshot, so it is not part of the frozen
B.2 inventory. The change is additive — every pre-existing `/health` field keeps a
byte-identical value, and `pool` is `null` unless the operator opts in — which is the
behaviour-preserving bar ADR 0006 sets. This ADR records that authorization.
- **No spawn argument changed.** `buildTuiCmd` is byte-identical; the pool calls it with the same
arguments. Banner-verified on live pooled panes: `· Claude Max`, never `API Usage Billing`
(the `--bare` trap documented in the latency plan).
### What a future contributor must not undo
- **Do not let a pane serve a second turn** (or `/clear`-and-reuse one) without first adding
user-line scoping to `lib/tui/transcript.mjs`. That is a cross-request text leak, not a perf
tweak. See Decision 1.
- **Do not remove the drain-before-sweep.** It is what keeps `kill-server` zombie reaping alive.
See Decision 3.
- **Do not go back to counting in-flight boots.** The pool must be able to *name* a session that
exists but has not finished booting. See Decision 4.
@@ -0,0 +1,54 @@
# ADR 0009 — Prompt-char budget derives from the models.json SPOT
Date: 2026-07-18
Status: Accepted (maintainer directive, 2026-07-18: "37.5k 截断未免太短了吧 … 这个在现在还适用吗")
## Context
`MAX_PROMPT_CHARS` (the tail-first truncation guard in `messagesToPrompt`) defaulted to a
hand-set constant of 150,000 chars ≈ 37.5k English tokens — set in the 200k-window era as a
runaway-context guard. Meanwhile `models.json` advertises `contextWindow: 200000` for every
model (and the underlying CLI registry carries 1M native windows for Opus 4.8 / Sonnet 5), and
`scripts/sync-openclaw.mjs` feeds that 200k into OpenClaw's compaction budget. The result was
a standing dishonesty identified in the PR #152 review: **no advertised contextWindow value was
true**, because the proxy silently guillotined every request at ~37.5k tokens — roughly 5×
below the advertised window — logging only a server-side warning the client never sees.
Raising the constant to another hand-set number would rot the same way. Following the model's
native 1M directly is also wrong: chars ≠ tokens (CJK runs ~11.5 chars/token vs ~4 for
English, so a 1M-token char cap would let CJK text sail past the model's real window into an
upstream rejection), single near-window requests can consume a large fraction of a 5-hour
subscription quota window, and the TUI paste path is untested at megabyte scale.
## Decision
The default budget **derives from the SPOT** instead of being a constant:
```
MAX_PROMPT_CHARS (default) = max(models.json models[].contextWindow) × 3 chars/token
= 200000 × 3 = 600,000 chars today
```
Implemented as the pure `derivePromptCharBudget(models, {charsPerToken = 3, floor = 150000})`
in `lib/prompt.mjs` (unit-tested; floor guards degenerate SPOT states). The multiplier ×3 is
deliberately conservative: full window for English, and CJK text reaches the model's real
window at roughly the same point the cap fires — so OCP truncates gracefully (tail-first)
instead of the upstream rejecting outright.
`CLAUDE_MAX_PROMPT_CHARS` (env) and the runtime settings API remain **absolute overrides**;
the derivation applies only when neither is set.
## Consequences
- The advertised `contextWindow: 200000` becomes honest: the proxy now actually accepts
prompts of that order (English ≈150200k tokens) before truncating.
- If `models.json` ever advertises a larger window (e.g. 1M for the 1M-native models), the
budget scales automatically — no code change. Whether to advertise 1M is a **separate,
deliberate decision** (quota burn per request, OpenClaw compaction memory, TUI paste
limits) and is explicitly NOT made by this ADR; the current recommendation is to keep
200000 advertised until a real >200k use case appears.
- One-time behavior change: requests between 150k and 600k chars that were previously
truncated now pass through whole — longer TTFT and higher quota consumption for those
requests, by design.
- The truncation mechanism, logging, and the multimodal-path budget threading (PR #154's F2,
pending) are unchanged — only the default value's provenance changed.
+42
View File
@@ -0,0 +1,42 @@
# Architecture Decision Records
This directory holds the OCP Architecture Decision Records (ADRs) — short documents that capture the **why** behind structural choices.
Read these before proposing governance, SPOT (single-source-of-truth), or process changes.
## Numbering
ADRs start at `0002`. The first one (`0001`) was reserved for an early
internal proposal that was superseded before publication; `0002` is
deliberately the first published record so the archived `0001` slot
remains a placeholder rather than being silently renumbered.
New ADRs increment from the highest existing number. Filenames are
`NNNN-<short-slug>.md`.
## Index
| ADR | Title | What it covers |
|---|---|---|
| [0002](0002-alignment-constitution.md) | Alignment Constitution | The `ALIGNMENT.md` constitution: why every `server.mjs` change requires `cli.js` citation + independent reviewer + CI blacklist pass. Background: the 2026-04-11 drift incident. |
| [0003](0003-models-json-spot.md) | `models.json` as SPOT | Why model IDs / aliases / context windows live in a single JSON file (not duplicated in `server.mjs` and `setup.mjs` arrays). v3.11.0 refactor. |
| [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 15 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. |
## When to write a new ADR
Open one whenever:
- A structural rule is being added or changed (e.g., new SPOT, new boundary, new CI guardrail).
- A decision encodes a lesson from an incident or drift.
- A future contributor reading the code alone could plausibly undo or re-litigate the choice.
Skip ADRs for routine implementation choices (algorithm pick, naming) — those belong in commit messages.
## Format
Keep ADRs short — Context / Decision / Consequences is the standard skeleton. Cite incidents, PRs, or commits where useful.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

After

Width:  |  Height:  |  Size: 366 KiB

+396
View File
@@ -0,0 +1,396 @@
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 │
└───────────────────────┬─────────────────────────────────┘
│ LAN
┌───────────────────┼───────────────────┐
▼ ▼ ▼
Laptop Phone/Tablet Pi / Server
(client) (browser) (client)
```
## Server Setup
> **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.522.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.
**Verify:**
```bash
curl http://127.0.0.1:3456/v1/models
# Returns: claude-opus-4-8, claude-opus-4-7, claude-opus-4-6, claude-sonnet-5, claude-sonnet-4-6, claude-haiku-4-5-20251001
```
### Headless install notes
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
as an OpenAI-compatible API for local IDEs.
Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
§Quickstart (single-machine install):
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 6 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
OpenAI-compatible endpoint.
Please follow https://github.com/dtzp555-max/ocp/blob/main/docs/lan-mode.md
"Server Setup" → "LAN mode" path:
1. Verify prerequisites: macOS or Linux (Windows not supported), Node.js
22.5+, git, Claude CLI installed and authenticated.
2. Generate a strong admin key with `openssl rand -base64 32`. Save it —
I'll need it to manage per-user keys later.
3. git clone https://github.com/dtzp555-max/ocp.git && cd ocp
4. Run `node setup.mjs --bind 0.0.0.0 --auth-mode multi`.
5. Add OCP_ADMIN_KEY to my shell rc (~/.zshrc or ~/.bashrc).
6. Run `ocp lan` to show me the LAN IP and connect command.
7. Optionally create example keys: `ocp keys add laptop`, `ocp keys add tablet`.
8. Verify: `curl http://127.0.0.1:3456/v1/models` returns 6 models.
Tell me each step before running it. On error, diagnose before retrying.
```
**Client connect** — configure this device to use an existing OCP server on your LAN:
```text
There's an OCP server at <SERVER_IP> on my LAN. Configure this machine to
use it for any local IDEs (Cursor, Cline, Continue.dev, OpenCode, OpenClaw).
Server IP: <SERVER_IP>
API key (leave blank if the server has anonymous mode enabled): <OPTIONAL_KEY>
Please follow https://github.com/dtzp555-max/ocp/blob/main/docs/lan-mode.md
"Client Setup" path:
1. Download ocp-connect:
curl -fsSL https://raw.githubusercontent.com/dtzp555-max/ocp/main/ocp-connect -o ocp-connect
chmod +x ocp-connect
2. Run `./ocp-connect <SERVER_IP>` (add `--key <KEY>` if you have one).
3. Follow any IDE-specific manual hints it prints.
4. Verify: `curl http://<SERVER_IP>:3456/v1/models` returns 6 models.
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:
```bash
curl -fsSL https://raw.githubusercontent.com/dtzp555-max/ocp/main/ocp-connect -o ocp-connect
chmod +x ocp-connect
./ocp-connect <server-ip>
```
**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`:
```bash
./ocp-connect <server-ip> --key <your-api-key>
```
Or as a one-liner (no file saved):
```bash
curl -fsSL https://raw.githubusercontent.com/dtzp555-max/ocp/main/ocp-connect | bash -s -- <server-ip>
```
Example:
```
$ ./ocp-connect 192.168.1.100
OCP Connect v1.3.0
─────────────────────────────────────
Remote: http://192.168.1.100:3456
Checking connectivity...
✓ Connected
Remote OCP v3.11.0 (auth: multi)
ⓘ Using server-advertised anonymous key: ocp_publ...n_v1
(set by admin via PROXY_ANONYMOUS_KEY; see issue #12 §14 Path A)
Testing API access...
✓ API accessible (6 models available)
Shell config:
✓ .bashrc
✓ .zshrc
OPENAI_BASE_URL=http://192.168.1.100:3456/v1
System-level (launchctl):
✓ OPENAI_BASE_URL set for GUI apps and daemons
IDE Configuration
─────────────────────────────────────
Detected: OpenClaw (~/.openclaw/openclaw.json)
Configure OpenClaw to use this OCP? [Y/n] y
Provider name (models show as <name>/model-id) [ocp]: ocp
How should OCP models be configured?
1) Primary — use OCP by default, keep existing models as backup
2) Backup — keep current primary, add OCP as additional option
Choice [1]: 1
Writing OpenClaw config...
✓ Per-agent auth profile seeded (2):
• ~/.openclaw/agents/main/agent/auth-profiles.json
• ~/.openclaw/agents/macbook_bot/agent/auth-profiles.json
✓ OpenClaw configured
Provider: ocp
Models:
• ocp/claude-opus-4-8
• ocp/claude-opus-4-7
• ocp/claude-opus-4-6
• ocp/claude-sonnet-5
• ocp/claude-sonnet-4-6
• ocp/claude-haiku-4-5-20251001
Priority: PRIMARY (default model)
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.
![OCP Dashboard](images/dashboard.png)
## Auth Modes
| Mode | Env | Use Case |
|------|-----|----------|
| `none` | `CLAUDE_AUTH_MODE=none` | Trusted home network, no auth needed |
| `shared` | `CLAUDE_AUTH_MODE=shared` + `PROXY_API_KEY=xxx` | Everyone shares one key |
| `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:
```bash
# Set a daily limit of 50 requests for a key
curl -X PATCH http://127.0.0.1:3456/api/keys/wife-laptop/quota \
-H "Authorization: Bearer $OCP_ADMIN_KEY" \
-d '{"daily": 50}'
# Set multiple limits at once
curl -X PATCH http://127.0.0.1:3456/api/keys/son-ipad/quota \
-H "Authorization: Bearer $OCP_ADMIN_KEY" \
-d '{"daily": 20, "weekly": 100}'
# Check current quota + usage
curl http://127.0.0.1:3456/api/keys/wife-laptop/quota
# → { "daily": { "limit": 50, "used": 12 }, "weekly": { "limit": null, "used": 34 }, ... }
# Remove a limit (set to null)
curl -X PATCH http://127.0.0.1:3456/api/keys/wife-laptop/quota \
-d '{"daily": null}'
```
When a key exceeds its quota, OCP returns HTTP 429 with a structured error:
```json
{
"error": {
"message": "Quota exceeded: 50/50 requests (daily). Resets 6h 12m.",
"type": "quota_exceeded",
"quota": { "period": "daily", "limit": 50, "used": 50, "resetsIn": "6h 12m" }
}
}
```
- `null` = unlimited (default for all keys)
- Only successful requests count toward quota
- 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
+11
View File
@@ -0,0 +1,11 @@
# OpenAI Compatibility Pin (Class B.1)
**Status:** Placeholder — populated at first B.1 audit per ADR 0006 §"Class B audit cadence".
This file is the Class B.1 counterpart to the Class A `cli.js` audit pin in `ALIGNMENT.md` §"Golden Reference". When the first annual alignment audit covers Class B (per `ALIGNMENT.md` §"Annual Alignment Audit"), this file will be populated with:
- The OpenAI `/v1/chat/completions` specification snapshot date being audited against (and the source URL the snapshot was taken from).
- The list of B.1 endpoints (currently `/v1/chat/completions`, `/v1/models`) and, for each one, the specific OpenAI spec fields and behaviours it honors.
- Drift detection notes for any OpenAI spec changes since the previous audit, and any OCP code changes required to track those changes.
Until populated, this file's existence is only a forward reference so that the link in `ALIGNMENT.md` does not 404. The actual audit procedure is defined in `ALIGNMENT.md` §"Annual Alignment Audit" (Class B scope) and ADR 0006 §"Class B audit cadence".
+236
View File
@@ -0,0 +1,236 @@
# TUI-mode latency: measured floor, and the four things worth fixing
**Date**: 2026-07-13
**Status**: findings + backlog. **Superseded in part** — see the dated update boxes below.
Item #1 shipped ([#156](https://github.com/dtzp555-max/ocp/pull/156)); item #2 is **dead**
([`streaming-spike.md`](streaming-spike.md)); item #4 measured, **no effect**; item #3 stands.
**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)
**Evidence**: [`measurements.jsonl`](measurements.jsonl) — **n=15** (3 configs × 5) · banner captures [`billing-banner.txt`](billing-banner.txt) · harness [`floor.sh`](floor.sh)
## Why this exists
An external consumer (the 知音 AI project) benchmarked OCP's prompt path and measured
**TTFT p50 ≈ 3032 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.841.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 3032 s is exactly that.
> **⚠️ 2026-07-13 correction — this decomposition attributes the ~20 s to the wrong thing.** It was
> inferred from the external 3032 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 3032 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.430.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.430.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.3217.19 s to **5.876.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 (~57 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.8710.54 s) vs **6.17 s** (5.876.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 24 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
bash docs/plans/2026-07-13-tui-latency/floor.sh 5 # baseline
TAG=effort-low EXTRA_ARGS="--effort low" bash .../floor.sh 5 # 40 %
TAG=bare EXTRA_ARGS="--bare" bash .../floor.sh 5 # the trap
# billing-pool check for ANY spawn-flag change — the banner is the only source of truth
tmux new-session -d -s probe -x 200 -y 50 -c "$HOME" \
"claude --model claude-sonnet-5 --session-id $(uuidgen) <your-flags-here>"
sleep 6; tmux capture-pane -p -t probe | grep -E "Claude Max|API Usage Billing"
tmux kill-session -t probe
```
## Interaction with OCP while the harness runs
- **Kill direction is safe both ways**: `reapStaleTuiSessions()` only `kill-session`s names
matching `ocp-tui-<port>-`, which `zhiyin-floor-*` never matches; and the harness only
`kill-session`s its own single session — it contains **no `kill-server`**.
- **One benign interaction** (only when TUI mode is enabled — the reap tick is itself gated on
`TUI_MODE`): OCP's periodic `kill-server` (zombie reaping) is gated on
`othersRemain`*any* foreign-prefixed tmux session suppresses it. So while the harness is
running, that sweep is skipped. This is the coexistence guard working as designed; it resumes
on the next tick.
## Harness caveats (stated so the numbers are not over-trusted)
- **n=5 per config**, single host, single model (Sonnet 5), single prompt size (~1850 tokens).
Enough to separate 6 s from 10 s from 30 s; **not** enough for a p95.
- TTFT is "marker visible in `capture-pane`", which includes tmux render latency (small, but
nonzero) — it is an upper bound on the true first-token time.
- **The harness's readiness marker is not OCP's.** `floor.sh` waits for `│ >||Try "`; OCP's
`tuiInputReady()` matches `/\? for shortcuts/`. These are different events, so the ~1.0 s
boot figure is **not** directly comparable to OCP's `BOOT_MS` gate (default cap 4000 ms). It
does not affect the conclusions (1 s ≪ 6 s TTFT), but it is not apples-to-apples.
- The first version of this harness reported TTFT **0.08 s** — a false positive: the prompt
literally contained the marker string it was grepping for, so the match fired the instant the
prompt was pasted. Fixed by describing the marker instead of spelling it. **The script exited 0
and "successfully" produced 5 samples both times** — exit status proves nothing here.
@@ -0,0 +1,3 @@
[] | ▝▜█████▛▘ 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
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env bash
# OCP TUI-mode latency floor harness — see README.md in this directory.
#
# 目的:回答一个问题——如果把 OCP 现有的两个已知开销砍掉
# (a) 每请求 spawn + boot(可用预热进程池消除)
# (b) 假流式(等 turn_duration 才返回,可用增量读 pane 消除)
# 之后,订阅池路径的**真实 TTFT 地板**是多少?
#
# 判据:地板 ≤ 4s → OCP 作为"省钱选项"可行;> 8s → 死透,不再讨论。
#
# 红线:
# - 不经过生产 OCP 服务(:3456)—— 直接起 tmux+claudeOCP 进程零干扰
# - tmux session 前缀用 zhiyin-floor-**不是** ocp-tui-),避免被 OCP 的
# reaper 当成自己的会话杀掉,也避免我们杀到它的
# - 用 real HOME(凭据)—— scratch HOME + symlink 凭据会 fork OAuth 导致 401
# (见跨机记忆 tui_scratch_home_credential_fork
set -uo pipefail
N=${1:-5}
MODEL=${MODEL:-claude-sonnet-5}
EXTRA_ARGS=${EXTRA_ARGS:-} # 额外 CLI 参数(如 --effort low --bare
TAG=${TAG:-baseline}
OUT=${OUT:-$(dirname "$0")/measurements.jsonl}
PROMPT_FILE=$(mktemp)
PREFIX="zhiyin-floor"
mkdir -p "$(dirname "$OUT")"
# ── 构造提示:~2000 token 的假会议转写 + 明确的起始标记 ────────────────
# 单行(多行会在 tmux send-keys 时提前触发 Enter
build_prompt() {
local seg="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. "
local body=""
for _ in $(seq 1 22); do body+="$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"
}
build_prompt
PROMPT_CHARS=$(wc -c < "$PROMPT_FILE" | tr -d ' ')
now_ms() { python3 -c 'import time;print(int(time.time()*1000))'; }
echo "配置: $TAG 参数: [$EXTRA_ARGS]"
echo "模型: $MODEL 样本: $N 提示长度: ${PROMPT_CHARS} chars (≈$((PROMPT_CHARS/4)) token)"
echo "输出: $OUT"
echo
for i in $(seq 1 "$N"); do
SESS="${PREFIX}-$$-$i"
SID=$(uuidgen)
# ── 冷启动:spawn + 等输入框就绪 ─────────────────────────────────
T_SPAWN=$(now_ms)
tmux new-session -d -s "$SESS" -x 200 -y 50 \
-e CLAUDE_CODE_DISABLE_CLAUDE_MDS=1 \
-e CLAUDE_CODE_DISABLE_AUTO_MEMORY=1 \
-c "$HOME" \
"claude --model $MODEL --session-id $SID --strict-mcp-config --disallowedTools 'mcp__*' $EXTRA_ARGS" 2>/dev/null
if [ $? -ne 0 ]; then echo "[$i] tmux spawn 失败,跳过"; continue; fi
# 轮询输入框就绪(claude TUI 的输入提示符)
READY=0
for _ in $(seq 1 150); do # 上限 15s
PANE=$(tmux capture-pane -p -t "$SESS" 2>/dev/null || true)
if grep -qE '│ >||Try "' <<<"$PANE"; then READY=1; break; fi
sleep 0.1
done
T_READY=$(now_ms)
BOOT_MS=$((T_READY - T_SPAWN))
if [ "$READY" -ne 1 ]; then
echo "[$i] 启动超时(${BOOT_MS}ms),pane 末 3 行:"
tmux capture-pane -p -t "$SESS" 2>/dev/null | tail -3 | sed 's/^/ /'
tmux kill-session -t "$SESS" 2>/dev/null
continue
fi
# ── 热态:粘提示 → 回车 → 量首 token ─────────────────────────────
tmux send-keys -t "$SESS" -l "$(cat "$PROMPT_FILE")" 2>/dev/null
sleep 0.4 # 让粘贴落地(OCP 用 400ms 轮询粒度)
T0=$(now_ms)
tmux send-keys -t "$SESS" Enter 2>/dev/null
TTFT_MS=-1
for _ in $(seq 1 600); do # 上限 60s
if tmux capture-pane -p -t "$SESS" 2>/dev/null | grep -q '###CARD'; then
TTFT_MS=$(( $(now_ms) - T0 )); break
fi
sleep 0.1
done
# ── 完整回答:pane 连续 2s 不再变化 ──────────────────────────────
COMPLETE_MS=-1
if [ "$TTFT_MS" -ge 0 ]; then
LAST=""; STABLE=0
for _ in $(seq 1 900); do # 上限 90s
CUR=$(tmux capture-pane -p -t "$SESS" 2>/dev/null | cksum)
if [ "$CUR" = "$LAST" ]; then
STABLE=$((STABLE+1))
[ "$STABLE" -ge 20 ] && { COMPLETE_MS=$(( $(now_ms) - T0 - 2000 )); break; }
else
STABLE=0; LAST="$CUR"
fi
sleep 0.1
done
fi
printf '{"i":%d,"tag":"%s","model":"%s","extra_args":"%s","prompt_chars":%s,"boot_ms":%d,"ttft_ms":%d,"complete_ms":%d}\n' \
"$i" "$TAG" "$MODEL" "$EXTRA_ARGS" "$PROMPT_CHARS" "$BOOT_MS" "$TTFT_MS" "$COMPLETE_MS" | tee -a "$OUT"
tmux kill-session -t "$SESS" 2>/dev/null
sleep 1
done
rm -f "$PROMPT_FILE"
echo
echo "=== 汇总 ==="
python3 - "$OUT" <<'EOF'
import json,sys,statistics
rows=[json.loads(l) for l in open(sys.argv[1]) if l.strip()]
ok=[r for r in rows if r['ttft_ms']>=0]
if not ok: print("无有效样本"); sys.exit()
def s(k):
v=[r[k] for r in ok if r[k]>=0]
return f"n={len(v)} 中位={statistics.median(v)/1000:.2f}s 最小={min(v)/1000:.2f}s 最大={max(v)/1000:.2f}s" if v else "无"
print(f" 冷启动 boot : {s('boot_ms')} ← 预热进程池可完全消除")
print(f" TTFT(首 token : {s('ttft_ms')} ★ 这就是地板")
print(f" 完整回答 : {s('complete_ms')}")
print(f"\n 失败样本: {len(rows)-len(ok)}/{len(rows)}")
EOF
@@ -0,0 +1,15 @@
{"i": 1, "tag": "effort-low", "model": "claude-sonnet-5", "extra_args": "--effort low", "prompt_chars": 7451, "boot_ms": 1077, "ttft_ms": 6172, "complete_ms": 9929}
{"i": 2, "tag": "effort-low", "model": "claude-sonnet-5", "extra_args": "--effort low", "prompt_chars": 7451, "boot_ms": 1026, "ttft_ms": 6160, "complete_ms": 9996}
{"i": 3, "tag": "effort-low", "model": "claude-sonnet-5", "extra_args": "--effort low", "prompt_chars": 7451, "boot_ms": 1010, "ttft_ms": 6437, "complete_ms": 9977}
{"i": 4, "tag": "effort-low", "model": "claude-sonnet-5", "extra_args": "--effort low", "prompt_chars": 7451, "boot_ms": 1033, "ttft_ms": 5872, "complete_ms": 9944}
{"i": 5, "tag": "effort-low", "model": "claude-sonnet-5", "extra_args": "--effort low", "prompt_chars": 7451, "boot_ms": 1154, "ttft_ms": 6387, "complete_ms": 9993}
{"i":1,"tag":"baseline","model":"claude-sonnet-5","extra_args":"","prompt_chars":7451,"boot_ms":1300,"ttft_ms":8321,"complete_ms":9939}
{"i":2,"tag":"baseline","model":"claude-sonnet-5","extra_args":"","prompt_chars":7451,"boot_ms":1070,"ttft_ms":10347,"complete_ms":11320}
{"i":3,"tag":"baseline","model":"claude-sonnet-5","extra_args":"","prompt_chars":7451,"boot_ms":911,"ttft_ms":13061,"complete_ms":15163}
{"i":4,"tag":"baseline","model":"claude-sonnet-5","extra_args":"","prompt_chars":7451,"boot_ms":1441,"ttft_ms":9981,"complete_ms":11066}
{"i":5,"tag":"baseline","model":"claude-sonnet-5","extra_args":"","prompt_chars":7451,"boot_ms":1036,"ttft_ms":17189,"complete_ms":17985}
{"i":1,"tag":"bare","model":"claude-sonnet-5","extra_args":"--bare","prompt_chars":7451,"boot_ms":429,"ttft_ms":-1,"complete_ms":-1}
{"i":2,"tag":"bare","model":"claude-sonnet-5","extra_args":"--bare","prompt_chars":7451,"boot_ms":437,"ttft_ms":-1,"complete_ms":-1}
{"i":3,"tag":"bare","model":"claude-sonnet-5","extra_args":"--bare","prompt_chars":7451,"boot_ms":444,"ttft_ms":-1,"complete_ms":-1}
{"i":4,"tag":"bare","model":"claude-sonnet-5","extra_args":"--bare","prompt_chars":7451,"boot_ms":446,"ttft_ms":-1,"complete_ms":-1}
{"i":5,"tag":"bare","model":"claude-sonnet-5","extra_args":"--bare","prompt_chars":7451,"boot_ms":441,"ttft_ms":-1,"complete_ms":-1}
@@ -0,0 +1,7 @@
{"hook_event_name": "MessageDisplay", "index": 0, "final": false, "delta": "## Mutex\n\n"}
{"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": 4, "final": false, "delta": "let counter = 0;\n\nasync function increment() {\n const release = await mutex.acquire();\n try {\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"}
{"hook_event_name": "MessageDisplay", "index": 6, "final": true, "delta": "```"}
@@ -0,0 +1,5 @@
{"i":1,"tag":"effort-low-exclude-dynamic","model":"claude-sonnet-5","extra_args":"--effort low --exclude-dynamic-system-prompt-sections","prompt_chars":7451,"boot_ms":934,"ttft_ms":5867,"complete_ms":9953}
{"i":2,"tag":"effort-low-exclude-dynamic","model":"claude-sonnet-5","extra_args":"--effort low --exclude-dynamic-system-prompt-sections","prompt_chars":7451,"boot_ms":1275,"ttft_ms":6388,"complete_ms":9874}
{"i":3,"tag":"effort-low-exclude-dynamic","model":"claude-sonnet-5","extra_args":"--effort low --exclude-dynamic-system-prompt-sections","prompt_chars":7451,"boot_ms":874,"ttft_ms":10537,"complete_ms":11782}
{"i":4,"tag":"effort-low-exclude-dynamic","model":"claude-sonnet-5","extra_args":"--effort low --exclude-dynamic-system-prompt-sections","prompt_chars":7451,"boot_ms":1170,"ttft_ms":6379,"complete_ms":9947}
{"i":5,"tag":"effort-low-exclude-dynamic","model":"claude-sonnet-5","extra_args":"--effort low --exclude-dynamic-system-prompt-sections","prompt_chars":7451,"boot_ms":1329,"ttft_ms":6443,"complete_ms":9884}
@@ -0,0 +1,256 @@
# Backlog #2 (real streaming): **achievable** — via the `MessageDisplay` hook
**Date**: 2026-07-13
**Status**: prereq-spike result. **Streaming IS achievable on the TUI path**, byte-faithfully, on the
subscription pool. Three obvious sources are dead ends; a fourth one works.
**Scope**: answers the prereq spike that [`README.md`](README.md) § "Backlog #2" demanded *before* any
streaming design:
> **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).
The answer: **(a) is dead, (b) is dead — and you are not stuck with either.** The CLI exposes its own
streaming interface as a **hook**, which the backlog did not consider.
**Measured on**: Mac mini / Claude Code **v2.1.207** / Sonnet 4.6 + Sonnet 5 / Claude Max /
real-home mode. Every claim below is reproducible from the commands given.
> **Honesty note on how this document was produced.** Its first version concluded the exact opposite —
> "streaming is not achievable; the CLI exposes no byte-faithful incremental source" — and was **wrong**.
> An adversarial reviewer, commissioned specifically to *refute* it, found `MessageDisplay` on a second
> pass; its own first pass had enumerated the hook registry with a truncated grep (it reported 21
> events — there are **30**). Both the wrong conclusion and its refutation are preserved here, because
> "we checked, it's impossible" is the most expensive kind of claim to get wrong: it closes a door and
> nobody re-opens it.
---
## ✅ The source that works: the `MessageDisplay` hook
`claude` fires a **`MessageDisplay`** hook as it renders each block of the assistant's reply. The
payload carries the **raw markdown source** of an incremental `delta`, plus a monotonic `index` and a
`final` flag:
```json
{ "hook_event_name": "MessageDisplay",
"turn_id": "6cb31d21-…", "message_id": "84ab9832-…",
"index": 0, "final": false, "delta": "## Mutex\n\n" }
```
*(payload also carries `session_id`, `transcript_path`, `prompt_id`, `cwd`)*
Registered as an ordinary command hook via `--settings` on a **plain interactive TUI spawn** (no `-p`,
no `--bare`), `claude-sonnet-4-6`, `--effort low`. Banner verified:
`▝▜█████▛▘ Sonnet 4.6 with low effort · Claude Max`**subscription pool, not metered billing**.
One live turn — 7 fires, spread across generation:
```
index=0 final=false len= 10 '## Mutex\n\n'
index=1 final=false len= 140 'A **mutual exclusion lock** prevents concurrent access to a shar…'
index=2 final=false len= 83 '- Acquiring a locked mutex blocks the caller until the current h…'
index=3 final=false len= 163 '- Failing to release a mutex causes a deadlock, freezing all wai…'
index=4 final=false len= 96 'let counter = 0;\n\nasync function increment() {\n const release =…'
index=5 final=false len= 84 ' counter++; // only one caller here at a time\n } finally {\n …'
index=6 final=true len= 3 '```'
```
**Every invariant a proxy needs — all hold:**
| requirement | result |
|---|---|
| **byte-faithful** — deltas are the model's *source*, not the rendered pane | ✅ `## `, `**`, ```` ```javascript ```` all present in the deltas |
| **exactness** — `concat(deltas) === T` (the transcript-authoritative text) | ✅ **true**, 579 == 579 bytes |
| **prefix-stable** — `T.startsWith(concat(deltas[0..n]))` at every n | ✅ **true at all 7 steps** |
| **incremental** — arrives during generation, not at the end | ✅ 7 fires spread across the turn |
| **no `-p`** — stays out of the metered `sdk-cli` pool | ✅ plain interactive TUI |
| **subscription pool** | ✅ banner `· Claude Max` |
This is exactly the contract a streaming design needs: deltas forward straight into SSE
`delta.content` chunks, and the transcript's final text `T` stays a cheap end-of-turn assertion
(`concat === T`) instead of a reconciliation problem.
### Caveats for the implementer
- **Block-level granularity, not token-level** — the hook fires **once per rendered block** (roughly one
per paragraph / list item / code block), so the chunk count **scales with answer length**: 7 fires for a
~600-byte answer, **18 for a ~2 KB one**. Plenty for SSE (`delta.content` has no minimum size), but do
not promise token-by-token output, and do not hard-code any assumption about chunk count.
- **🔴 The sink MUST be keyed by `session_id` — this is live TODAY, not a future concern.**
`OCP_TUI_MAX_CONCURRENT` defaults to **2**, so **two `claude` processes already run concurrently**. One
hook command writing to one shared sink would **interleave deltas from two different turns into one
stream** — request A's client receiving request B's text, the worst failure a proxy can have, and one a
single-request test will never surface. The payload carries `session_id` (and `turn_id` / `message_id`),
so demux is easy: derive the sink path from `session_id` (`<dir>/<session_id>.jsonl`) and read only your
own turn's file. This *also* keeps the design **warm-pool compatible**, because a pre-booted pane's
session-id is fixed at boot — one static hook script serves every pane. **Test it with ≥2 concurrent
streaming requests carrying distinguishable prompts and assert zero cross-contamination.**
- **⚠️ `forceSyncExecution: true` in the hook's source — `claude` BLOCKS on the hook.** A slow hook
adds latency to *every* delta. The hook must write and exit immediately (e.g. write to a FIFO / unix
socket that OCP reads; never work inline). **Measure the added per-delta latency.**
- **Thinking blocks appear to be excluded — but this is NOT yet stress-tested. Verify before shipping.**
The exclusion is inferred from `content.map(c => c.type === "text" ? c.text : "")` — but that snippet is
from the **`final:true`** call site, not the incremental one. Four live turns (incl. two at `--effort
high`) showed no thinking text in any delta and `concat === T` held — **but each transcript's thinking
block was empty (`thinking:""`, 0 chars)**, so the exclusion was never actually stressed. **The failure
mode is severe**: if thinking deltas *do* fire on some config (Opus, `xhigh`), `concat(deltas) !== T`
**and OCP streams the model's private reasoning to the caller**. The end-of-turn `concat === T` assertion
would *detect* that but **cannot prevent** it — SSE deltas cannot be un-sent. **Before shipping, run a
turn on a model+effort that produces substantive thinking** (a hard reasoning prompt on Opus / `xhigh`)
and confirm both (a) no thinking text in any delta and (b) `concat === T` still holds.
- OCP already owns the spawn (isolated HOME, its own flags), so injecting `--settings` with a
`MessageDisplay` hook sits inside the existing architecture.
- **`ALIGNMENT.md`**: this consumes `claude`'s **own** hook surface as emitted — forwarding, not
inventing. Not a new endpoint, not a fabricated protocol. (Class B / ADR 0007 — the TUI spawn is
OCP-owned; no `cli.js` citation applies.)
### Reproduce in 60 seconds
```bash
# hook script: append the payload (arrives on stdin) and exit immediately
printf '#!/bin/bash\ncat >> "$MD_LOG"; printf "\\n" >> "$MD_LOG"; exit 0\n' > /tmp/h.sh && chmod +x /tmp/h.sh
echo '{"hooks":{"MessageDisplay":[{"hooks":[{"type":"command","command":"MD_LOG=/tmp/deltas.jsonl /tmp/h.sh"}]}]}}' > /tmp/s.json
# plain interactive claude in tmux (prefix NOT ocp-tui-*, and never kill-server)
tmux new-session -d -s md-probe -x 220 -y 50 \
"claude --model claude-sonnet-4-6 --effort low --session-id $(uuidgen) --settings /tmp/s.json"
# …wait for '? for shortcuts', paste a markdown-producing prompt, press Enter…
jq -r '"\(.index) \(.final) \(.delta|@json)"' /tmp/deltas.jsonl # incremental raw-markdown deltas
# then assert: concat(deltas) == extractLatestAssistantText(<transcript>.jsonl)
```
---
## The three dead ends (still worth knowing — they say what NOT to build)
### (a) Incremental transcript reads — **dead: event granularity, not token granularity**
The transcript JSONL *does* grow during a turn, but one **whole event at a time**; the assistant's text
event is written as **one complete line**, appearing only ~0.3 s before the terminal `turn_duration`.
Observed (session `efd5b161`, `turn_duration: 7319 ms`):
```
#6 t+0.0s type=user (the prompt)
#15 t+4.7s type=assistant blocks=thinking
#16 t+7.0s type=assistant blocks=text ← the ENTIRE answer, in one line
#21 t+7.3s type=system subtype=turn_duration ← terminal
```
Cross-checked at **20 ms polling + `fs.watch`** (25× finer): a partial line **never touches disk** —
one write, `+1` line, carrying the complete answer. Also forced with the undocumented
`CLAUDE_CODE_INCLUDE_PARTIAL_MESSAGES=1`: still 1 assistant event, 0 partials (interactive mode has no
stream-json *sink* for it to write to).
**The transcript is still needed** — as the terminal-turn signal, as the authoritative `concat === T`
check, and as the input to the existing honesty gates (auth-banner detection, `truncated`). It is just
not the *streaming* source.
### (b) `tmux capture-pane` diffing — **dead: the pane is a RENDERED view, not the text**
The backlog expected to fall back to this, calling it "lossy … (wrapping, scrollback, spinner lines)".
The loss is far worse than formatting noise: **the pane does not contain the answer's source bytes at
all.** The TUI *renders* markdown, and `capture-pane -p` strips the ANSI that rendering produced.
Same turn, same lines:
```
TRANSCRIPT (authoritative T): PANE (capture-pane -p -J -S -500):
'## Semaphore' '⏺ Semaphore' ← heading marker gone
'' ''
'A **semaphore** is a synchro…' ' A semaphore is a synchro…' ← bold markers gone, indented
```
| token in the answer | in `T` | in the pane's answer region |
|---|---|---|
| `## ` (ATX heading) | yes | **no** — rendered as `` |
| `**` (bold markers) | yes | **no** — rendered to ANSI bold, then stripped by `-p` |
| ` ```javascript ` (fence + language) | yes | **no** — fence and language tag both gone |
| `- ` (list item) | yes | yes |
*(A literal `**` does appear elsewhere in the pane — in the **prompt echo**, because the prompt asked
for bold. Not in the answer.)*
**`capture-pane -e` (keeping the ANSI) does not rescue it — the inverse is provably non-unique.**
With `T` = ``"## Alpha\n\n**bravo**\n\n```javascript\nlet x=1;\n```"``:
```
⏺\e[39m \e[1mAlpha\n\n\e[0m \e[1mbravo\n\n\e[0m \e[34mlet\e[39m x=\e[32m1\e[39m;
```
`## Alpha` → **SGR 1 (bold)**. `**bravo**` → **SGR 1 (bold)**. *Identical ANSI* — an H2 and a bold span
are indistinguishable, never mind `**` vs `__`. The fence and its `javascript` tag are consumed by the
syntax highlighter into colours; recovering the tag would mean inverting a highlighter, and
`let x=1;` is valid in several languages.
So `T.startsWith(paneText)` is **false** — raw and indent-stripped, on essentially every markdown
answer. A proxy streaming pane text would be streaming **something the model did not say**. With
`MessageDisplay` available there is no reason to go near it.
### (c) `--debug-file` — **dead: it logs stream *timing*, never stream *content***
Worth stating precisely, because a casual check misleads in **both** directions here.
The default log level is `debug`, which **suppresses every `verbose` site**. Raise it and per-chunk
lines *do* appear, spread across generation:
```bash
CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose claude --debug-file /tmp/d.log …
```
```
05:51:11.088 [VERBOSE] [shoji-engine] yield stream_event/- ← 16 of these, mid-turn,
05:51:11.537 [VERBOSE] [shoji-engine] yield stream_event/- over ~3.9 s of generation
05:51:15.192 [DEBUG] [shoji-engine] turn 1 end (usage in=575 out=255 api=6736ms stop=end_turn resultLen=857)
```
**But they carry no payload** — the format is `yield <type>/<subtype>`, a bare presence marker. Run with
no category filter (i.e. all categories) at verbose level: `content_block_delta` = **0**, `text_delta` =
**0**, `content_block_start` / `message_start` = **0**. The only byte-exact text in the log is the
end-of-turn `Stop` hook payload (`"last_assistant_message":"## Title\n\n**alpha bravo charlie**"`) —
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. |
| `--input-format stream-json` | `Error: --input-format=stream-json requires output-format=stream-json` → same gate. |
| `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 3032 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
interactive-latency consumers regardless.
+268
View File
@@ -0,0 +1,268 @@
# OCP Anthropic-Only Sandbox Strategy — Handoff Document
**Status:** Forward-looking planning doc (not yet a decision)
**Date:** 2026-05-29
**Audience:** future OCP maintainer / session picking up multi-tenant security work
**Provenance:** authored during OLP Phase 7 PR-B re-evaluation; OLP's parallel analysis (multi-provider) lives at `dtzp555-max/olp` `docs/adr/0014-sandbox-runtime-integration.md` Amendment 1 (pending). This OCP-side doc strips the multi-LLM generalization and keeps only what applies to OCP's single-provider (anthropic) deployment.
---
## 1. Why this doc exists
OCP is in maintenance mode (per OLP ADR 0001 supersession of OCP ADR 0005). It is not under active development for new features. However, two things may eventually drive sandbox work in OCP:
1. **Multi-key OCP deployments.** `OCP_OWNER_TOKEN` + per-key cache namespace already shipped (OCP `lib/keys.mjs`). If multiple human users share an OCP instance, the same multi-tenant filesystem-isolation gap that motivated OLP Phase 7 also exists here.
2. **Cloud or shared-host OCP deployments.** Any deployment beyond "single user on their own machine" inherits the threat surface.
If/when that work starts, this doc is the prior-art capture so the maintainer doesn't repeat OLP's PR-B path (which has a documented dead-end — see § 3.2 below).
This doc is anthropic-only by design — codex/mistral/etc. multi-LLM concerns are out of scope per OCP ADR 0005.
---
## 2. The multi-tenant gap (OCP-specific)
OCP spawns `claude -p` as the OCP-process user. Every spawned claude instance runs with the OCP user's filesystem permissions. Consequences for a multi-key OCP deployment:
1. **Cross-key lateral read.** A prompt-injected `cat ~/.ocp/keys/<other-key>.json` reads any other key's manifest (token hash, owner_tier, providers_enabled — not catastrophic since it's only the *hash*, but still identity-attribution surface).
2. **OAuth credential exposure.** `~/.claude/.credentials.json` is the Anthropic OAuth refresh token. A prompt-injected read of this file = stealing the subscription that OCP exists to pool.
3. **SSH identity exposure.** `~/.ssh/id_*` reachable for lateral movement to other hosts the OCP user can reach.
4. **Other host secrets.** Anything else under the OCP user's home is reachable.
OCP's `ALIGNMENT.md` Class A/B endpoint discipline does not address this — that discipline is wire-level honesty (`cli.js` mirror), not host-level isolation.
The threat model assumes prompt-injection capability — any caller with a valid OCP key + ability to craft a prompt that elicits a tool call. Default `claude -p` mode includes Read/Bash/etc. tool descriptions in the system prompt; the model is **eager** to use them.
---
## 3. Why OLP Phase 7 PR-B is the wrong path to copy
OLP attempted to wrap `claude -p` spawn in `@anthropic-ai/sandbox-runtime` (outer bubblewrap on Linux, sandbox-exec on macOS). This produced four binding problems documented during OLP's re-evaluation:
### 3.1 Anthropic's design doesn't expect external sandboxing
Per Anthropic's [engineering blog on Claude Code sandboxing](https://www.anthropic.com/engineering/claude-code-sandboxing), `sandbox-runtime` is designed to be invoked **by claude code itself** to sandbox **its own** Bash tool / MCP servers / spawn children. It is **not** designed to sandbox claude code as an externally-wrapped process.
Concretely: claude CLI assumes it can freely read+write its own `$HOME`-derived paths (`~/.claude.json`, `~/.claude/.credentials.json`, `~/.config/claude/`, future state files). When wrapped in `bwrap --ro-bind / /`, those writes hit `EROFS` and claude silently exits with no stdout.
### 3.2 `~/.claude.json` upstream status is "closed not planned"
claude CLI writes `~/.claude.json` non-atomically at startup. Upstream issues #28842, #29162, #29217, #28837, #29051, #29250, #7243 all document this. **#29250 is closed as "not planned / duplicate"** — Anthropic is not going to make this file atomic-write because their mental model is that claude runs in an environment that can write its `$HOME`.
For OCP, this means: any outer-sandbox approach that uses `--ro-bind` on `$HOME` will be a **permanent maintenance treadmill** — every new claude CLI version that adds a state file outside the patched mount paths breaks OCP. OLP's PR-B fold-in tried to patch this by promoting `~/.claude/` to rw, which was insufficient (the actual file is `~/.claude.json` at $HOME root, not inside `~/.claude/`).
### 3.3 The threat model doesn't justify the cost
OCP is, per ADR 0005, a personal-and-family-scale tool. The realistic threat surface is misbehaving prompts from family members or self-injected via dependent agents, not adversarial external attackers. The blast radius of a successful cross-key read is bounded (token *hash*, OAuth that's pooled-by-design across all OCP keys).
A maintenance-mode project investing weeks into outer-sandboxing for a hypothetical threat is a poor cost/benefit. There are cheaper architectures (§ 4 below) that get most of the protection.
### 3.4 OLP-specific reason that does NOT apply to OCP
OLP also hit a multi-provider conflict: codex CLI has its own inner bubblewrap that breaks when wrapped in an outer bwrap (openai/codex#16018). **This is not an OCP concern** — OCP only spawns claude. So the multi-provider forcing function for OLP doesn't apply here. The other three reasons (§ 3.13.3) are sufficient on their own.
---
## 4. Three viable approaches for OCP
Ranked by "engineering cost vs isolation strength" — pick by deployment context.
### 4.1 Approach A — Ephemeral `$HOME` via env var (recommended starting point)
Per-spawn setup:
```
ephemeralRoot=/tmp/ocp-spawn/<keyId>/<reqId>/home
mkdir -p $ephemeralRoot/.claude
ln -s ~/.claude/.credentials.json $ephemeralRoot/.claude/.credentials.json
HOME=$ephemeralRoot claude -p --output-format stream-json ...
```
Mechanics:
- claude CLI uses Node's `os.homedir()` which reads `$HOME` env first.
- `~/.claude.json` written by claude on startup → lands in `/tmp/ocp-spawn/<keyId>/<reqId>/home/.claude.json` (tmpfs, discarded after spawn).
- `~/.claude/.credentials.json` is the OAuth file claude needs — symlinked in read-only from the real one.
- Any new state file claude CLI introduces in a future version → also lands in the ephemeral home, no patch needed.
Threat coverage:
- ✅ Solves EROFS upgrade tax permanently — any claude state-file location works because they all land in tmpfs.
- ✅ Cross-key OAuth credential isolation — keyA's ephemeral home has only keyA's symlink, but here the symlink target is the SAME real file because OCP shares OAuth (this is fine: shared OAuth is OCP's design, the symlink just keeps the file inaccessible via `cat ~/.claude/.credentials.json` from a different keyId's ephemeral root).
- ❌ Does NOT solve cross-key lateral filesystem read via absolute paths. A prompt-injected `cat /home/<ocp-user>/.ocp/keys/<otherKey>.json` still works — `os.homedir()` override doesn't affect absolute-path reads.
5-minute spike before adopting:
```bash
HOME=/tmp/fake-home-spike claude --print "echo PONG" --no-session-persistence 2>&1
ls -la /tmp/fake-home-spike # expect: .claude.json + .claude/ created here
find ~/.claude ~/.claude.json -newer /tmp/spike-marker 2>/dev/null # expect: empty
```
If claude falls back to `os.userInfo().homedir` (uses getpwuid_r, ignores HOME env), this approach degrades — fall back to Approach B.
**Engineering cost:** ~50 LOC in OCP's spawn pipeline (mkdir + symlink + env merge + cleanup-on-exit). No new dependencies.
### 4.2 Approach B — Outer bubblewrap with `--tmpfs $HOME` + `--ro-bind` credentials
```
bwrap \
--ro-bind / / \
--tmpfs /home/<ocp-user> \
--ro-bind /home/<ocp-user>/.claude/.credentials.json /home/<ocp-user>/.claude/.credentials.json \
--ro-bind /home/<ocp-user>/.ocp/keys/<thisKeyId>.json /home/<ocp-user>/.ocp/keys/<thisKeyId>.json \
--dev /dev --proc /proc --tmpfs /tmp \
claude -p ...
```
This is the canonical bwrap pattern (Flatpak uses exactly this for every sandboxed app — see [Bubblewrap ArchWiki Examples](https://wiki.archlinux.org/title/Bubblewrap/Examples)).
Threat coverage:
- ✅ Solves EROFS upgrade tax (tmpfs accepts any write path).
- ✅ Cross-key lateral read prevention — only the current key's manifest is bind-mounted in, others are simply absent from the sandbox view.
-`~/.ssh` and similar identity material absent from sandbox.
Trade-offs:
- bwrap dependency: install `bubblewrap` apt package on host.
- Bypasses `@anthropic-ai/sandbox-runtime` library — direct bwrap arg composition. Worth it because sandbox-runtime's outer-wrap design is for short-lived claude-internal subprocesses, not long-running claude CLI itself (per § 3.1).
- macOS: not supported by bwrap (macOS would need separate `sandbox-exec` profile, ~50-100 LOC additional work). OCP cross-machine maintainer deploys mostly on Mac mini + Oracle ARM VM — both Linux on the cloud side, Mac mini side may remain unsandboxed if family-trust-zone.
**Engineering cost:** ~150 LOC for the spawn wrapper + deployment doc updates to require `apt install bubblewrap`. macOS support is a separate ~100 LOC if/when needed.
### 4.3 Approach C — OverlayFS lowerdir (read-only) + tmpfs upperdir (writable)
```
mount -t overlay overlay \
-o lowerdir=/home/<ocp-user>/.claude,upperdir=/tmp/ocp-spawn/<reqId>/upper,workdir=/tmp/ocp-spawn/<reqId>/work \
/tmp/ocp-spawn/<reqId>/merged-claude
HOME=/tmp/ocp-spawn/<reqId>/home claude -p ...
# After spawn: umount + rm -rf
```
Most elegant — claude sees a view identical to its real `~/.claude/`, all writes go to tmpfs upperdir, real `~/.claude/` is never touched.
Trade-offs:
- Requires `CAP_SYS_ADMIN` or rootless-overlayfs (kernel ≥5.11 + user-ns enabled). OCP currently runs as the maintainer's user — no SYS_ADMIN — so this would require either running OCP as root (bad) or rootless-overlayfs setup.
- More moving parts (mount/umount per spawn, work-dir lifetime, cleanup-on-crash).
Better fit if OCP ever moves to a dedicated `ocp` system user with `CAP_SYS_ADMIN` capability via systemd.
**Engineering cost:** ~120 LOC + kernel/permission preflight check.
---
## 5. Cross-key isolation orthogonal layer
The three approaches above all solve `~/.claude.json` EROFS + state-write isolation. None of them alone solve **cross-key lateral filesystem read via absolute paths** (e.g. prompt-injected `cat /home/<user>/.ocp/keys/<otherKey>.json`).
For that, two options compose with any of A/B/C:
### 5.1 Per-spawn `sandbox-runtime` customConfig with `denyRead`
`@anthropic-ai/sandbox-runtime`'s `wrapWithSandbox(command, binShell?, customConfig?, abortSignal?)` accepts per-call override:
```
const otherKeysWorkspaces = listAllKeyManifestsExcept(thisKeyId)
const wrapped = await SandboxManager.wrapWithSandbox(claudeCommand, undefined, {
filesystem: {
denyRead: [
...otherKeysWorkspaces, // all keys except current
'/home/<ocp-user>/.ssh',
'/home/<ocp-user>/.gnupg',
'/home/<ocp-user>/.aws',
],
allowWrite: [ephemeralRoot, '/tmp'],
},
})
```
This adds bwrap deny-paths per-spawn (after sandbox-runtime singleton init). Works in combination with Approach A (the `HOME` env-var override is independent of sandbox-runtime's restrictions).
Caveat: this re-introduces the outer-bwrap concern from § 3.1 — claude CLI is now wrapped after all. Mitigation: use this only for **cross-key isolation**, not for `$HOME` restriction. The `denyRead` paths are all outside `$HOME`, so claude's `~/.claude.json` write is unaffected.
### 5.2 Per-OS-user OCP spawning
Each OCP key gets a dedicated Linux user (`ocp-<keyId>`). Spawn claude as that user via `runuser` or `sudo -u`. OAuth credential shared via Linux group permissions or bind-mount.
True kernel-level uid isolation. Most robust answer for OCP-as-shared-host scenarios.
Trade-offs:
- Setup script complexity (one-time per key).
- Linux-only.
- Doesn't fit Mac mini deployment.
Best fit for a cloud OCP deployment where per-tenant trust isolation matters.
---
## 6. Trust model framing
OCP's authentication layer (`lib/keys.mjs`) provides **attribution** (per-key audit, per-key cache namespace). It does NOT, by itself, provide **isolation** (per-key trust boundary against prompt-injection lateral reads).
This distinction is worth making explicit in OCP's README "Security" section (it currently isn't). The three tiers:
| Tier | Trust Model | Sandbox requirement |
|---|---|---|
| **Single-user** | maintainer's own machine, single OCP token | None — system-user permissions are sufficient |
| **Family-trust-zone** | maintainer + family members on shared OCP instance, all parties trusted not to attack each other | Optional — Approach A (ephemeral $HOME) gives cleanup hygiene without changing trust assumptions |
| **Shared-host / cloud / external callers** | OCP keys handed to potentially-adversarial callers (CI runners, third-party agents, public demo) | Required — Approach B or C + § 5 cross-key isolation |
The current OCP deployment fits tier 1 or 2. The work in this doc applies only when promoting to tier 3.
---
## 7. Recommendation if/when this work starts
**Phase 1 — Approach A (ephemeral `$HOME`) only.**
- ~50 LOC, no apt deps, works on Mac mini + Linux
- Solves the EROFS upgrade tax structurally
- Closes cross-key OAuth-credential-file lateral read
- Cost-effective hygiene improvement
**Phase 2 — Approach B (outer bwrap) gated by deployment config.**
- Add `~/.ocp/config.json` field `security.sandbox: 'off' | 'tmpfs-home'`
- Default off (preserves Mac mini family deployment)
- Operator opts in on Linux cloud deployments
- Apt prereq documented in deployment guide
**Phase 3 — § 5 cross-key isolation (only if tier 3 deployment is planned).**
- Layer per-spawn customConfig denyRead OR per-OS-user spawning
- Treat as separate ADR amendment with its own threat-model evidence
**Skip Approach C** unless a future requirement forces overlay (low likelihood for OCP scope).
---
## 8. Authority citations
This doc claims findings about claude CLI / `@anthropic-ai/sandbox-runtime` behavior. Sources for verification:
- [Anthropic engineering — Claude Code sandboxing](https://www.anthropic.com/engineering/claude-code-sandboxing) (sandbox-runtime design intent)
- [Anthropic sandbox-runtime GitHub](https://github.com/anthropic-experimental/sandbox-runtime) (wrapWithSandbox API + customConfig per-call signature)
- [claude-code#29250 — `.claude.json` non-atomic-write closed-not-planned](https://github.com/anthropics/claude-code/issues/29250)
- [claude-code#29162 — read-only `~/.claude.json` startup hang](https://github.com/anthropics/claude-code/issues/29162)
- [claude-code#29217 — concurrent-write corruption](https://github.com/anthropics/claude-code/issues/29217)
- [claude-code#28842 — Windows startup race](https://github.com/anthropics/claude-code/issues/28842)
- [claude-code#7243 — "the .claude.json elephant in the room"](https://github.com/anthropics/claude-code/issues/7243)
- [Bubblewrap README](https://github.com/containers/bubblewrap)
- [Bubblewrap ArchWiki — Examples section, --tmpfs HOME pattern](https://wiki.archlinux.org/title/Bubblewrap/Examples)
- [Sandboxing CLI tools with Bubblewrap — botmonster](https://botmonster.com/self-hosting/sandbox-linux-apps-cli-tools-bubblewrap/)
- [OverlayFS kernel documentation](https://docs.kernel.org/filesystems/overlayfs.html)
- [OverlayFS ArchWiki](https://wiki.archlinux.org/title/Overlay_filesystem)
OLP's parallel work (multi-provider generalization of this strategy, including the codex inner-bwrap conflict that does not apply to OCP):
- `dtzp555-max/olp` `docs/adr/0014-sandbox-runtime-integration.md` (PR-B as-shipped) + Amendment 1 (pending — Solution 1 architecture)
- `dtzp555-max/olp` `docs/plans/cloud-deployment-family.md` § 5 (deployment-side trust tier mapping)
- archive branch `dtzp555-max/olp:phase-7-pr-b-outer-bwrap-snapshot` captures the outer-bwrap approach as snapshot if anyone wants to revisit it
---
## 9. What this doc is NOT
- Not an ADR. ADRs are decisions; this is a forward-facing strategy doc that becomes an ADR only when work starts and a decision is made.
- Not a binding spec. The three approaches are alternatives; the recommendation in § 7 is the maintainer's lean from prior-art analysis, not a constitution.
- Not authority for any code change. OCP `ALIGNMENT.md` still requires citation per Class A/B; no sandbox code lands without proper authority pinning when the work eventually starts.
- Not a security audit. The threat model is informal — based on prior-art search + incident memory from OLP's parallel session. A real cloud deployment should commission an independent threat model.
---
**Authors:** project maintainer (handoff prepared with AI drafting assistance during OLP Phase 7 PR-B re-evaluation, 2026-05-29).
+151
View File
@@ -0,0 +1,151 @@
# 2026-06-15 Canary Runbook
**Purpose:** Confirm that a TUI-mode turn is billed to the **Pro/Max subscription pool** (not the Agent SDK credit pool) after Anthropic's 2026-06-15 billing split activates.
The billing classifier reading `cli` is **necessary but NOT sufficient** proof. (Note the naming: the value is stored in the JSONL transcript under the field name `entrypoint`, and sent to Anthropic on the wire as the `cc_entrypoint` header — they carry the same value after claude's startup classification. The commands below grep the transcript, so they match `entrypoint`.) A `cli` label tells you OCP sent the right classification; it does not tell you Anthropic billed the right pool. The only authoritative test is to observe whether the **Agent SDK credit balance** moves or not before and after the canary turn.
---
## Prerequisites
- `CLAUDE_TUI_MODE=true` already set and OCP restarted (see [TUI-mode setup](../tui-mode.md#enabling-tui-mode-opt-in))
- `tmux` installed on the host
- No other OCP traffic during the canary (quiesce — see below)
- Access to your Anthropic account billing page (manual step — see below)
---
## Step 1 — Quiesce the host
Stop any IDE or client that is actively sending requests through this OCP instance.
Confirm the proxy is idle:
```bash
curl -s http://127.0.0.1:3456/health | python3 -m json.tool | grep activeRequests
# Expected: "activeRequests": 0
```
Wait until `activeRequests` is `0` before proceeding. If you cannot quiesce (e.g. family members are actively using it), run the canary on a separate OCP instance or during a quiet window.
---
## Step 2 — Read the Agent SDK credit balance BEFORE the canary
> **Manual step — no programmatic API available.**
>
> OCP's `/usage` endpoint reads `anthropic-ratelimit-unified-*` response headers from the Pro/Max plan quota (5-hour and 7-day subscription windows). These headers report **subscription usage**, not the Agent SDK credit pool balance. There is no known programmatic API to query the Agent SDK credit pool balance from outside the Anthropic web app.
To read the balance:
1. Open [https://claude.ai/settings/billing](https://claude.ai/settings/billing) (or your Anthropic Console billing page) in a browser.
2. Find the **Agent SDK Credits** section (sometimes labeled "API Credits" or "Agent SDK usage").
3. Note the current balance (e.g. `$18.43 remaining of $20.00`).
Write the value down — you will compare it after the canary turn.
---
## Step 3 — Send the canary turn
With TUI-mode on and the host quiesced, send exactly one small request:
```bash
curl -s -X POST http://127.0.0.1:3456/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "claude-haiku-4-5-20251001",
"messages": [{"role": "user", "content": "Reply with the single word: pong"}],
"max_tokens": 10
}' | python3 -m json.tool
```
Use Haiku (the cheapest model) to minimize any hypothetical impact if the canary turns red.
Wait for the response to arrive completely (TUI-mode buffers the full response before returning — you will see a delay of several seconds, then the full reply).
---
## Step 4 — Confirm the transcript shows `entrypoint:"cli"`
After the canary turn completes, inspect the most recent JSONL transcript for the billing-classifier label:
```bash
# The canary was run quiesced (Step 1), so the most recent JSONL across ALL project
# dirs IS the canary turn. We glob every projects subdir instead of recomputing
# claude's cwd-encoding rule (it maps every "/" AND "." to "-", e.g. ~/.ocp-tui/work
# => projects/-home-<user>--ocp-tui-work/; see lib/tui/transcript.mjs encodeCwd) —
# a glob is robust even if that encoding changes in a future claude build.
LATEST=$(ls -t "$HOME"/.claude/projects/*/*.jsonl 2>/dev/null | head -1)
echo "Transcript: $LATEST"
grep -o '"entrypoint":"[^"]*"' "$LATEST" | tail -1
# Expected: "entrypoint":"cli"
```
If the output shows `"entrypoint":"cli"`, the billing-classifier label is correct. If it shows `"entrypoint":"sdk-cli"`, the spawn did not get a real PTY — stop immediately and do not re-enable TUI-mode without investigation. Check `tmux new-session` manually and review ADR 0007 § spawn/PTY gate. (If the grep returns nothing, the transcript may not yet be flushed — re-run after a second, or confirm the turn completed.)
**Reminder: an `entrypoint:cli` label (the `cc_entrypoint=cli` wire header) is necessary but not sufficient.** It tells you OCP sent the right label to Anthropic. You must still check the credit balance in Step 5.
---
## Step 5 — Re-read the Agent SDK credit balance AFTER the canary
Return to [https://claude.ai/settings/billing](https://claude.ai/settings/billing) and reload the page. Note the current balance again.
---
## Step 6 — Green/Red decision
### Green (balance unchanged)
The Agent SDK credit balance did not decrease. The turn billed against the Pro/Max subscription pool as expected. TUI-mode is working correctly.
**Actions:**
- Keep `CLAUDE_TUI_MODE=true` on this host.
- Monitor the balance periodically for the first week to catch any delayed attribution.
- Resume normal traffic.
### Red (Agent SDK credit balance decreased)
The Agent SDK credit balance decreased. The subscription pool is not being used for TUI-mode turns on this host, despite `cc_entrypoint=cli` being set. This may indicate a backend routing change on Anthropic's side, a TTY detection failure, or a policy change.
**Actions — immediate:**
1. Unset `CLAUDE_TUI_MODE` (or set to any value other than `"true"`) in the service unit:
- systemd: edit `/etc/ocp/ocp.env` (or the unit's `Environment=` line), then `sudo systemctl daemon-reload && sudo systemctl restart ocp.service`
- launchd: edit the plist `EnvironmentVariables` section, then `launchctl bootout gui/$(id -u)/dev.ocp.proxy && launchctl bootstrap gui/$(id -u) <plist-path>`
2. Restart OCP and confirm the `/health` response no longer shows TUI-mode active.
3. If you share this OCP with family or other Max users: freeze their access temporarily until you understand the billing impact.
4. Consider pivoting to OLP multi-provider (see [OLP](https://github.com/dtzp555-max/olp)) which can spread load across other providers to avoid the Agent SDK credit drain.
Per ALIGNMENT.md Rule 2 / ADR 0007 § Kill-switch: "Per the constitution, the response is to drop the Anthropic provider rather than escalate spoofing."
---
## Ongoing monitoring — self-classification mini-canary
To detect future drift (e.g. a claude CLI upgrade that changes TTY-detection behavior), you can run a periodic one-liner that sends a tiny TUI turn with `OCP_TUI_ENTRYPOINT=auto` (so claude self-classifies rather than having OCP pin the value) and alerts if the transcript self-classification is not `cli`:
```bash
# Run with OCP temporarily configured OCP_TUI_ENTRYPOINT=auto
# Then check the most recent transcript:
# Glob the most recent transcript across all project dirs (robust to claude's
# cwd-encoding rule; run this right after the auto-mode mini-canary turn).
LATEST=$(ls -t "$HOME"/.claude/projects/*/*.jsonl 2>/dev/null | head -1)
RESULT=$(grep -o '"entrypoint":"[^"]*"' "$LATEST" | tail -1)
echo "Self-classified entrypoint: $RESULT"
if echo "$RESULT" | grep -q '"entrypoint":"cli"'; then
echo "OK — subscription pool"
else
echo "ALERT — not cli; check TTY and billing"
fi
```
Run this after any major `claude` CLI upgrade. The `auto` mode lets the CLI's own `t$A` startup function determine the value from the actual TTY state (see ADR 0007 § Billing-classifier labeling).
---
## Related
- [Flip/rollback runbook](./tui-flip-rollback.md) — how to set and unset `CLAUDE_TUI_MODE` on systemd and launchd hosts
- [ADR 0007](../adr/0007-tui-interactive-mode.md) — TUI-mode architecture and governing rules
- [Subscription-pool (TUI) mode](../tui-mode.md#subscription-pool-tui-mode)
+180
View File
@@ -0,0 +1,180 @@
# TUI-Mode Flip and Rollback Runbook
**Purpose:** Step-by-step instructions for enabling (`CLAUDE_TUI_MODE=true`) or disabling TUI-mode on real OCP deployments managed by **systemd** (Linux) or **launchd** (macOS).
Run the [615-canary](./615-canary.md) runbook after any flip to confirm billing pool routing is correct.
---
## Critical pitfalls — read first
### systemd: `daemon-reload` is required after editing the unit
Editing the unit file (or EnvironmentFile) and then doing `systemctl restart ocp.service` **without** `daemon-reload` will restart the process with the **old** environment from the cached unit. Always run `daemon-reload` after editing any unit file.
### launchd: `launchctl kickstart -k` does NOT reload plist env
`launchctl kickstart -k gui/$(id -u)/dev.ocp.proxy` kills the running process and re-launches it, but it **re-uses the launchd-cached environment** — not the current plist file. If you edited the plist's `EnvironmentVariables` section, you must do a full `bootout` + `bootstrap` cycle for the change to take effect. `kickstart` is not sufficient.
---
## Flip — enable TUI-mode
### systemd (Linux, e.g. Raspberry Pi, VPS)
**Option A — EnvironmentFile (recommended for clean separation)**
If your unit uses `EnvironmentFile=/etc/ocp/ocp.env` (or similar):
```bash
# 1. Edit the environment file
sudo nano /etc/ocp/ocp.env
# Add or update:
# CLAUDE_TUI_MODE=true
#
# If OCP binds to 0.0.0.0 AND you trust the network:
# OCP_TUI_ALLOW_LAN=1
# (WARNING: TUI-mode is single-user only — only enable OCP_TUI_ALLOW_LAN=1
# if you fully trust every caller that can reach the OCP port on your network)
# 2. Reload the unit definition and restart
sudo systemctl daemon-reload
sudo systemctl restart ocp.service
# 3. Verify
curl -s http://127.0.0.1:3456/health | python3 -m json.tool | grep -E "tui|version"
# Expected: "tuiMode": true (or similar TUI indicator in the health response)
```
**Option B — inline Environment= in the unit file**
```bash
# 1. Edit the unit file
sudo systemctl edit --full ocp.service
# Add or update in the [Service] section:
# Environment=CLAUDE_TUI_MODE=true
# 2. Reload and restart
sudo systemctl daemon-reload
sudo systemctl restart ocp.service
# 3. Verify
systemctl show ocp.service --property=Environment
# Expected: Environment=CLAUDE_TUI_MODE=true ...
```
### launchd (macOS)
Locate the OCP plist. The standard label is `dev.ocp.proxy`:
```bash
# Find the plist path
ls ~/Library/LaunchAgents/dev.ocp.proxy.plist
```
**Edit the plist:**
```bash
# 1. Stop the service first (bootout)
launchctl bootout gui/$(id -u)/dev.ocp.proxy
# 2. Edit the plist — add CLAUDE_TUI_MODE to EnvironmentVariables
# Use your editor of choice:
nano ~/Library/LaunchAgents/dev.ocp.proxy.plist
```
Inside the plist, in the `<key>EnvironmentVariables</key>` `<dict>` block, add:
```xml
<key>CLAUDE_TUI_MODE</key>
<string>true</string>
```
If `OCP_TUI_ALLOW_LAN=1` is also needed (only if OCP binds to `0.0.0.0` and you trust the network):
```xml
<key>OCP_TUI_ALLOW_LAN</key>
<string>1</string>
```
```bash
# 3. Bootstrap (reload from disk + start)
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/dev.ocp.proxy.plist
# 4. Verify
curl -s http://127.0.0.1:3456/health | python3 -m json.tool | grep -E "tui|version"
```
**Confirm env was actually loaded** (not just set in your shell):
```bash
ps aux | grep server.mjs | grep -v grep
# Get the PID, then:
# macOS: ps -E -p <PID> | tr ' ' '\n' | grep CLAUDE_TUI_MODE
# Expected: CLAUDE_TUI_MODE=true
```
---
## Rollback — disable TUI-mode
Rollback is the same procedure as flip, but you **remove** `CLAUDE_TUI_MODE` or set it to any value other than `"true"` (e.g. `false`, or simply omit it).
After rollback, OCP returns to the default `callClaude` / `callClaudeStreaming` stream-json path — byte-for-byte identical to the pre-TUI code path. No other change is required.
### systemd rollback
```bash
# Option A — EnvironmentFile
sudo nano /etc/ocp/ocp.env
# Remove or comment out:
# CLAUDE_TUI_MODE=true
# OCP_TUI_ALLOW_LAN=1 (if set)
sudo systemctl daemon-reload
sudo systemctl restart ocp.service
# Verify
curl -s http://127.0.0.1:3456/health | python3 -m json.tool | grep tui
# Expected: "tuiMode": false (or the field absent)
```
### launchd rollback
```bash
# 1. Stop
launchctl bootout gui/$(id -u)/dev.ocp.proxy
# 2. Edit plist — remove the CLAUDE_TUI_MODE and OCP_TUI_ALLOW_LAN entries from EnvironmentVariables
# 3. Bootstrap
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/dev.ocp.proxy.plist
# 4. Verify
curl -s http://127.0.0.1:3456/health | python3 -m json.tool | grep tui
```
---
## Billing impact of staying on the default (non-TUI) path after 2026-06-15
If you do NOT flip to TUI-mode and keep `CLAUDE_TUI_MODE` unset (the default), OCP continues using `claude -p --output-format stream-json`, which sets `cc_entrypoint=sdk-cli`. After 2026-06-15, every OCP request on the default path will draw from the Agent SDK credit pool (approximately $20/month on a Pro plan, or $100/month on a Max plan) rather than the Pro/Max subscription. The subscription pool usage (5-hour and 7-day windows) will be unaffected, but the Agent SDK credit balance will drain with each request.
If you want to continue using OCP without TUI-mode after 2026-06-15, budget for the Agent SDK credit cost accordingly — or switch to [OLP](https://github.com/dtzp555-max/olp) for multi-provider fallback.
---
## Verify after any flip
1. Check `/health` shows the expected `tuiMode` state.
2. Run the [615-canary](./615-canary.md) to confirm billing pool routing.
3. If TUI-mode is ON: check `ocp logs 10` for any TUI spawn errors (`tui_spawn_failed`, tmux errors).
---
## Related
- [615-canary runbook](./615-canary.md) — how to verify billing pool routing after a flip
- [ADR 0007](../adr/0007-tui-interactive-mode.md) — TUI-mode architecture; Kill-switch section
- [Subscription-pool (TUI) mode](../tui-mode.md#subscription-pool-tui-mode)
- README § [Environment Variables](../../README.md#environment-variables) — `CLAUDE_TUI_MODE`, `OCP_TUI_ALLOW_LAN=1`
@@ -0,0 +1,737 @@
# TUI-mode (OCP-first) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add an opt-in `CLAUDE_TUI_MODE` to OCP that serves `/v1/chat/completions` by driving a *real interactive* `claude` session (no `-p`, no `--output-format`) so the request bills as `cc_entrypoint=cli` (subscription pool), reading the answer from claude's native JSONL transcript — while the default stream-json path stays byte-for-byte unchanged.
**Architecture:** Two new pure-ish modules under `lib/tui/` — a transcript **reader** (`transcript.mjs`, provider-agnostic, the shareable core) and a tmux **session driver** (`session.mjs`, OCP-specific). `server.mjs` gains a `callClaudeTui()` that returns `Promise<string>` and is gated into the existing dispatch by a single env flag; because OCP's entire downstream (singleflight → `setCachedResponse``completionResponse` / chunked-SSE-replay → `recordUsage`) already consumes a string from `callClaude`, TUI-mode is a drop-in. Streaming is buffered then replayed as chunked SSE (no token streaming — deliberately, "don't build fragile features").
**Tech Stack:** Node.js ESM (`.mjs`), `tmux` (interactive PTY host), `child_process` (`spawnSync`), `node:fs` polling (no `fs.watch`, no terminal-screen parsing). Test harness: `node test-features.mjs`.
**Source of truth for the TUI mechanism:** the OLP design spec `docs/superpowers/specs/2026-05-30-tui-mode-production-design.md` (CLI-level, applies to both projects) + its 6 validation spikes (S1S6, T1T6) run on PI231 against `claude v2.1.158`. This plan is the OCP-grounded execution of that spec.
---
## Why OCP-first / scope decisions (read before coding)
- **OCP-first** because OCP has the users and its compute path is `callClaude → Promise<string>`, a near-perfect impedance match for a reader that also returns a string. OLP would additionally need a string→IR-chunk-array adapter. OLP-sync is **deferred entirely until the post-2026-06-15 fork decision** — do not spend cycles keeping OLP's TUI in lockstep.
- **A-path only.** Single-user / multi-device on one subscription. No per-key ephemeral isolation, no multi-tenant. (That is the OLP B-path, deferred.)
- **A-path isolation = real `$HOME` + dedicated scratch cwd + `--strict-mcp-config`.** OCP has *no* ISOLATION contract and we do not build one. We run interactive `claude` in the operator's real home (OAuth + onboarding already valid) but in a **dedicated scratch working directory** (`OCP_TUI_CWD`, default `$HOME/.ocp-tui/work`) so transcripts land under one stable `projects/<cwd>` folder instead of polluting the operator's genuine project histories, and the trust-folder dialog is granted once.
- **One `claude` session per request.** OCP is stateless (full conversation re-serialized each request via `messagesToPrompt`). TUI-mode mirrors this: per request, start a fresh interactive session with a fresh `--session-id`, submit one serialized prompt, await turn completion, read the transcript, extract the latest assistant text, tear the session down. Warm-pool / large-paste optimizations are explicitly out of v1 scope.
- **Billing is unmeasurable until 2026-06-15.** Spike S1 proved the `cc_entrypoint=cli` *signal*, not the billed pool. The pre-6/15 deliverable is "a tested, working transport that emits `cli`"; 6/16 we flip the flag and measure with a documented kill-switch.
- **Coexistence rule (PI231 runs an OLP test instance too).** All tmux sessions use the prefix `ocp-tui-`; the reaper kills **only** `ocp-tui-*`, never `olp-tui-*`. Never run two TUI proxies on the same OAuth concurrently — stop the OLP test instance during OCP integration.
- **Provenance.** TUI-mode originated in OCP PR #101 (author courtesy: jaekwon-park <insainty21@gmail.com>). The PR #101 author should be credited + notified on the shipping PR.
---
## File Structure
| File | Responsibility | New/Modified |
|------|----------------|--------------|
| `lib/tui/transcript.mjs` | Pure transcript parsing + the polling reader. Returns the latest assistant text once the turn is terminal or the wall-clock cap elapses. Provider-agnostic — the shareable core. | **Create** |
| `lib/tui/session.mjs` | tmux session lifecycle: boot interactive `claude`, answer the trust dialog, submit the prompt (file → `"$(cat)"` paste → separate Enter), await the reader, tear down. Plus the prefix-scoped reaper. OCP-specific. | **Create** |
| `lib/tui/fixtures/` | Real transcript JSONL harvested from PI231 + a few hand-crafted edge cases, for the reader's unit tests. | **Create** |
| `server.mjs` | `callClaudeTui()` (`Promise<string>`); `streamStringAsSSE()` helper (DRY refactor of the cache-replay block); single-flag dispatch gates; reaper hook at boot; env consts. | **Modify** (`:258` env consts, `:1018``:1023` helpers, `:1467` dispatch, boot block) |
| `test-features.mjs` | Suite for the reader (fixtures, runs in CI) + a live-only guarded suite for the driver (`OCP_TUI_LIVE=1`, skipped in CI). | **Modify** |
| `docs/adr/0007-tui-interactive-mode.md` | OCP ADR 0007 (OCP's next number) — TUI mode rationale, billing-signal authority, scope, kill-switch. | **Create** |
| `README.md` | New env vars (`CLAUDE_TUI_MODE`, `CLAUDE_TUI_WALLCLOCK_MS`, `OCP_TUI_CWD`), a "Subscription-pool (TUI) mode" section, troubleshooting + kill-switch. | **Modify** |
| `CHANGELOG.md` | Unreleased entry. | **Modify** |
---
## PR-1 — Transcript reader (`lib/tui/transcript.mjs`)
The shareable core. Pure functions + a polling reader. Fully unit-testable from committed fixtures; needs PI231 only once, to harvest realistic fixtures.
### Task 0: Harvest real fixtures from PI231
**Files:**
- Create: `lib/tui/fixtures/complete-haiku.jsonl` (real, has `turn_duration`)
- Create: `lib/tui/fixtures/complete-sonnet-multiblock.jsonl` (real, multi content-block answer)
- [ ] **Step 1: Drive one real interactive turn on PI231 and copy its transcript**
On PI231 (the only box with an authenticated interactive `claude`), run a single interactive turn in a scratch cwd, then locate its transcript:
Run (on PI231):
```bash
SID=$(uuidgen)
mkdir -p ~/.ocp-tui/work
# drive one turn by hand in tmux OR reuse a transcript already produced by the S-spikes:
ls -t ~/.claude/projects/-home-*-.ocp-tui-work/*.jsonl 2>/dev/null | head
# pick one complete transcript (must contain a line with "subtype":"turn_duration")
```
Expected: at least one `.jsonl` file whose tail contains `{"type":"system","subtype":"turn_duration",...}`.
- [ ] **Step 2: Copy 2 real transcripts into the repo as fixtures, scrubbed**
Run (from the workstation):
```bash
scp pi231:'~/.claude/projects/<encoded-cwd>/<sid>.jsonl' lib/tui/fixtures/complete-haiku.jsonl
# Scrub: the transcript may contain the prompt/answer text only (no OAuth token — tokens
# live in ~/.claude/.credentials.json, NOT in projects/*.jsonl). Confirm no credential
# material before committing:
grep -iE "sk-ant|oat01|bearer|authorization" lib/tui/fixtures/*.jsonl && echo "STOP: scrub" || echo "clean"
```
Expected: `clean`. (Transcripts hold conversation content + metadata, never the bearer token. If a fixture's prompt text is sensitive, replace it with a benign hand-edited turn that keeps the JSON shape.)
- [ ] **Step 3: Commit the fixtures**
```bash
git add lib/tui/fixtures/complete-haiku.jsonl lib/tui/fixtures/complete-sonnet-multiblock.jsonl
git commit -m "test(tui): real claude transcript fixtures harvested from PI231 (v2.1.158)"
```
### Task 1: `encodeCwd` + `transcriptPath` (the path formula)
**Files:**
- Create: `lib/tui/transcript.mjs`
- Test: `test-features.mjs` (new Suite "TUI transcript")
- [ ] **Step 1: Write the failing test**
Add to `test-features.mjs`:
```js
// ── Suite: TUI transcript reader ────────────────────────────────────────
import { encodeCwd, transcriptPath } from "./lib/tui/transcript.mjs";
test("encodeCwd replaces every slash incl. leading", () => {
assertEqual(encodeCwd("/home/u/.ocp-tui/work"), "-home-u-.ocp-tui-work");
});
test("transcriptPath composes EHOME/.claude/projects/<enc>/<sid>.jsonl", () => {
assertEqual(
transcriptPath("/home/u", "/home/u/.ocp-tui/work", "abc-123"),
"/home/u/.claude/projects/-home-u-.ocp-tui-work/abc-123.jsonl"
);
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `node test-features.mjs 2>&1 | grep -i "tui transcript\|Cannot find module"`
Expected: FAIL — `Cannot find module './lib/tui/transcript.mjs'`.
- [ ] **Step 3: Minimal implementation**
Create `lib/tui/transcript.mjs`:
```js
// Transcript reader for TUI-mode. Reads claude's native JSONL session transcript
// and returns the latest assistant turn's text once the turn is terminal.
//
// Authority: claude CLI v2.1.158 — interactive session transcript at
// <HOME>/.claude/projects/<CWD with every "/" -> "-">/<--session-id>.jsonl
// Completion marker: a line {"type":"system","subtype":"turn_duration",...}.
// See docs/superpowers/specs/2026-05-30-tui-mode-production-design.md §4.
import { readFileSync, existsSync } from "node:fs";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Project-dir encoding: every "/" -> "-" (including the leading slash).
export function encodeCwd(cwd) {
return cwd.replace(/\//g, "-");
}
export function transcriptPath(home, cwd, sessionId) {
return `${home}/.claude/projects/${encodeCwd(cwd)}/${sessionId}.jsonl`;
}
```
- [ ] **Step 4: Run to verify it passes**
Run: `node test-features.mjs 2>&1 | grep -i "tui transcript"`
Expected: PASS for both cases.
- [ ] **Step 5: Commit**
```bash
git add lib/tui/transcript.mjs test-features.mjs
git commit -m "feat(tui): transcript path formula (encodeCwd + transcriptPath)"
```
### Task 2: `parseTranscriptLines` + `isTerminalLine` + `extractLatestAssistantText`
**Files:**
- Modify: `lib/tui/transcript.mjs`
- Test: `test-features.mjs`
- [ ] **Step 1: Write the failing tests**
```js
import { parseTranscriptLines, isTerminalLine, extractLatestAssistantText } from "./lib/tui/transcript.mjs";
import { readFileSync } from "node:fs";
test("parseTranscriptLines skips blank + malformed/partial lines", () => {
const evs = parseTranscriptLines('{"a":1}\n\n{bad json\n{"b":2}\n');
assertEqual(evs.length, 2);
assertEqual(evs[1].b, 2);
});
test("isTerminalLine true on turn_duration", () => {
assertEqual(isTerminalLine({ type: "system", subtype: "turn_duration" }), true);
});
test("isTerminalLine true on stop_reason tool_use (message-wrapped + flat)", () => {
assertEqual(isTerminalLine({ type: "assistant", message: { stop_reason: "tool_use" } }), true);
assertEqual(isTerminalLine({ stop_reason: "tool_use" }), true);
});
test("isTerminalLine false on ordinary assistant/text lines", () => {
assertEqual(isTerminalLine({ type: "assistant", message: { content: [{ type: "text", text: "hi" }] } }), false);
});
test("extractLatestAssistantText concatenates text blocks of the LAST assistant turn", () => {
const evs = [
{ type: "assistant", message: { content: [{ type: "text", text: "first" }] } },
{ type: "user", message: { content: "..." } },
{ type: "assistant", message: { content: [{ type: "text", text: "A" }, { type: "thinking", thinking: "x" }, { type: "text", text: "B" }] } },
];
assertEqual(extractLatestAssistantText(evs), "AB");
});
test("real complete fixture yields non-empty text and is terminal", () => {
const evs = parseTranscriptLines(readFileSync("./lib/tui/fixtures/complete-haiku.jsonl", "utf8"));
assert(evs.some(isTerminalLine), "fixture must contain a terminal line");
assert(extractLatestAssistantText(evs).length > 0, "fixture must yield assistant text");
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `node test-features.mjs 2>&1 | grep -i "parseTranscript\|isTerminal\|extractLatest\|real complete fixture"`
Expected: FAIL — exports not defined.
- [ ] **Step 3: Minimal implementation** (append to `lib/tui/transcript.mjs`)
```js
// Parse NDJSON text into objects; skip blank lines and partial/forming lines
// (the live transcript is read mid-write, so the last line may be incomplete).
export function parseTranscriptLines(text) {
const out = [];
for (const line of text.split("\n")) {
const t = line.trim();
if (!t) continue;
try { out.push(JSON.parse(t)); } catch { /* partial line being written */ }
}
return out;
}
// A line marks the assistant turn complete when it is the turn_duration system
// event, or an assistant message that stopped to hand off to a tool.
export function isTerminalLine(obj) {
if (!obj || typeof obj !== "object") return false;
if (obj.type === "system" && obj.subtype === "turn_duration") return true;
const sr = (obj.message && obj.message.stop_reason) || obj.stop_reason;
return sr === "tool_use";
}
// Text of the LAST assistant turn: concatenate its text content blocks
// (ignore thinking/tool_use blocks). Later assistant entries overwrite earlier.
export function extractLatestAssistantText(events) {
let text = "";
for (const ev of events) {
if (!ev || ev.type !== "assistant") continue;
const content = ev.message && ev.message.content;
if (!Array.isArray(content)) continue;
const parts = content
.filter((b) => b && b.type === "text" && typeof b.text === "string")
.map((b) => b.text);
if (parts.length) text = parts.join("");
}
return text;
}
```
- [ ] **Step 4: Run to verify it passes**
Run: `node test-features.mjs 2>&1 | grep -iE "parseTranscript|isTerminal|extractLatest|real complete fixture"`
Expected: all PASS.
- [ ] **Step 5: Commit**
```bash
git add lib/tui/transcript.mjs test-features.mjs
git commit -m "feat(tui): transcript parsing + terminal detection + assistant-text extraction"
```
### Task 3: `readTuiTranscript` (the polling reader with wall-clock cap)
**Files:**
- Modify: `lib/tui/transcript.mjs`
- Test: `test-features.mjs`
- [ ] **Step 1: Write the failing tests**
```js
import { readTuiTranscript } from "./lib/tui/transcript.mjs";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
test("readTuiTranscript returns assistant text when terminal marker present", async () => {
const dir = mkdtempSync(`${tmpdir()}/tui-`);
const p = `${dir}/s.jsonl`;
writeFileSync(p, [
JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "hello world" }] } }),
JSON.stringify({ type: "system", subtype: "turn_duration", durationMs: 1200 }),
].join("\n") + "\n");
const out = await readTuiTranscript({ transcriptPath: p, wallclockMs: 2000, pollMs: 50 });
assertEqual(out, "hello world");
});
test("readTuiTranscript honours wall-clock cap and returns partial text", async () => {
const dir = mkdtempSync(`${tmpdir()}/tui-`);
const p = `${dir}/s.jsonl`;
writeFileSync(p, JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "partial" }] } }) + "\n");
const out = await readTuiTranscript({ transcriptPath: p, wallclockMs: 300, pollMs: 50 }); // never terminal
assertEqual(out, "partial");
});
test("readTuiTranscript throws when no text and cap elapses", async () => {
const dir = mkdtempSync(`${tmpdir()}/tui-`);
const p = `${dir}/missing.jsonl`; // file never appears
let threw = false;
try { await readTuiTranscript({ transcriptPath: p, wallclockMs: 200, pollMs: 50 }); }
catch { threw = true; }
assert(threw, "must throw on empty timeout");
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `node test-features.mjs 2>&1 | grep -i "readTuiTranscript"`
Expected: FAIL — export not defined.
- [ ] **Step 3: Minimal implementation** (append)
```js
// Block until the session transcript is terminal (turn_duration / tool_use) or
// the wall-clock cap elapses, polling the file (no fs.watch — robust over NFS /
// editors). Returns the latest assistant text. On cap with text, returns the
// partial text; on cap with no text at all, throws.
//
// No quiescence heuristic by design: a long Opus thinking turn stalls transcript
// growth and a "file stable for N s" rule would false-abort it (spec §4.3).
export async function readTuiTranscript({ transcriptPath: p, wallclockMs = 120000, pollMs = 250 }) {
const deadline = Date.now() + wallclockMs;
let lastText = "";
while (Date.now() < deadline) {
if (existsSync(p)) {
const events = parseTranscriptLines(readFileSync(p, "utf8"));
lastText = extractLatestAssistantText(events) || lastText;
if (events.some(isTerminalLine)) return lastText;
}
await sleep(pollMs);
}
if (lastText) return lastText;
throw new Error("tui_transcript_timeout: no assistant text within wallclock cap");
}
```
- [ ] **Step 4: Run to verify it passes**
Run: `node test-features.mjs 2>&1 | grep -i "readTuiTranscript"`
Expected: all 3 PASS.
- [ ] **Step 5: Commit**
```bash
git add lib/tui/transcript.mjs test-features.mjs
git commit -m "feat(tui): polling transcript reader with wall-clock cap (no quiescence)"
```
---
## PR-2 — Session driver (`lib/tui/session.mjs`)
tmux lifecycle + the validated submission recipe. Cannot be unit-tested without a live authenticated `claude`; tested by a live-only guarded suite that runs on PI231.
### Task 4: `reapStaleTuiSessions` (prefix-scoped reaper)
**Files:**
- Create: `lib/tui/session.mjs`
- Test: `test-features.mjs`
- [ ] **Step 1: Write the failing test** (pure — no live claude; inject a fake tmux runner)
```js
import { reapStaleTuiSessions, SESSION_PREFIX } from "./lib/tui/session.mjs";
test("reaper kills ONLY ocp-tui- sessions, never olp-tui-", () => {
const killed = [];
const fakeTmux = (args) => {
if (args[0] === "list-sessions") return { status: 0, stdout: "ocp-tui-aaaa\nolp-tui-bbbb\nmisc\nocp-tui-cccc\n" };
if (args[0] === "kill-session") { killed.push(args[args.indexOf("-t") + 1]); return { status: 0 }; }
return { status: 0, stdout: "" };
};
const n = reapStaleTuiSessions({ tmux: fakeTmux });
assertEqual(SESSION_PREFIX, "ocp-tui-");
assertEqual(n, 2);
assertEqual(killed.join(","), "ocp-tui-aaaa,ocp-tui-cccc");
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `node test-features.mjs 2>&1 | grep -i "reaper kills"`
Expected: FAIL — module/export missing.
- [ ] **Step 3: Minimal implementation**
Create `lib/tui/session.mjs`:
```js
// TUI-mode session driver: hosts an interactive `claude` in tmux, submits one
// serialized prompt, awaits the transcript reader, tears down. OCP-specific.
//
// Authority: claude CLI v2.1.158 interactive mode (no -p / no --output-format
// => cc_entrypoint=cli). Submission recipe + dialog handling validated by spikes
// T3/T6 on PI231. See docs/superpowers/specs/2026-05-30-tui-mode-production-design.md.
import { spawnSync } from "node:child_process";
import { mkdtempSync, writeFileSync, mkdirSync, existsSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { transcriptPath, readTuiTranscript } from "./transcript.mjs";
export const SESSION_PREFIX = "ocp-tui-"; // per-proxy namespace (coexistence rule)
const TMUX = process.env.OCP_TUI_TMUX_BIN || "tmux";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const defaultTmux = (args, opts = {}) => spawnSync(TMUX, args, { encoding: "utf8", ...opts });
// Kill ONLY our own stale sessions. Scoped to SESSION_PREFIX so a co-hosted
// OLP test instance's `olp-tui-*` sessions are never touched.
export function reapStaleTuiSessions({ tmux = defaultTmux } = {}) {
const r = tmux(["list-sessions", "-F", "#{session_name}"]);
if (!r || r.status !== 0) return 0; // no tmux server / no sessions
let killed = 0;
for (const name of String(r.stdout || "").split("\n").map((s) => s.trim()).filter(Boolean)) {
if (name.startsWith(SESSION_PREFIX)) { tmux(["kill-session", "-t", name]); killed++; }
}
return killed;
}
```
- [ ] **Step 4: Run to verify it passes**
Run: `node test-features.mjs 2>&1 | grep -i "reaper kills"`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add lib/tui/session.mjs test-features.mjs
git commit -m "feat(tui): prefix-scoped session reaper (ocp-tui-* only)"
```
### Task 5: `runTuiTurn` (boot → trust dialog → paste → Enter → read → teardown)
**Files:**
- Modify: `lib/tui/session.mjs`
- Test: `test-features.mjs` (live-only, guarded by `OCP_TUI_LIVE=1`)
- [ ] **Step 1: Write the live-only guarded test** (skipped in CI; run on PI231)
```js
// Live-only: requires an authenticated interactive `claude`. Skipped unless OCP_TUI_LIVE=1.
if (process.env.OCP_TUI_LIVE === "1") {
test("runTuiTurn drives a real interactive turn and returns text", async () => {
const { runTuiTurn } = await import("./lib/tui/session.mjs");
const out = await runTuiTurn({
prompt: "Reply with exactly the word PONG and nothing else.",
model: "claude-haiku-4-5-20251001",
claudeBin: process.env.OCP_TUI_CLAUDE_BIN || "claude",
home: process.env.HOME,
cwd: `${process.env.HOME}/.ocp-tui/work`,
wallclockMs: 120000,
});
assert(/PONG/i.test(out), `expected PONG, got: ${out.slice(0, 200)}`);
});
} else {
test("runTuiTurn (live) — SKIPPED (set OCP_TUI_LIVE=1 on PI231 to run)", () => { assert(true); });
}
```
- [ ] **Step 2: Run to verify it fails** (on a box, with the flag)
Run (PI231): `OCP_TUI_LIVE=1 node test-features.mjs 2>&1 | grep -i "runTuiTurn"`
Expected: FAIL — `runTuiTurn` not exported yet.
- [ ] **Step 3: Implementation** (append to `lib/tui/session.mjs`)
```js
// Boot wait + dialog timing. Conservative defaults validated on PI231; env-tunable.
const BOOT_MS = parseInt(process.env.OCP_TUI_BOOT_MS || "3500", 10);
const DIALOG_MS = parseInt(process.env.OCP_TUI_DIALOG_MS || "1200", 10);
const PASTE_SETTLE_MS = parseInt(process.env.OCP_TUI_PASTE_MS || "1800", 10);
const shq = (s) => `'${String(s).replace(/'/g, "'\\''")}'`; // single-quote for sh -c
// Build interactive claude argv: NO -p, NO --output-format (=> cc_entrypoint=cli).
// MCP hard-disabled: --strict-mcp-config (no --mcp-config) is the only mechanism
// that stops account-attached managed MCP from connecting (spec §5.2 / T6),
// belt-and-braces with --disallowedTools "mcp__*".
function buildTuiCmd(claudeBin, model, sessionId) {
return [
shq(claudeBin),
"--model", shq(model),
"--session-id", sessionId,
"--strict-mcp-config",
"--disallowedTools", shq("mcp__*"),
].join(" ");
}
export async function runTuiTurn({
prompt, model, claudeBin, home, cwd,
wallclockMs = 120000, tmux = defaultTmux,
}) {
const sessionId = randomUUID();
const tmuxName = SESSION_PREFIX + sessionId.slice(0, 8);
if (!existsSync(cwd)) mkdirSync(cwd, { recursive: true });
const tmpDir = mkdtempSync(`${tmpdir()}/ocp-tui-`);
const promptFile = `${tmpDir}/prompt.txt`;
writeFileSync(promptFile, prompt, { mode: 0o600 });
const env = { ...process.env, CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL: "1" };
delete env.CLAUDECODE; delete env.ANTHROPIC_API_KEY; delete env.ANTHROPIC_BASE_URL; delete env.ANTHROPIC_AUTH_TOKEN;
if (home) env.HOME = home;
try {
// 1. Boot the interactive session inside tmux, in the dedicated scratch cwd.
tmux(["new-session", "-d", "-s", tmuxName, "-x", "220", "-y", "50", "-c", cwd,
buildTuiCmd(claudeBin, model, sessionId)], { env });
await sleep(BOOT_MS);
// 2. Answer the trust-folder dialog defensively. The seeded bypass flag (if any)
// suppresses the *bypass-permissions* dialog but NOT the trust-folder dialog;
// "1" = "Yes, proceed". Harmless if the dialog is absent (cwd already trusted).
tmux(["send-keys", "-t", tmuxName, "1"]);
tmux(["send-keys", "-t", tmuxName, "Enter"]);
await sleep(DIALOG_MS);
// 3. Submit the prompt. Body is pasted via `"$(cat file)"` so the content never
// touches the command line (no shell injection from prompt text), then a
// SEPARATE Enter key event submits it (Ink #15553: literal "\n" in a paste
// does not submit; the Enter key event does).
spawnSync("sh", ["-c",
`${shq(TMUX)} send-keys -t ${shq(tmuxName)} -- "$(cat ${shq(promptFile)})"`],
{ env, encoding: "utf8" });
await sleep(PASTE_SETTLE_MS);
tmux(["send-keys", "-t", tmuxName, "Enter"]);
// 4. Read the answer from the native transcript.
const tpath = transcriptPath(home || process.env.HOME, cwd, sessionId);
return await readTuiTranscript({ transcriptPath: tpath, wallclockMs });
} finally {
// 5. Teardown — always. Kill the session, remove the temp prompt dir.
try { tmux(["kill-session", "-t", tmuxName]); } catch { /* already gone */ }
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
}
}
```
- [ ] **Step 4: Run to verify it passes** (PI231, live)
Run (PI231): `OCP_TUI_LIVE=1 node test-features.mjs 2>&1 | grep -i "runTuiTurn"`
Expected: PASS — output contains `PONG`. Also confirm no orphan sessions: `tmux ls 2>/dev/null | grep ocp-tui- || echo "clean"``clean`.
- [ ] **Step 5: Commit**
```bash
git add lib/tui/session.mjs test-features.mjs
git commit -m "feat(tui): runTuiTurn — interactive session driver (boot/trust/paste/Enter/read/teardown)"
```
---
## PR-3 — Wiring into `server.mjs`
Gate TUI-mode behind one env flag. Default path (`CLAUDE_TUI_MODE` unset) stays byte-for-byte identical.
### Task 6: env consts + `streamStringAsSSE` DRY refactor
**Files:**
- Modify: `server.mjs` (env consts near `:275`; refactor cache-replay block `:1524``:1539` into a helper near `:1023`)
- [ ] **Step 1: Add TUI env consts + import** (near the other `const ... = process.env...` at `server.mjs:258``:275`)
```js
import { runTuiTurn, reapStaleTuiSessions } from "./lib/tui/session.mjs";
// TUI-mode (subscription-pool bridge). Opt-in; default OFF keeps stream-json path.
// Authority: docs/adr/0007-tui-interactive-mode.md.
const TUI_MODE = process.env.CLAUDE_TUI_MODE === "true";
const TUI_WALLCLOCK_MS = parseInt(process.env.CLAUDE_TUI_WALLCLOCK_MS || "120000", 10);
const TUI_CWD = process.env.OCP_TUI_CWD || `${process.env.HOME}/.ocp-tui/work`;
```
- [ ] **Step 2: Extract the chunked-SSE-replay into a reusable helper** (near `completionResponse` at `:1023`)
```js
// Replay a complete string as a chunked SSE stream (80 codepoints/chunk).
// Extracted from the cache-hit replay block so TUI-mode streaming reuses it.
function streamStringAsSSE(res, id, model, content) {
const created = Math.floor(Date.now() / 1000);
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" });
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] });
const CHUNK = 80;
const codepoints = Array.from(content);
for (let i = 0; i < codepoints.length; i += CHUNK) {
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { content: codepoints.slice(i, i + CHUNK).join("") }, finish_reason: null }] });
}
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] });
res.write("data: [DONE]\n\n");
res.end();
}
```
- [ ] **Step 3: Point the cache-hit streaming replay (`:1524``:1539`) at the helper** (DRY — behavior identical)
Replace the inline block inside `if (stream) { ... }` of the cache hit with:
```js
if (stream) {
const id = `chatcmpl-${randomUUID()}`;
streamStringAsSSE(res, id, model, cached.response);
return;
} else {
```
- [ ] **Step 4: Run the full suite to verify no regression**
Run: `node test-features.mjs 2>&1 | tail -3`
Expected: all existing tests PASS (the refactor is behavior-preserving; cache-replay covered by existing D3 tests).
- [ ] **Step 5: Commit**
```bash
git add server.mjs
git commit -m "refactor(server): extract streamStringAsSSE helper + add TUI env consts"
```
### Task 7: `callClaudeTui` + dispatch gates
**Files:**
- Modify: `server.mjs` (new `callClaudeTui` near `callClaude:735`; gates at the buffered dispatch `:1563`/`:1594` and streaming dispatch `:1551`)
- [ ] **Step 1: Add `callClaudeTui`** (near `callClaude`, after `:800`)
```js
// TUI-mode upstream: drive an interactive claude session, return the assistant
// text as a string — same contract as callClaude(), so all downstream
// (singleflight, cache write-back, completionResponse) is unchanged.
// System messages are rendered inline as [System] blocks by messagesToPrompt;
// we deliberately do NOT pass --system-prompt in interactive mode to avoid any
// flag that could perturb cc_entrypoint classification.
function callClaudeTui(model, messages, conversationId, keyName) {
const cliModel = MODEL_MAP[model] || model;
const prompt = messagesToPrompt(messages); // includes system as [System] inline
recordModelRequest(cliModel, prompt.length);
return runTuiTurn({
prompt, model: cliModel, claudeBin: CLAUDE,
home: process.env.HOME, cwd: TUI_CWD, wallclockMs: TUI_WALLCLOCK_MS,
}).then((text) => {
recordModelSuccess(cliModel, 0);
return text;
}).catch((err) => {
recordModelError(cliModel, false);
throw err;
});
}
```
- [ ] **Step 2: Gate the buffered dispatch** — at `server.mjs:1563``:1597`, replace the two `callClaude(...)` call sites (inside the singleflight closure and the cache-disabled fallback) with a selected upstream:
Add once, just before the `if (CACHE_TTL > 0 && req._cacheHash)` block (~`:1563`):
```js
const upstreamCall = TUI_MODE ? callClaudeTui : callClaude;
```
Then change `await callClaude(model, messages, conversationId, req._authKeyName)``await upstreamCall(model, messages, conversationId, req._authKeyName)` at **both** sites (`:1572` and `:1594`).
- [ ] **Step 3: Gate the streaming dispatch** — at `server.mjs:1551``:1553`, branch TUI streaming to buffer-then-replay:
```js
if (stream) {
if (TUI_MODE) {
// TUI has no token stream; buffer the turn, write-back to cache, replay as chunked SSE.
const t0Usage = Date.now();
try {
const content = await callClaudeTui(model, messages, conversationId, req._authKeyName);
if (CACHE_TTL > 0 && req._cacheHash) {
try { setCachedResponse(req._cacheHash, model, content); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
}
const id = `chatcmpl-${randomUUID()}`;
streamStringAsSSE(res, id, model, content);
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars: messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0), responseChars: content.length, elapsedMs: Date.now() - t0Usage, success: true }); } catch {}
return;
} catch (err) {
if (res.headersSent || res.writableEnded || res.destroyed) { try { res.end(); } catch {}; return; }
const safeMessage = (err.message || "Internal error").replace(/\/[\w/.\-]+/g, "[path]");
return jsonResponse(res, 500, { error: { message: safeMessage, type: "proxy_error" } });
}
}
// Default: real stream-json streaming, unchanged.
return callClaudeStreaming(model, messages, conversationId, res, { keyId: req._authKeyId, keyName: req._authKeyName, cacheHash: req._cacheHash });
}
```
- [ ] **Step 4: Verify default path is untouched + TUI path selected only by flag**
Run: `CLAUDE_TUI_MODE= node -e "process.env.CLAUDE_TUI_MODE; import('./server.mjs')" 2>&1 | head -1 || true`
Then the regression suite: `node test-features.mjs 2>&1 | tail -3`
Expected: all PASS (no test sets `CLAUDE_TUI_MODE`, so `upstreamCall === callClaude` and streaming uses `callClaudeStreaming` — identical to today).
Live end-to-end (PI231, after Task 8 setup): with `CLAUDE_TUI_MODE=true` start OCP and `curl` both `stream:false` and `stream:true`:
```bash
curl -s localhost:3456/v1/chat/completions -H "Authorization: Bearer <key>" \
-d '{"model":"claude-haiku-4-5-20251001","messages":[{"role":"user","content":"say PONG"}]}' | head
```
Expected: a normal OpenAI completion whose content contains `PONG`. Cross-check on PI231 that the spawned `claude` had no `-p`/`--output-format` (`ps -ef | grep claude`).
- [ ] **Step 5: Commit**
```bash
git add server.mjs
git commit -m "feat(tui): gate interactive TUI upstream behind CLAUDE_TUI_MODE (buffered + streaming)"
```
### Task 8: reaper hook at boot + ADR + README + CHANGELOG
**Files:**
- Modify: `server.mjs` (boot block — call `reapStaleTuiSessions()` once on startup when `TUI_MODE`)
- Create: `docs/adr/0007-tui-interactive-mode.md`
- Modify: `README.md`, `CHANGELOG.md`
- [ ] **Step 1: Reaper on boot** (in the server start/`listen` block)
```js
if (TUI_MODE) {
try { const n = reapStaleTuiSessions(); if (n) logEvent("info", "tui_reaped_stale_sessions", { count: n }); } catch {}
console.log(` TUI-mode: ON (interactive claude → cc_entrypoint=cli). cwd=${TUI_CWD} wallclock=${TUI_WALLCLOCK_MS}ms`);
}
```
- [ ] **Step 2: Write ADR 0007**`docs/adr/0007-tui-interactive-mode.md`
Context: 2026-06-15 billing split routes by `cc_entrypoint`; `-p`/`--output-format``sdk-cli` (Agent SDK credit pool, ~$20 on Pro = unusable). Decision: opt-in interactive driver ⇒ `cli` (subscription pool). Authority: spec §1/§4, claude v2.1.158. Scope: A-path single-user; MCP hard-disabled via `--strict-mcp-config`. Kill-switch: unset `CLAUDE_TUI_MODE` → stream-json path restored. Consequences: no token streaming (buffered+replayed); grey-area, billing unmeasurable until 6/15; reaper + tmux-prefix coexistence rules.
- [ ] **Step 3: README** — add `CLAUDE_TUI_MODE`, `CLAUDE_TUI_WALLCLOCK_MS`, `OCP_TUI_CWD` to the env-var table; add a "Subscription-pool (TUI) mode" section (what it is, opt-in, the 6/15 rationale, no-streaming caveat, the one-time `mkdir -p ~/.ocp-tui/work` + tmux dependency, and the `CLAUDE_TUI_MODE` unset kill-switch).
- [ ] **Step 4: CHANGELOG** — Unreleased: `feat(tui): opt-in CLAUDE_TUI_MODE — serve via interactive claude (cc_entrypoint=cli / subscription pool); default stream-json path unchanged.`
- [ ] **Step 5: Commit**
```bash
git add server.mjs docs/adr/0007-tui-interactive-mode.md README.md CHANGELOG.md
git commit -m "feat(tui): boot reaper + ADR 0007 + README + CHANGELOG (TUI-mode docs)"
```
---
## Integration & canary (post-implementation, on PI231)
1. Stop the OLP test instance (`:4567`) — clean shared OAuth + no tmux collision.
2. `git clone`/checkout this branch on PI231, `mkdir -p ~/.ocp-tui/work`, start OCP on `:3456` with `CLAUDE_TUI_MODE=true`.
3. Run the live driver suite: `OCP_TUI_LIVE=1 node test-features.mjs`.
4. End-to-end `curl` (buffered + streaming) through OCP; confirm spawned `claude` carries no `-p`/`--output-format`.
5. **Pre-6/15 deliverable = here.** Billing measurement waits for 6/15; document the kill-switch (unset `CLAUDE_TUI_MODE`).
---
## Self-Review (against spec + the OCP-first execution review)
- **Spec coverage:** transcript path formula (§4 → Task 1), parsing/terminal/extract (§4 → Task 2), polling reader + wall-clock cap + no-quiescence (§4.3 → Task 3), submission recipe file→paste→Enter (§5/T3 → Task 5), trust-dialog handling (§5.2 → Task 5), MCP disable `--strict-mcp-config` (§5.2/T6 → Tasks 5 & buildTuiCmd), string-contract drop-in (→ Tasks 67), kill-switch + default-path-sacred (→ Task 7 Step 4), coexistence prefix + reaper (→ Tasks 4 & 8). ✅
- **Review findings folded:** OCP-first string match (Task 7); no ephemeral-home, real-home + scratch cwd (scope §); reader-only sharing, driver forked (file table); tmux prefix + scoped reaper + never-both-on-OAuth (Task 4, Integration §1); `TIMEOUT=600000 > 120s` cap verified (no SIGKILL-mid-turn); `--strict-mcp-config` added (Task 5); provenance jaekwon-park (Why §). OLP-sync deferred. ✅
- **Placeholder scan:** none — every code step carries real code; every run step an exact command + expected output. ✅
- **Type consistency:** `runTuiTurn`/`reapStaleTuiSessions`/`SESSION_PREFIX` exported in Task 45 match imports in Task 68; `streamStringAsSSE(res, id, model, content)` defined Task 6, used Tasks 67; `callClaudeTui(model, messages, conversationId, keyName)` mirrors `callClaude`'s signature. ✅
- **Open item for integration:** confirm on PI231 that the seeded `~/.claude.json` is unnecessary for real-home A (onboarding already complete); if a bypass-permissions dialog *does* appear in real home, add a one-line seed step (`bypassPermissionsModeAccepted:true`) — but the driver already answers the trust dialog defensively, so the turn still completes.
@@ -0,0 +1,147 @@
# Design: Response Cache Upgrade (Per-Key Isolation, cache_control Bypass, Chunked Stream Replay, Singleflight)
**Date:** 2026-05-07
**Status:** Draft (awaiting maintainer approval)
**Target version:** v3.13.0 (minor — internal correctness/concurrency improvements; no new public env vars or endpoints)
**Driving ADR:** [ADR 0005 — No Multi-Provider](../../adr/0005-no-multi-provider.md), decision §3 ("Cache improvements are in scope")
---
## Overview
OCP already has a response cache (`keys.mjs:296` `cacheHash` / `keys.mjs:311` `getCachedResponse` / `keys.mjs:324` `setCachedResponse`), wired into the proxy core at `server.mjs:1220` (non-streaming path read), `server.mjs:1227` (cache-hit-on-streaming-request replay), and `server.mjs:683` (streaming write-back). Today it has four functional gaps. This PR pair closes all four, in two minimum-reviewable units, **without changing the public API surface**.
| Gap | Impact today | Fix lands in |
|---|---|---|
| All keys share one cache pool | Key A's cache hit can leak Key B's prompt response | PR-A |
| Anthropic `cache_control` markers not detected | OCP cache may interfere with Anthropic prompt caching that the user explicitly requested | PR-A |
| Stream cache hit replays whole content in one SSE chunk | Downstream renders all-at-once; some SDKs misbehave on huge single deltas | PR-A |
| Concurrent identical cache misses all spawn `cli.js` independently | Cache stampede: N requests → N spawns → N billable calls | PR-B |
---
## Constitutional alignment (ALIGNMENT.md)
**`cli.js` does not perform response caching at the proxy layer.** The OCP response cache is a value-add operation that exists only inside OCP, between the wire (clients ↔ OCP) and the spawn (OCP ↔ `cli.js`). It does not introduce, rename, or alter any endpoint, header, request field, or response field that `cli.js` emits or expects. Cache hits return content byte-identical to what `cli.js` returned on the original miss, with the same `chat.completion` / `chat.completion.chunk` shape — **no client-observable wire shape change**.
This PR pair extends the existing cache (introduced in earlier commits) without expanding its surface. No new endpoints. No new headers. No new env vars exposed publicly (we add internal counters readable via the existing `/cache/stats` endpoint, but the response shape only gains numeric fields, not new structural fields).
Per Rule 1 / Rule 5: every commit body in this PR pair will state the absence of `cli.js` reference explicitly and justify scope under Rule 2's value-add carve-out for non-wire-affecting proxy operations.
---
## Key decisions (with rationale)
### D1. Per-key isolation via hash input, not schema column
`cacheHash` gains an optional `keyId` input. Distinct `keyId` values produce distinct hashes for the same prompt, so SQLite-level isolation falls out for free without a schema change.
**Rationale.** Adding a `key_id` column to `response_cache` requires either (a) dropping the existing `hash UNIQUE` index and replacing with a composite `(hash, key_id) UNIQUE`, which SQLite cannot do via plain `ALTER TABLE` and would require a table-rebuild migration, or (b) tolerating duplicate `hash` rows, which contradicts the existing schema comment and breaks `setCachedResponse`'s `ON CONFLICT(hash)` upsert clause.
The hash-input approach is reversible (we can switch to a schema column later if analytics across keys becomes a real need) and zero-risk on the SQL plane. The trade-off — losing the ability to query "which keys have cached this prompt?" — has no current consumer.
**Hash input format.** `cacheHash` prepends a version tag and key tag before the existing inputs:
```
v2|k:<keyId or "anon">|<model>|...rest as today
```
The `v2` prefix means existing v1-format rows in the cache table no longer hash-match any new request. They are abandoned, not deleted; the existing TTL-based `clearCache(CACHE_TTL)` cleanup interval at `server.mjs:185` reaps them within one TTL window. **No migration step is needed.** This is acceptable because the cache is by definition ephemeral and best-effort.
**Anonymous fallback.** When the request has no authenticated key (`req._authKeyId === undefined`), `keyId` is `"anon"`. Anonymous-mode users (PROXY_ANONYMOUS_KEY or no auth) share one anonymous pool, which preserves the only legitimate today-multi-user use case (a household running OCP without per-user keys). If this becomes a problem we can add per-IP scoping later, but anonymous-pool sharing is acceptable for v1 because anonymous mode is fundamentally a trust-everyone-on-LAN posture.
### D2. `cache_control` bypass: detect anywhere, skip OCP cache entirely
If any element in `messages` (top-level or nested in `content` arrays) carries a `cache_control` field, OCP sets `req._cacheHash = null` and skips both lookup and write-back.
**Rationale.** Anthropic's [prompt caching](https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching) is opt-in by client-side annotation. A user who annotates `cache_control: { type: "ephemeral" }` is explicitly requesting that *Anthropic's* cache serve the call (and is paying the reduced cache-read pricing). Layering OCP's response cache on top in this case is wrong on two counts:
1. The user's intent is "cache at provider, not at proxy." OCP overruling that intent silently is the same drift family as the 2026-04-11 incident — proxy invents behavior the upstream surface doesn't request.
2. OCP cache hits would make `usage.cache_read_input_tokens` (the client-observable signal that prompt caching worked) appear inconsistent — sometimes present, sometimes absent — depending on whether OCP cached.
Detection is purely structural: walk `messages`, for each `m` check `m.cache_control` (rare top-level form) and if `m.content` is an array, check each part. No semantic interpretation; if the field is present, we bypass.
**Implementation site.** A small helper `hasCacheControl(messages)` exported from `keys.mjs`, called in `handleChatCompletions` immediately before the existing `cacheHash` call. If it returns true, we skip the cache-lookup branch entirely.
### D3. Chunked stream replay (80 chars/chunk, no artificial delay)
Today's cache-hit-on-streaming-request branch (`server.mjs:12271237`) sends the entire cached content in a single `delta.content` chunk. This works for spec-compliant SSE clients but visibly degrades the UX (no incremental render) and has tripped at least one buggy SDK in the wild that assumes deltas are small.
The fix splits cached content into ~80-character substrings, each sent as a separate `chat.completion.chunk` SSE event. **No artificial delay between chunks** — they ship as fast as `res.write` accepts. This preserves OCP's "ship as fast as possible" disposition; we are simulating *the chunk shape* of streaming, not the *latency*.
**Why 80 chars?** Compromise: small enough that even a multi-paragraph cached response yields >5 chunks (visible incremental render), large enough that even a 4 KB response only produces 50 chunks (not 4000 single-char events). Tunable later via internal constant; not exposed as env var per scope-creep avoidance.
**Boundary safety.** UTF-8 multibyte characters: we slice by `Array.from(content)` (so each iteration step is a full code point) and group every 80 code points. This avoids producing invalid UTF-8 mid-character.
### D4. Singleflight stampede protection: in-process Map, all-or-nothing failure
`keys.mjs` exports `singleflight(hash, fn)`. An in-memory `Map<hash, Promise>` deduplicates concurrent identical cache-miss flows. The first request executes `fn()`; concurrent requests with the same hash receive the same promise. When the promise settles (resolve or reject), the map entry is deleted.
**Rationale (single-process scope).** OCP runs as a single Node.js process per host. A `Map` is sufficient. Adding Redis or another shared store would be the start of a multi-instance evolution, which is out of scope per ADR 0005 (OCP is a personal power tool, not a horizontally-scaled SaaS).
**All-or-nothing failure semantics.** When the leader's `fn()` rejects, all followers receive the same rejection. The alternative — letting followers retry independently after a leader failure — risks N retries of an already-broken upstream, which is exactly what stampede protection was meant to prevent. Followers can retry at the *next* request, with idle backoff handled by the client. This matches Go's `golang.org/x/sync/singleflight` reference behavior.
**Streaming caveat.** Singleflight wraps the *non-streaming* code path only in PR-B. For streaming, deduplicating concurrent identical streaming requests is materially harder (we'd need to fan out one upstream stream to N downstream connections in real time, with backpressure). It's also a less common case (cache stampedes typically come from non-streaming batch jobs hitting the proxy in parallel). Streaming dedup is **explicitly out of scope** for this PR pair; leave a TODO comment in `callClaudeStreaming` for a future ticket.
**Map size unboundedness.** In normal operation the map is empty most of the time (entries delete on Promise settlement). Pathological case: an upstream call that hangs forever leaks one Map entry per stuck request. The existing `TIMEOUT` guard on `callClaude` (server.mjs spawn timeout) bounds this — the Promise will reject (timeout) within `TIMEOUT` ms, and the entry clears. No additional sweep needed.
---
## PR boundaries
### PR-A — Foundation (D1 + D2 + D3)
**Files touched:**
- `keys.mjs`: extend `cacheHash` with optional `keyId`/version prefix; add `hasCacheControl(messages)` helper
- `server.mjs`: pass `req._authKeyId` to `cacheHash`; check `hasCacheControl` and bypass; chunk cache-hit replay at line 12271237
- `test-features.mjs`: add cases for keyId isolation, cache_control bypass, chunked replay shape
**LOC budget:** ~80 production + ~50 test
**Risk:** Low — all changes are additive or guard-clause; existing cache behavior preserved when `keyId` defaults to "anon" and no `cache_control` present.
**Backward compat:** v1-format hashes naturally orphan; TTL cleanup reaps within one window; no migration script.
### PR-B — Concurrency (D4)
**Files touched:**
- `keys.mjs`: add `singleflight(hash, fn)` and `getInflightStats()` exports
- `server.mjs`: wrap non-streaming cache-miss path through `singleflight`; add inflight count to `/cache/stats` response
- `test-features.mjs`: add concurrent-request test that asserts only 1 spawn occurs for N=10 simultaneous identical requests
**LOC budget:** ~70 production + ~40 test
**Risk:** Medium — concurrency code is harder to reason about; mitigation is an explicit test case for the dedup behavior.
**Streaming explicitly out of scope:** TODO comment placed in `callClaudeStreaming` for follow-up ticket.
---
## Testing strategy
**Unit-ish (in `test-features.mjs`):**
1. `cacheHash` with two different `keyId` values → different hashes
2. `cacheHash` v2 prefix present in output (sanity check)
3. `hasCacheControl` returns true for top-level `cache_control` and for nested in `content[]`
4. `hasCacheControl` returns false for benign messages
5. Chunked replay: cached "abcdefgh..." (160 chars) produces 2 deltas
**Integration (manual smoke before merge):**
1. Set `CLAUDE_CACHE_TTL=60000`; create key A and key B; identical prompt from each → both spawn fresh; second-call from same key → cache hit
2. Send a message with `cache_control` annotation → OCP logs `cache_skipped: cache_control_present`; no cache write
3. Streaming cache hit visibly produces multiple SSE deltas (`curl -N | grep "data: "` shows >1 lines)
**Concurrent (PR-B only):**
1. Spawn 10 simultaneous identical non-streaming requests; assert (via `/cache/stats` inflight peak or via a process spawn counter) only 1 `cli.js` spawn occurred
---
## Out of scope (deliberately deferred)
- **Streaming singleflight** — see D4 streaming caveat. TODO in code.
- **Semantic cache** (embedding-based near-match) — needs an embedding provider + vector index. Punt to v3.14+ if there's user demand.
- **Cross-process cache** (Redis backend) — violates ADR 0005's "personal power tool" posture.
- **Cache versioning by model ID hash** — model upgrades currently invalidate cache organically because model is in the hash; if Anthropic ever silently changes a model's behavior without a model ID bump, that's a separate alignment problem.
- **Per-key cache TTL override** — single global TTL (existing `CLAUDE_CACHE_TTL`) is fine; per-key TTL is a knob no one has asked for.
---
## Rollback plan
If either PR introduces a regression, the rollback is a clean git revert. The cache layer is opt-in (default `CLAUDE_CACHE_TTL=0` = disabled), so users who never enabled the cache are unaffected by any cache-layer regression. Users who *had* enabled the cache lose only ephemeral state on revert. No persistent on-disk state is reshaped by this PR pair (we explicitly avoid schema migrations per D1 rationale).
+136
View File
@@ -0,0 +1,136 @@
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:
```bash
launchctl bootout gui/$(id -u)/dev.ocp.proxy # macOS launchd
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`:
```bash
launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/dev.ocp.proxy.plist
```
Verify the new value reached the running process:
```bash
ps -E -p "$(launchctl print gui/$(id -u)/dev.ocp.proxy 2>/dev/null | awk '/pid =/{print $3}')" | tr ' ' '\n' | grep CLAUDE_
```
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
```
Future `ocp update` invocations sync automatically.
<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
tr '\0' '\n' < /proc/$(pgrep -f server.mjs | head -1)/environ | grep CLAUDE_CODE_OAUTH_TOKEN
# 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.
+196
View File
@@ -0,0 +1,196 @@
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.
<a id="tui-entrypoint"></a>
## Billing-classifier labeling (`OCP_TUI_ENTRYPOINT`)
`OCP_TUI_ENTRYPOINT` (default `cli`) controls how `CLAUDE_CODE_ENTRYPOINT` is set on the spawn
environment. The default (`cli`) pins the value deterministically — immune to a stray inherited
env var or a future stdout-redirect bug silently flipping it to `sdk-cli`. This label is honest
**only** when the spawn is a genuine interactive PTY (tmux pane, no `-p`, stdout not redirected,
and `tmux new-session` verified to succeed). If you need to observe the raw TTY-derived value, set
`OCP_TUI_ENTRYPOINT=auto`. See ADR 0007 for the full rationale and governing rule.
## Enabling TUI-mode (opt-in)
```bash
# Prerequisites
mkdir -p ~/.ocp-tui/work # one-time scratch cwd setup
# 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 always runs `claude` with `CLAUDE_CODE_DISABLE_CLAUDE_MDS` + `CLAUDE_CODE_DISABLE_AUTO_MEMORY`, so a `CLAUDE.md` on the OCP host can never leak into proxied turns (verified live; see #4). Built-in tool schemas + the interactive system prompt remain (the inherent ~2035K context floor of interactive mode); MCP is hard-disabled.
- **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:
| | measured |
|---|---|
| **TTFT floor (first token)** | **≈ 6 s** — immovable |
| cold boot → input bar ready | ~1 s (per request; not the bottleneck) |
| OCP's own overhead above the CLI | ~4 s (n=1 same-turn decomposition) |
| direct Anthropic API, same prompt (for scale) | 0.841.64 s |
The ~6 s floor is the `claude` CLI itself: it always injects the full Claude Code system prompt plus
its tool definitions before your prompt, on every turn, no matter what you ask. No flag removes it
(`--exclude-dynamic-system-prompt-sections` was measured: **no effect** on the floor). Extended
thinking is *not* the cause — `OCP_TUI_EFFORT` already defaults to `low`, which is what cuts a
formerly-inherited `xhigh` down to this floor and collapses its variance.
On top of the floor you pay the model's generation time (a function of output length). Progressive
output is not wired up **yet** (see "No real token streaming" above — it is achievable and planned),
so today a turn returns as one blob once generation completes. Note that streaming, when it lands,
will move the *first* byte earlier — it does **not** shorten the turn, and a consumer that needs the
complete answer gains nothing from it.
**Use TUI mode for**: batch, background, and latency-insensitive work where the subscription pool is
the point. **Do not use it for**: anything a person is waiting on interactively, or any consumer with
a sub-5-second budget. Full measurements and methodology:
[`plans/2026-07-13-tui-latency/`](plans/2026-07-13-tui-latency/).
## Monitoring drift via `/health`
`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)
"size": 2, // target warm panes (OCP_TUI_POOL_SIZE)
"warm": 2, // panes ready right now — each is a LIVE idle claude process
"booting": 0, // replacement panes currently pre-booting
"model": "claude-sonnet-4-6", // the model being warmed (the most recently requested one)
"hits": 12, // requests served by a warm pane
"misses": 1, // requests that fell back to the cold boot (the 1st is always one)
"boots": 14, // panes successfully pre-booted
"bootFailures": 0, // pre-boots that genuinely never reached the input bar — WATCH this
"cancelled": 4, // in-flight boots OCP killed on purpose (drain / model switch) — not faults
"dropped": 8 // panes discarded unused (drain sweep / expired / unhealthy)
}
}
```
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).
+79
View File
@@ -0,0 +1,79 @@
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 --yes # restore most recent snapshot (--yes confirms)
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`
- `agents.defaults.models["claude-local/*"]` aliases
**Safety**:
- Timestamped backup written before every change: `~/.openclaw/openclaw.json.bak.<ms>`
- Idempotent — already-in-sync runs are a no-op (no backup, no rewrite)
- Non-fatal — sync failure does NOT abort `ocp update`; `/v1/models` still works
- Skips silently if OpenClaw is not installed (`~/.openclaw/openclaw.json` missing)
**Manual trigger** (e.g. after fixing a hand-edited config, or for the one-time v3.10.0→v3.11.0 bootstrap quirk):
```bash
node ~/ocp/scripts/sync-openclaw.mjs
node ~/ocp/scripts/sync-openclaw.mjs --quiet # silent unless changes
```
**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.
+133 -7
View File
@@ -3,25 +3,77 @@
import { DatabaseSync } from "node:sqlite";
import { randomBytes, createHash } from "node:crypto";
import { join } from "node:path";
import { mkdirSync } from "node:fs";
import { mkdirSync, chmodSync } from "node:fs";
import { homedir } from "node:os";
const OCP_DIR = join(homedir(), ".ocp");
mkdirSync(OCP_DIR, { recursive: true });
const DB_PATH = join(OCP_DIR, "ocp.db");
// Resolved LAZILY, on first getDb() — not at module top-level. Two reasons, and the second is
// the bug this fixes:
//
// 1. Merely IMPORTING keys.mjs should not, as a side effect, create directories in the
// operator's home.
// 2. OCP_DIR_OVERRIDE exists so the test suite can point the key store at a scratch dir — and
// because ESM hoists imports, a top-level `const OCP_DIR = ...` here would be evaluated
// BEFORE an importing module's body could set the env var. Eager resolution made the
// override unsettable in the one place that needs it. (test-features.mjs carried a comment
// claiming it could "set env before the first getDb() call" — it could not, because nothing
// here ever read an env var. So `npm test` wrote real, UNREVOKED api_keys rows into the
// operator's live ~/.ocp/ocp.db: two per run, unbounded — 737 junk keys against 12 real ones
// on the maintainer's host — and two concurrent runs raced one file, which is the ~1-in-6
// flake in `listKeys includes quota fields`.)
//
// The override is gated on NODE_ENV === "test", and that gate is the ACTUAL guard. An earlier
// cut of this fix relied on the variable merely having an awkward name — i.e. a naming convention
// plus a comment — which is precisely the failure mode this whole change exists to indict (a
// comment describing an intention that nothing enforces). The two-key gate means NEITHER var
// alone does anything: a stray OCP_DIR_OVERRIDE with no NODE_ENV is inert, and NODE_ENV=test with
// no override just resolves the default dir.
//
// This gate does NOT, by itself, prove a production daemon can't be redirected — an earlier
// version of this comment overclaimed that ("a production server runs without NODE_ENV, so it
// CANNOT honor the override no matter how the variable got in"). That is only true while the
// daemon's env actually lacks NODE_ENV=test, which is an assumption, not something this file can
// enforce. What makes it hold in the shipped configuration is defense-in-depth in OCP's launchers:
// the plist/systemd units strip both vars on every (re)install (scripts/lib/plist-merge.mjs
// NEVER_PRESERVE), and `ocp` restart's manual nohup fallback strips them (`env -u`). So a server
// OCP itself started cannot carry the test-only redirection. The one residual path is an operator
// who hand-launches `node server.mjs` with BOTH vars explicitly exported, bypassing every
// launcher — a case no library-level gate can catch. The loud getDb() log below ("NOT the default
// ~/.ocp/ocp.db") is the backstop there: a wrong key store is at least never silent (in
// AUTH_MODE=multi that would otherwise be a total auth outage with nothing on /health to show it).
function resolveOcpDir() {
const override = process.env.NODE_ENV === "test" ? process.env.OCP_DIR_OVERRIDE : null;
const dir = override || join(homedir(), ".ocp");
mkdirSync(dir, { recursive: true, mode: 0o700 });
// Tighten the directory mode in case it already existed with broader permissions.
try { chmodSync(dir, 0o700); } catch { /* ignore EPERM on pre-existing dirs */ }
return dir;
}
let db;
let dbPath; // resolved on first open, alongside the db handle
export function getDb() {
if (!db) {
db = new DatabaseSync(DB_PATH);
dbPath = join(resolveOcpDir(), "ocp.db");
// Say which store we opened. Silence was the other half of the bug: a server on the wrong
// key store looks exactly like a server on the right one until every request 401s.
if (dbPath !== join(homedir(), ".ocp", "ocp.db")) {
console.error(`[keys] key store: ${dbPath} (NOT the default ~/.ocp/ocp.db)`);
}
db = new DatabaseSync(dbPath);
db.exec("PRAGMA journal_mode = WAL");
db.exec("PRAGMA foreign_keys = ON");
initSchema();
// Tighten mode on the DB file (0600) after creation / first open.
try { chmodSync(dbPath, 0o600); } catch { /* ignore — same-user access still works */ }
}
return db;
}
// Which file the key store actually opened. Exported so a test can ASSERT it is not the
// operator's real db — the bug this replaced was invisible precisely because nothing checked.
export function getDbPath() { return dbPath; }
function initSchema() {
db.exec(`
CREATE TABLE IF NOT EXISTS api_keys (
@@ -292,13 +344,27 @@ export function getKeyQuota(keyId) {
// ── Response cache ──
// Generate a cache key from model + messages + request params that affect output
// Generate a cache key from model + messages + request params that affect output.
// opts.keyId isolates per-API-key cache pools (v2 hash format).
// When keyId is absent/null/empty, falls back to "anon" (shared anonymous pool).
export function cacheHash(model, messages, opts = {}) {
const keyId = opts.keyId || "anon";
const h = createHash("sha256");
h.update(`v2|k:${keyId}|`);
h.update(model);
if (opts.temperature != null) h.update(`t:${opts.temperature}`);
if (opts.max_tokens != null) h.update(`mt:${opts.max_tokens}`);
if (opts.top_p != null) h.update(`tp:${opts.top_p}`);
// #176: fold the server's boot-config epoch into the key, so a config change that shapes
// answers (operator system prompt, wrapper text, allowed tools, NO_CONTEXT) invalidates
// the persistent cache instead of serving answers composed under the old config. Callers
// that omit it (older paths, tests) hash byte-identically to before.
if (opts.configEpoch != null) h.update(`ce:${opts.configEpoch}|`);
// Structured-output (OpenAI response_format / json_mode) requests must never share a cache slot
// with the conversational answer to the same prompt, nor with a different schema — the steering
// instruction and validated JSON payload differ. Keying on the detected descriptor isolates them.
// Absent for normal requests → hashes are byte-identical to pre-change.
if (opts.structured != null) h.update(`s:${JSON.stringify(opts.structured)}`);
for (const m of messages) {
h.update(m.role || "");
h.update(typeof m.content === "string" ? m.content : JSON.stringify(m.content));
@@ -306,6 +372,22 @@ export function cacheHash(model, messages, opts = {}) {
return h.digest("hex");
}
// Check whether any message (or content part) carries an Anthropic cache_control field.
// If true, OCP should skip its own cache to avoid interfering with prompt-caching intent.
export function hasCacheControl(messages) {
for (const m of messages || []) {
if (m && typeof m === "object") {
if (m.cache_control) return true;
if (Array.isArray(m.content)) {
for (const part of m.content) {
if (part && typeof part === "object" && part.cache_control) return true;
}
}
}
}
return false;
}
// Look up a cached response. Returns { response, hits } or null.
// Also updates last_hit_at and increments hits counter on hit.
export function getCachedResponse(hash, ttlMs) {
@@ -351,6 +433,50 @@ export function getCacheStats() {
return { entries: total, totalHits, sizeBytes };
}
// ── Singleflight stampede protection ──
// In-memory singleflight Map: hash → { promise, requesters }
// Deduplicates concurrent identical cache-miss flows so only one upstream call runs.
// Per ADR 0005 / spec D4: in-process scope only (single Node process per host).
const inflightMap = new Map();
// `retryIf` (optional, audit finding M1): a predicate applied on the FOLLOWER path only.
// When a follower joins an existing flight and the shared promise rejects with an error for
// which retryIf(err) is true (in practice: the LEADER's client disconnected while queued —
// an error that is personal to the leader, not a verdict about the upstream), the follower
// does NOT inherit that rejection. Instead it re-enters singleflight with its OWN fn: it
// either becomes the new leader (the map entry is already deleted — see the finally below,
// which runs before any follower's catch because it is attached upstream of the promise the
// followers await) or joins a flight another retrying follower just created. The leader's
// own rejection is never retried here — its error belongs to it (leader path returns the
// bare promise). Callers that pass no retryIf get the exact pre-M1 share-everything behavior.
export function singleflight(hash, fn, retryIf) {
const existing = inflightMap.get(hash);
if (existing) {
existing.requesters++;
if (!retryIf) return existing.promise;
return existing.promise.catch((err) => {
if (!retryIf(err)) throw err;
return singleflight(hash, fn, retryIf);
});
}
// Wrap fn() in Promise.resolve().then() so synchronous throws don't escape.
const promise = Promise.resolve().then(fn).finally(() => {
inflightMap.delete(hash);
});
inflightMap.set(hash, { promise, requesters: 1 });
return promise;
}
export function getInflightStats() {
let totalRequesters = 0;
for (const entry of inflightMap.values()) totalRequesters += entry.requesters;
return {
inflight: inflightMap.size,
requesters: totalRequesters,
};
}
// Find a key by id or name (returns { id, name } or null)
export function findKey(idOrName) {
const d = getDb();
@@ -358,5 +484,5 @@ export function findKey(idOrName) {
}
export function closeDb() {
if (db) { db.close(); db = null; }
if (db) { db.close(); db = null; dbPath = undefined; } // clear both — a path to a closed db is a footgun
}
+33
View File
@@ -0,0 +1,33 @@
/**
* OCP shared constants — single source of truth.
*
* Any literal that appears in more than one place across server.mjs, setup.mjs,
* scripts/* belongs here so port-drift / URL-drift cascades cannot recur.
*
* Background: from 2026-05-08 (PR #71 dogfood accident) through 2026-05-13
* (v3.16.3) a single hardcoded "3478" in scripts/upgrade.mjs + scripts/doctor.mjs
* cascaded into every downstream config write, ultimately taking out the
* OpenClaw "大内总管" Telegram agent. See CHANGELOG v3.16.2 and v3.16.3.
*
* Adding a new constant: prefer ALL_CAPS_SNAKE_CASE. Document the consumers.
* If a literal is referenced from a shell script (ocp, ocp-connect, setup.sh)
* that can't import .mjs, add a `// keep in sync with lib/constants.mjs` note
* at the shell-script reference; CI grep prevents drift.
*/
// Default TCP port the OCP HTTP proxy listens on. Set by env CLAUDE_PROXY_PORT
// at runtime; this is the fallback when env is unset.
// Consumers: server.mjs, setup.mjs, scripts/upgrade.mjs, scripts/doctor.mjs,
// scripts/sync-openclaw.mjs. Shell scripts ocp / ocp-connect keep the literal
// "3456" in sync with this value (see CI gate in .github/workflows/alignment.yml).
export const DEFAULT_PORT = 3456;
// Localhost bind for client-side fetches (curl, health checks).
export const LOCAL_HOST = "127.0.0.1";
// OpenAI-compatible API base path appended to the proxy URL.
export const OPENAI_API_BASE = "/v1";
// Convenience: full local URL the OCP proxy listens on by default.
// scripts that want to probe locally can use this directly.
export const LOCAL_PROXY_URL = `http://${LOCAL_HOST}:${DEFAULT_PORT}`;
+29
View File
@@ -0,0 +1,29 @@
// OCP env-var parsing helpers.
//
// Fail-closed positive-integer parsing for numeric caps (body size, image
// byte/count limits). A misconfigured cap must NEVER silently disable a guard:
// `parseInt("unlimited", 10)` is NaN and `x > NaN` is always false, so a naive
// parse of CLAUDE_MAX_BODY_SIZE=unlimited would remove the body-size limit
// entirely (unbounded body → OOM). Likewise CLAUDE_MAX_BODY_SIZE=5MB naively
// parses to 5 (bytes) and bricks the proxy. So a present-but-invalid value is
// REJECTED (default kept, caller warns), not accepted. (PR #154 review F3.)
//
// Pure (no env access, no IO) so it is unit-testable without a live server.
// Parse `raw` as a strictly-positive base-10 integer of bytes/count (no unit
// suffix). Returns { value, ok, reason }:
// - missing/empty → { value: def, ok: true } (use default)
// - valid positive int → { value: n, ok: true }
// - anything else → { value: def, ok: false, reason } (fail closed)
// Rejects: NaN ("unlimited"), non-positive ("0", "-1"), unit-suffixed ("5MB"),
// and fractional/ambiguous ("20.5", "0x10") values — String(n) !== trimmed catches
// any input parseInt only partially consumed.
export function parsePositiveInt(raw, def) {
if (raw === undefined || raw === null || raw === "") return { value: def, ok: true };
const trimmed = String(raw).trim();
const n = parseInt(trimmed, 10);
if (!Number.isFinite(n) || n <= 0 || String(n) !== trimmed) {
return { value: def, ok: false, reason: "not a strictly-positive integer (bytes/count, no unit suffix)" };
}
return { value: n, ok: true };
}
+278
View File
@@ -0,0 +1,278 @@
// OCP multimodal helpers — OpenAI `image_url` content parts → Anthropic image
// blocks fed to `claude -p --input-format stream-json`. (issue #110)
//
// Class B.1 (OpenAI-compatibility surface). Protocol authority is OpenAI's
// chat/completions spec — the multimodal `content` parts shape
// (https://platform.openai.com/docs/guides/vision and
// https://platform.openai.com/docs/api-reference/chat/create#chat-create-messages,
// `image_url` part with `image_url.url` = data URI or http(s) URL). Authorized
// by ADR 0006. This module introduces NO field beyond OpenAI's published shape:
// the OpenAI-side vocabulary read here is `type:"image_url"` +
// `image_url:{url, detail?}`; the Anthropic-side vocabulary written here
// (`type:"image", source:{type:"base64"|"url", ...}`) is the CLI's native
// stream-json input contract, not an OCP invention.
//
// Kept as a pure module (no I/O, no network, no process state) mirroring the
// lib/*.mjs pattern so it is unit-testable without a live server. server.mjs is
// the only consumer; it owns spawning, caps configuration, and HTTP status.
// Anthropic vision-supported image media types. A data URI whose media type is
// outside this set is rejected with a clear 4xx rather than forwarded (the API
// would reject it anyway; failing early gives a better error).
export const SUPPORTED_IMAGE_TYPES = new Set([
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
]);
// Default caps. server.mjs overrides these from env; they live here so the
// pure transform is self-contained and testable.
export const DEFAULT_MULTIMODAL_OPTS = {
allowRemoteUrl: false, // http(s) image URLs are OFF by default (v1: data URIs only)
maxImageBytes: 5 * 1024 * 1024, // per-image decoded-byte cap
maxImages: 20, // max image parts across the whole request
maxTotalImageBytes: 20 * 1024 * 1024, // aggregate decoded-byte cap
maxTextChars: Infinity, // text-char budget (server passes MAX_PROMPT_CHARS); Infinity = no truncation
};
// Typed error so server.mjs can map to the right HTTP status + OpenAI-shaped
// error body. `status` is the HTTP code; `type` is the OpenAI error `type`.
export class MultimodalError extends Error {
constructor(code, status, message) {
super(message);
this.name = "MultimodalError";
this.code = code;
this.status = status;
this.type = "invalid_request_error"; // OpenAI error `type` for 4xx client errors
}
}
// True if any message carries an OpenAI `image_url` content part. Cheap guard so
// the byte-for-byte text path is only left when an image is genuinely present.
export function hasImageContent(messages) {
if (!Array.isArray(messages)) return false;
for (const m of messages) {
if (m && Array.isArray(m.content)) {
for (const part of m.content) {
if (part && part.type === "image_url") return true;
}
}
}
return false;
}
// Extract the URL string from an OpenAI image_url part. Spec form is
// `{type:"image_url", image_url:{url, detail?}}`; many OpenAI-compatible clients
// also send `image_url` as a bare string. Accept both (input leniency — no new
// output field). `detail` (auto|low|high) is OpenAI-only and has no Anthropic
// analogue, so it is read-and-ignored.
function imageUrlOf(part) {
const iu = part.image_url;
if (typeof iu === "string") return iu;
if (iu && typeof iu.url === "string") return iu.url;
return null;
}
// Parse a base64 data URI: `data:[<media_type>][;base64],<data>`.
// Returns { mediaType, data (base64), bytes (decoded size) } or throws MultimodalError.
function parseDataUri(uri) {
const comma = uri.indexOf(",");
if (comma === -1) {
throw new MultimodalError("invalid_data_uri", 400, "Malformed image data URI (no comma).");
}
const meta = uri.slice(5, comma); // strip leading "data:"
const segs = meta.split(";");
const mediaType = (segs[0] || "").trim().toLowerCase();
const isBase64 = segs.slice(1).some((s) => s.trim().toLowerCase() === "base64");
if (!isBase64) {
throw new MultimodalError("invalid_data_uri", 400, "Only base64-encoded image data URIs are supported.");
}
if (!SUPPORTED_IMAGE_TYPES.has(mediaType)) {
throw new MultimodalError(
"unsupported_image_type",
400,
`Unsupported image media type '${mediaType || "(none)"}'. Supported: ${[...SUPPORTED_IMAGE_TYPES].join(", ")}.`
);
}
// Strip incidental whitespace/newlines some encoders insert into data URIs.
const data = uri.slice(comma + 1).replace(/\s/g, "");
if (data.length === 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(data)) {
throw new MultimodalError("invalid_data_uri", 400, "Image data URI payload is not valid base64.");
}
// Decoded size from base64 length (minus padding); avoids decoding the buffer
// just to measure it.
const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
const bytes = Math.floor((data.length * 3) / 4) - padding;
return { mediaType, data, bytes };
}
// Convert a single OpenAI image_url part to an Anthropic image block, enforcing
// caps via the mutable `acc` accumulator ({ images, bytes }). Throws MultimodalError.
function imagePartToBlock(part, opts, acc) {
const url = imageUrlOf(part);
if (!url) {
throw new MultimodalError("invalid_image_url", 400, "image_url part is missing a URL.");
}
acc.images += 1;
if (acc.images > opts.maxImages) {
throw new MultimodalError("too_many_images", 413, `Too many images in request (max ${opts.maxImages}).`);
}
if (url.startsWith("data:")) {
const { mediaType, data, bytes } = parseDataUri(url);
if (bytes > opts.maxImageBytes) {
throw new MultimodalError("image_too_large", 413, `Image exceeds per-image size limit (${opts.maxImageBytes} bytes).`);
}
acc.bytes += bytes;
if (acc.bytes > opts.maxTotalImageBytes) {
throw new MultimodalError("images_too_large", 413, `Total image payload exceeds limit (${opts.maxTotalImageBytes} bytes).`);
}
return { type: "image", source: { type: "base64", media_type: mediaType, data } };
}
if (/^https?:\/\//i.test(url)) {
if (!opts.allowRemoteUrl) {
throw new MultimodalError(
"remote_url_disabled",
400,
"Remote image URLs are disabled. Enable CLAUDE_IMAGE_ALLOW_URL=1 to allow http(s) image URLs, or pass the image as a base64 data URI."
);
}
// Passthrough as an Anthropic url-source block. OCP does NOT fetch the URL
// itself (no OCP-side SSRF surface); the fetch is performed upstream by the
// Anthropic API. Best-effort: unreachable/blocked URLs surface as an API error.
return { type: "image", source: { type: "url", url } };
}
throw new MultimodalError("unsupported_url_scheme", 400, "image_url must be a base64 data URI or an http(s) URL.");
}
// Role prefix mirrors messagesToPrompt()'s text-path labeling so a multi-turn
// conversation reads the same whether or not it carries images. System messages
// are handled by the caller via --system-prompt and never reach here.
function rolePrefix(role) {
if (role === "assistant") return "[Assistant] ";
return ""; // user / tool / anything else: verbatim, as in the text path
}
// Build the Anthropic content-block array for a single stream-json user
// envelope. Mirrors the text path's "collapse the whole conversation into one
// turn passed via stdin" model (OCP runs stateless, full context per spawn), but
// preserves image position relative to text and keeps images out of the text
// char budget entirely. Returns { blocks, stats } or throws MultimodalError.
export function buildImageBlocks(messages, opts = {}) {
const o = { ...DEFAULT_MULTIMODAL_OPTS, ...opts };
const blocks = [];
const acc = { images: 0, bytes: 0 };
let textChars = 0;
let firstMessage = true;
const pushText = (text) => {
if (!text) return;
blocks.push({ type: "text", text });
textChars += text.length;
};
for (const m of messages) {
const prefix = rolePrefix(m.role);
// Separate messages with a blank line, matching messagesToPrompt's "\n\n" join.
const sep = firstMessage ? "" : "\n\n";
firstMessage = false;
let prefixEmitted = false;
const emitPrefixWith = (t) => {
if (prefixEmitted) return t;
prefixEmitted = true;
return sep + prefix + t;
};
if (typeof m.content === "string") {
pushText(emitPrefixWith(m.content));
continue;
}
if (!Array.isArray(m.content)) {
// null / object content: mirror contentToText's fallback.
const t = m.content == null ? "" : JSON.stringify(m.content);
pushText(emitPrefixWith(t));
continue;
}
for (const part of m.content) {
if (part && part.type === "text" && typeof part.text === "string") {
pushText(emitPrefixWith(part.text));
} else if (part && part.type === "image_url") {
// Ensure the role prefix isn't lost when a message leads with an image.
if (!prefixEmitted && prefix) pushText(emitPrefixWith(""));
blocks.push(imagePartToBlock(part, o, acc));
} else {
// audio / file / unknown parts: preserve the existing placeholder
// behavior (issue #110) — deferred to a future version.
pushText(emitPrefixWith("[non-text content omitted]"));
}
}
}
// Defensive: a stream-json user turn must have at least one content block.
if (blocks.length === 0) blocks.push({ type: "text", text: "" });
// Enforce the text-char budget (PR #154 review F2). Without this, attaching a
// single tiny image would let unbounded text bypass the gateway's runaway-context
// guard entirely — messagesToPrompt truncates the text path, so the multimodal
// path must too. Image blocks are preserved and are NOT counted (they are bounded
// by the byte/count caps above).
const budgeted = enforceTextBudget(blocks, o.maxTextChars);
return {
blocks: budgeted.blocks,
stats: {
imageCount: acc.images,
totalImageBytes: acc.bytes,
textChars: budgeted.textChars,
originalTextChars: budgeted.originalTextChars,
truncated: budgeted.truncated,
},
};
}
// Enforce a text-char budget over already-built content blocks, mirroring
// messagesToPrompt's "keep the tail, drop the oldest" truncation. Image blocks are
// preserved in place; only text blocks count against the budget. A truncation note
// is prepended when anything is dropped. Returns
// { blocks, truncated, originalTextChars, textChars }.
function enforceTextBudget(blocks, maxTextChars) {
let originalTextChars = 0;
for (const b of blocks) if (b.type === "text") originalTextChars += b.text.length;
if (!(maxTextChars > 0) || originalTextChars <= maxTextChars) {
return { blocks, truncated: false, originalTextChars, textChars: originalTextChars };
}
// Keep the most recent text up to the budget; trim within the boundary block and
// drop older text blocks. Non-text (image) blocks always survive, in order.
let budget = maxTextChars;
const out = [];
for (let i = blocks.length - 1; i >= 0; i--) {
const b = blocks[i];
if (b.type !== "text") { out.unshift(b); continue; }
if (budget <= 0) continue; // older text fully dropped
if (b.text.length <= budget) {
out.unshift(b);
budget -= b.text.length;
} else {
out.unshift({ type: "text", text: b.text.slice(b.text.length - budget) });
budget = 0;
}
}
out.unshift({ type: "text", text: "[System] Note: older text content was truncated to fit the context limit." });
let textChars = 0;
for (const b of out) if (b.type === "text") textChars += b.text.length;
return { blocks: out, truncated: true, originalTextChars, textChars };
}
// Serialize the non-system conversation to a single newline-terminated
// stream-json user message for `claude -p --input-format stream-json` stdin.
// Returns { payload, stats } or throws MultimodalError.
export function buildStreamJsonInput(messages, opts = {}) {
const { blocks, stats } = buildImageBlocks(messages, opts);
const envelope = { type: "user", message: { role: "user", content: blocks } };
return { payload: JSON.stringify(envelope) + "\n", stats };
}
+9
View File
@@ -0,0 +1,9 @@
// OCP network helpers — shared so server.mjs and tests use one definition. (issue #125)
// A bind address is "loopback" only if it cannot be reached from another host.
// Any other address (0.0.0.0, ::, a concrete LAN/Tailscale IP, etc.) is
// network-exposed and must trigger the TUI LAN gate.
export function isLoopbackBind(addr) {
return addr === "127.0.0.1" || addr === "::1" || addr === "localhost" ||
addr === "::ffff:127.0.0.1" || /^127\./.test(addr);
}
+89
View File
@@ -0,0 +1,89 @@
// lib/prompt.mjs — pure operator-append step for the system prompt.
//
// Extracted so the rule is unit-testable (the suite never imports server.mjs — it
// boots a listener). server.mjs composes wrapper + client system messages exactly as
// before, then passes the result through this. With CLAUDE_SYSTEM_PROMPT unset the
// return is the INPUT STRING UNCHANGED — the default path stays byte-for-byte
// identical, which is the repo's bar for touching a request-shaping function.
//
// The operator prompt goes LAST deliberately: a server-wide directive ("answer in
// Chinese") should read as the final instruction, not something a client system
// message overrides by coming later. Whitespace-only values are treated as unset —
// a stray space in a service unit's Environment= line must not inject "\n\n " into
// every request.
export function appendOperatorPrompt(base, operatorAppend) {
const op = typeof operatorAppend === "string" ? operatorAppend.trim() : "";
return op ? `${base}\n\n${op}` : base;
}
// Derive the default prompt-char budget from the models.json SPOT (ADR 0009).
//
// The old default was a hand-set constant (150000 chars ≈ 37.5k English tokens) from the
// 200k-window era — silently far below what the advertised contextWindow promises. Instead
// of picking a new constant that will also rot, the default now FOLLOWS the SPOT:
//
// budget = max(models[].contextWindow) × charsPerToken
//
// charsPerToken = 3 is deliberately conservative: English runs ~4 chars/token, CJK ~11.5.
// At ×3, a 200k-token window yields 600,000 chars — full window for English, and CJK text
// hits the model's real window at roughly the same point the cap fires, so we truncate
// (graceful, tail-first) rather than let the upstream reject the request outright.
//
// The floor guards the degenerate cases (empty/missing models[], absent contextWindow):
// fall back to the historical constant rather than 0 — a zero budget would truncate every
// request to nothing, which is fail-OPEN in the "serve garbage" sense. CLAUDE_MAX_PROMPT_CHARS
// remains an absolute operator override at the call site (server.mjs); this function is only
// the unset-env default.
export function derivePromptCharBudget(models, { charsPerToken = 3, floor = 150000 } = {}) {
const windows = (Array.isArray(models) ? models : [])
.map(m => m?.contextWindow)
.filter(w => Number.isFinite(w) && w > 0);
if (windows.length === 0) return floor;
return Math.max(floor, Math.max(...windows) * charsPerToken);
}
// Resolve the effective budget from the env var + SPOT. TRUTHINESS (not != null) on the env
// value deliberately: an EMPTY value ("CLAUDE_MAX_PROMPT_CHARS=" in a systemd EnvironmentFile
// or .env) must mean "use the default" — exactly the old `parseInt(env || "150000")` contract.
// Treating "" as explicit gives parseInt("") = NaN, and a NaN cap silently DISABLES the
// runaway-context guard while injecting a false "[System] Note: 0 older messages were
// truncated" line into every prompt (caught in PR #179 review). Non-empty garbage still
// parses to NaN — the pre-existing class, slated for parseIntEnv routing in PR #154.
export function resolvePromptCharBudget(rawEnv, models, opts) {
return rawEnv ? parseInt(rawEnv, 10) : derivePromptCharBudget(models, opts);
}
// OCP_LOCAL_TOOLS system-prompt wrapper selection (pure).
//
// OCP's `-p` path prepends a fixed wrapper to every request's system prompt. The DEFAULT wrapper
// tells the model it has NO local filesystem/shell/env access — the right posture for a shared or
// multi-tenant gateway. But a single-user, loopback-bound instance (e.g. an OpenClaw agent talking
// to its own local OCP) DOES legitimately have tools — the `-p` path already passes `--allowedTools`
// and the CLI's built-in tools are available — so the default wrapper actively gaslights the model
// into refusing to use tools it holds. `OCP_LOCAL_TOOLS=1` swaps in a positive wrapper for that case.
//
// This does NOT expand the tool surface: tools are governed solely by `--allowedTools` /
// `--disallowedTools` (multi-tenant mode `--disallowedTools` the whole FS surface regardless of the
// wrapper). It only changes the PROMPT the operator's own model reads. Pure so it is unit-testable.
export function selectPromptWrapper(localToolsEnabled, negativeWrapper, positiveWrapper) {
return localToolsEnabled ? positiveWrapper : negativeWrapper;
}
// Boot-time safety gate for OCP_LOCAL_TOOLS, mirroring the OCP_TUI_FULL_TOOLS model (ADR 0007): a
// positive "you may use local tools" wrapper must never reach an untrusted caller. Returns a fatal
// message string when the flag is enabled in an unsafe deployment, or null when it is safe/disabled.
// Fail-closed: any of multi-tenant auth, a non-loopback bind, or an anonymous key is refused. Pure —
// the caller does the process.exit so this stays testable.
export function localToolsSafetyError({ enabled, authMode, loopbackBind, anonymousKey }) {
if (!enabled) return null;
if (authMode === "multi") {
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.";
}
return null;
}
+67
View File
@@ -0,0 +1,67 @@
// Pure, dependency-injected primitives for the `-p` spawn-token resolution + HOME-isolation
// layer. Extracted from server.mjs (findings F3 / F5 / F6, 2026-07-07) so the concurrency,
// caching and expiry logic is unit-testable WITHOUT booting the server or mocking execFileSync /
// child_process.spawn / fs. server.mjs owns all I/O (macOS keychain exec, process spawn, fs);
// this module owns only pure decision logic.
//
// ALIGNMENT NOTE: none of this touches the OAuth wire machinery (no endpoint / header / body).
// OCP still NEVER performs a refresh_token grant itself — these helpers only READ + GATE a token
// that some other process (the operator's real claude, or a spawned claude under the real HOME)
// refreshes. That property is load-bearing (issue #112) and preserved.
// Promise-chain mutex. `acquire()` resolves to a `release()` fn; the NEXT `acquire()` does not
// resolve until the current holder calls its `release()`. Serializes async critical sections
// without busy-waiting. release() is idempotent.
export function createSerialMutex() {
let tail = Promise.resolve();
return {
acquire() {
let release;
const gate = new Promise((r) => { release = r; });
const prev = tail;
tail = tail.then(() => gate);
// Hand the caller its release fn only after the previous holder has released.
return prev.then(() => {
let released = false;
return function releaseMutex() { if (!released) { released = true; release(); } };
});
},
};
}
// Short-TTL memo. `get(produce, now)` returns the cached value while `now - storedAt < ttlMs`,
// otherwise calls `produce()` and re-stores. A miss that produces null/undefined is STILL stored
// (so a genuinely-absent source is not re-probed on every call within the TTL window). `now` is
// injectable for testing.
export function createTtlCache({ ttlMs }) {
let value;
let at = -Infinity;
let has = false;
return {
get(produce, now = Date.now()) {
if (has && now - at < ttlMs) return value;
value = produce();
at = now;
has = true;
return value;
},
clear() { has = false; value = undefined; at = -Infinity; },
};
}
// Pure expiry gate. Returns true when `creds` carries a known expiry that is at/within `bufferMs`
// of `now`. Creds WITHOUT `expiresAt` (e.g. long-lived env tokens) are never treated as expiring.
// This gate is applied to the CACHED creds on EVERY use — which is precisely why a short-TTL
// keychain cache (createTtlCache) cannot reintroduce the #146 forever-stale-token regression: the
// cache bounds how often we re-READ the keychain, but the expiry decision is recomputed per use.
export function isTokenExpiring(creds, now = Date.now(), bufferMs = 300000) {
return !!(creds && creds.expiresAt && now + bufferMs >= creds.expiresAt);
}
// Order candidate keychain labels so the last-known-good label is tried first (avoids the
// wrong-label miss that doubles the `security` exec count on the hot path). Pure: performs no
// read. Returns a fresh array; input is not mutated.
export function orderLabelsLastGoodFirst(labels, lastGood) {
if (!lastGood || !labels.includes(lastGood)) return labels.slice();
return [lastGood, ...labels.filter((l) => l !== lastGood)];
}
+318
View File
@@ -0,0 +1,318 @@
// ── OpenAI Structured Outputs (response_format) — pure helpers ───────────────
//
// OCP's `/v1/chat/completions` (Class B.1, ADR 0006) advertises OpenAI compatibility but forwards
// to `claude -p`, which has no native `response_format`. Asked for JSON, the coding-assistant CLI
// typically replies with prose, a Markdown table, or a ```json fenced block — none of which is
// `JSON.parse`-able. These helpers implement the OpenAI `response_format` contract on top of that:
// detect the request, build a strict JSON-only steering instruction, then extract and validate the
// JSON the model returns. All functions here are pure (no I/O) so they are unit-tested directly;
// the retry loop that calls the model lives in server.mjs (runStructuredCompletion).
//
// Spec authority (B.1, ADR 0006): OpenAI chat/completions `response_format`
// https://platform.openai.com/docs/api-reference/chat/create#chat-create-response_format
// No field or behaviour beyond that published shape is introduced.
export class StructuredOutputError extends Error {
constructor(reason, raw) {
super(`structured output could not be produced: ${reason}`);
this.name = "StructuredOutputError";
this.reason = reason;
this.raw = raw;
}
}
// Fail-closed parse of the OCP_STRUCTURED_MAX_ATTEMPTS retry cap. `Math.max(1, parseInt("abc",10))`
// === `Math.max(1, NaN)` === NaN, and a retry loop bounded by `attempt < NaN` never runs → 0 spawns,
// every structured request silently refuses. So any non-integer / non-finite / <1 value keeps the
// documented default instead (and warns), rather than bricking the feature. (PR #153 review round 2.)
export function resolveMaxAttempts(raw, { fallback = 3, warn } = {}) {
if (raw === undefined || raw === null || raw === "") return fallback;
const n = parseInt(raw, 10);
if (!Number.isFinite(n) || n < 1) {
if (typeof warn === "function") {
warn(`Ignoring invalid OCP_STRUCTURED_MAX_ATTEMPTS="${raw}" (want integer >= 1); using default ${fallback}.`);
}
return fallback;
}
return n;
}
// Returns { mode: "schema", schema, name?, strict } | { mode: "json_object" } | null.
// Supports the OpenAI shapes: response_format:{type:"json_schema",json_schema:{schema,strict,name}}
// and response_format:{type:"json_object"}, a lenient response_format:{schema} fallback, and the
// widely-used (non-standard) top-level `json_mode: true` flag as a json_object alias.
export function detectStructuredOutput(parsed) {
const rf = parsed?.response_format;
if (rf && typeof rf === "object") {
if (rf.type === "json_schema") {
const js = (rf.json_schema && typeof rf.json_schema === "object") ? rf.json_schema : {};
const schema = js.schema || rf.schema || null;
return { mode: "schema", schema, name: js.name, strict: js.strict === true };
}
if (rf.type === "json_object") return { mode: "json_object" };
if (rf.schema && typeof rf.schema === "object") {
return { mode: "schema", schema: rf.schema, strict: rf.strict === true };
}
}
// Non-standard convenience alias honored by several OpenAI-compatible clients.
if (parsed?.json_mode === true) return { mode: "json_object" };
return null;
}
export function jsonTypeOf(v) {
if (v === null) return "null";
if (Array.isArray(v)) return "array";
return typeof v; // object | string | number | boolean
}
export function jsonTypeMatches(t, value) {
switch (t) {
case "string": return typeof value === "string";
case "number": return typeof value === "number" && Number.isFinite(value);
case "integer": return typeof value === "number" && Number.isInteger(value);
case "boolean": return typeof value === "boolean";
case "object": return value !== null && typeof value === "object" && !Array.isArray(value);
case "array": return Array.isArray(value);
case "null": return value === null;
default: return true; // unknown type keyword → do not fail on it
}
}
const jsonDeepEqual = (a, b) => JSON.stringify(a) === JSON.stringify(b);
// Resolve a local JSON-Pointer `$ref` (e.g. "#/$defs/Step" or "#/definitions/Step") against the
// document root. Only same-document refs are supported (that is all the OpenAI SDK emits); a remote
// or unresolvable ref returns null and the caller skips validation for it rather than failing.
function resolveRef(ref, root) {
if (typeof ref !== "string" || !ref.startsWith("#/") || !root) return null;
const parts = ref.slice(2).split("/").map(p => p.replace(/~1/g, "/").replace(/~0/g, "~"));
let cur = root;
for (const p of parts) {
if (cur && typeof cur === "object" && Object.prototype.hasOwnProperty.call(cur, p)) cur = cur[p];
else return null;
}
return (cur && typeof cur === "object") ? cur : null;
}
// Minimal JSON-Schema validator: covers the subset OpenAI structured outputs use — type (incl.
// arrays of types / integer), required, properties, additionalProperties (no invented keys), items
// (list + tuple), enum, const, nullability (type:["x","null"] or nullable:true), min/maxItems, and
// $ref/$defs + allOf/anyOf/oneOf composition (which the official OpenAI SDK emits heavily via
// zodResponseFormat / client.beta.chat.completions.parse). `root` carries the top-level schema so
// same-document $refs resolve. Returns error strings ([] = valid).
// `refChain` tracks 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 is a pure ref→ref (or ref→composition→ref) cycle that recurses forever independent of
// the data — we fail closed on it. Data-consuming recursion (properties/items/additionalProperties)
// deliberately resets the chain (default []): it always terminates because a JSON value is a finite
// tree, so a legitimately recursive schema (Node→child:Node) must NOT be flagged as a cycle. A depth
// cap backstops any threading mistake. (PR #153 review round 2, cyclic-$ref blocker.)
const REF_DEPTH_CAP = 512;
export function validateJsonSchema(value, schema, path = "$", strict = false, root = schema, refChain = []) {
const errors = [];
if (!schema || typeof schema !== "object") return errors;
if (refChain.length > REF_DEPTH_CAP) { // defensive backstop; refChain cycle-check below is primary
errors.push(`${path}: $ref resolution too deep (possible cycle)`);
return errors;
}
// $ref: resolve against the document root ($defs / definitions) and validate the target. Without
// this a nested {$ref:"#/$defs/Step"} presents as {no type, no properties} — and under strict that
// used to wrongly reject every real key as "additional property not allowed" (the flagship OpenAI
// SDK shape). Sibling keywords alongside $ref (rare) are merged over the resolved target.
if (typeof schema.$ref === "string") {
if (refChain.includes(schema.$ref)) { // cyclic $ref (a→b→a, or self a→a) — fail closed.
errors.push(`${path}: cyclic $ref detected (${schema.$ref})`);
return errors;
}
const resolved = resolveRef(schema.$ref, root);
if (!resolved) return errors; // unresolvable ref → cannot validate; do not fail
const { $ref, ...siblings } = schema;
return validateJsonSchema(value, { ...resolved, ...siblings }, path, strict, root, [...refChain, schema.$ref]);
}
// Composition. allOf: every branch must pass. anyOf: at least one. oneOf: exactly one.
// These re-validate the SAME value → thread refChain so a ref cycle routed through a branch is caught.
if (Array.isArray(schema.allOf)) {
for (const sub of schema.allOf) errors.push(...validateJsonSchema(value, sub, path, strict, root, refChain));
}
if (Array.isArray(schema.anyOf)) {
if (!schema.anyOf.some(sub => validateJsonSchema(value, sub, path, strict, root, refChain).length === 0)) {
errors.push(`${path}: does not match any of the allowed schemas (anyOf)`);
}
}
if (Array.isArray(schema.oneOf)) {
const matches = schema.oneOf.filter(sub => validateJsonSchema(value, sub, path, strict, root, refChain).length === 0).length;
if (matches !== 1) errors.push(`${path}: must match exactly one allowed schema (oneOf), matched ${matches}`);
}
// Nullability takes precedence: a null value is valid whenever the schema permits null (its type
// union includes "null", or nullable:true), regardless of enum/const. This mirrors OpenAI
// structured-output behaviour — nullable fields accept null even when a bare enum (as generated by
// Home Assistant's extended_openai_conversation) omits null from its value list.
const allowsNull = schema.nullable === true
|| (Array.isArray(schema.type) ? schema.type.includes("null") : schema.type === "null");
if (value === null && allowsNull) return errors;
if (Array.isArray(schema.enum) && !schema.enum.some(e => jsonDeepEqual(e, value))) {
errors.push(`${path}: not one of the allowed enum values`);
}
if ("const" in schema && !jsonDeepEqual(schema.const, value)) {
errors.push(`${path}: does not equal the required const value`);
}
if (schema.type !== undefined) {
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
const nullable = schema.nullable === true || types.includes("null");
const ok = types.some(t => jsonTypeMatches(t, value)) || (value === null && nullable);
if (!ok) {
errors.push(`${path}: expected ${types.join("|")}${schema.nullable ? "|null" : ""}, got ${jsonTypeOf(value)}`);
return errors; // type mismatch — deeper checks are meaningless
}
}
if (value === null) return errors;
const vt = jsonTypeOf(value);
if (vt === "object") {
const props = schema.properties || {};
for (const r of (schema.required || [])) {
if (!Object.prototype.hasOwnProperty.call(value, r)) errors.push(`${path}.${r}: required property missing`);
}
const addl = schema.additionalProperties;
// Only treat strict as implying "no additional properties" when this object actually declares
// its own `properties` and is NOT a composite (allOf/anyOf/oneOf put the real keys in sub-schemas,
// which are validated separately above). Inferring closure from an EMPTY properties map — the
// shape an unresolved $ref or a pure-composition node presents — would reject every real key.
// An explicit additionalProperties:false is always honoured. (PR #153 review, finding 1.)
const isComposite = Array.isArray(schema.allOf) || Array.isArray(schema.anyOf) || Array.isArray(schema.oneOf);
const noExtra = addl === false || (strict && addl === undefined && Object.keys(props).length > 0 && !isComposite);
for (const k of Object.keys(value)) {
if (props[k]) {
errors.push(...validateJsonSchema(value[k], props[k], `${path}.${k}`, strict, root));
} else if (isComposite) {
// key may be defined in an allOf/anyOf/oneOf branch — already validated there; don't reject.
} else if (noExtra) {
errors.push(`${path}.${k}: additional property not allowed`);
} else if (addl && typeof addl === "object") {
errors.push(...validateJsonSchema(value[k], addl, `${path}.${k}`, strict, root));
}
}
} else if (vt === "array" && schema.items) {
if (Array.isArray(schema.items)) {
schema.items.forEach((s, i) => { if (i < value.length) errors.push(...validateJsonSchema(value[i], s, `${path}[${i}]`, strict, root)); });
} else {
value.forEach((item, i) => errors.push(...validateJsonSchema(item, schema.items, `${path}[${i}]`, strict, root)));
}
if (typeof schema.minItems === "number" && value.length < schema.minItems) errors.push(`${path}: fewer items than minItems ${schema.minItems}`);
if (typeof schema.maxItems === "number" && value.length > schema.maxItems) errors.push(`${path}: more items than maxItems ${schema.maxItems}`);
}
return errors;
}
// Crash-safe façade over validateJsonSchema (issue #181). The validator recurses on the DATA's
// nesting depth (properties/items/additionalProperties), which the REF_DEPTH_CAP does NOT bound —
// only the ref-chain is. A model reply nested ~2000 levels deep therefore overflowed the stack with
// a RangeError, which the request handler caught as a generic HTTP 500 instead of the spec-correct
// `refusal`. This wrapper converts ANY throw (the deep-data RangeError, or any future recursion
// hazard) into a single validation error, so the structured-output retry loop treats a pathological
// reply as "did not validate" → refusal — never a 500, never a crash. A well-formed reply is
// unaffected: the inner validator returns and this just passes its errors through.
export function validateJsonSchemaSafe(value, schema, path = "$", strict = false, root = schema) {
try {
return validateJsonSchema(value, schema, path, strict, root);
} catch (e) {
// Catch ONLY the deep-nesting stack overflow (the #181 vector) and turn it into a validation
// miss → retry → refusal, never a 500. Any OTHER throw is a genuine bug: re-throw it so it
// surfaces at error level instead of being silently masked as "did not validate" (reviewer
// finding — a catch-all would hide a future TypeError behind a warn-level structured_retry).
if (e instanceof RangeError) return [`${path}: schema validation aborted (value nesting too deep)`];
throw e;
}
}
function tryJsonParse(s) {
try { return { ok: true, value: JSON.parse(s) }; } catch { return { ok: false }; }
}
// Find the next brace-balanced JSON span at or after `from`. String-aware: brackets inside quoted
// strings are ignored. Returns { text, start, end } for the first complete top-level span, or null.
function balancedSlice(s, from) {
let start = -1;
for (let i = from; i < s.length; i++) { if (s[i] === "{" || s[i] === "[") { start = i; break; } }
if (start === -1) return null;
let depth = 0, inStr = false, esc = false;
for (let i = start; i < s.length; i++) {
const c = s[i];
if (inStr) {
if (esc) esc = false;
else if (c === "\\") esc = true;
else if (c === '"') inStr = false;
continue;
}
if (c === '"') { inStr = true; continue; }
if (c === "{" || c === "[") depth++;
else if (c === "}" || c === "]") { depth--; if (depth === 0) return { text: s.slice(start, i + 1), start, end: i }; }
}
return null;
}
// Extract a single JSON value from model text. Two modes (PR #153 review, finding 2):
//
// opts.whole === true (json_object mode): the ENTIRE reply, after trimming and stripping one code
// fence, must parse as a single JSON value. json_object has no schema to validate against, so
// this whole-reply parse is its ONLY guard — a reply like `I can't. The schema is {"type":"object"}`
// must NOT parse-and-serve the embedded object as if it were the answer.
//
// default (schema mode): prose-wrapped JSON is tolerated (models often add a sentence), BUT a reply
// containing MORE THAN ONE top-level JSON value is rejected rather than silently serving the first
// — "Schema: {...}\n\nAnswer: {...}" or "Option A: {...} Option B: {...}" is ambiguous, not an
// answer. The extracted value is still schema-validated by the caller.
//
// Returns { ok:true, value } | { ok:false, reason? }.
export function extractJsonPayload(text, opts = {}) {
if (typeof text !== "string") return { ok: false };
let s = text.trim();
const fence = s.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
if (fence) s = fence[1].trim();
if (opts.whole) {
const whole = tryJsonParse(s);
return whole.ok ? whole : { ok: false, reason: "reply was not a single JSON value" };
}
const direct = tryJsonParse(s);
if (direct.ok) return direct;
const first = balancedSlice(s, 0);
if (!first) return { ok: false };
const parsedFirst = tryJsonParse(first.text);
if (!parsedFirst.ok) return { ok: false };
// Reject ambiguity: a second parseable top-level JSON value means we cannot know which is the answer.
const second = balancedSlice(s, first.end + 1);
if (second && tryJsonParse(second.text).ok) {
return { ok: false, reason: "reply contained more than one JSON value" };
}
return parsedFirst;
}
// The strict JSON-only system instruction appended to the request (attempt 0), escalated with the
// prior failure reason on retries.
export function structuredSystemInstruction(structured, attempt, lastErr) {
const schemaBlock = (structured.mode === "schema" && structured.schema)
? `Your JSON MUST validate EXACTLY against this JSON Schema:\n${JSON.stringify(structured.schema)}\n`
+ `- Include every required property.\n`
+ `- Do NOT add any property that is not defined in the schema.\n`
+ `- Respect all declared types, enums, and nullability.`
: `Respond with a single valid JSON value.`;
let text =
`You are a strict JSON generator. Output a SINGLE JSON value and NOTHING else.
- The response MUST begin with { or [ and end with the matching } or ].
- Do NOT wrap the JSON in Markdown or code fences (no \`\`\`).
- Do NOT include any prose, explanation, heading, comment, reasoning, or XML — only the raw JSON.
${schemaBlock}`;
if (attempt > 0) {
text = `YOUR PREVIOUS RESPONSE WAS REJECTED (${lastErr}). Output ONLY the corrected raw JSON now, with no other text.\n\n` + text;
}
return text;
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
{"type":"user","entrypoint":"cli","cwd":"/tmp/tui-test","sessionId":"bbbb2222-3333-4444-5555-666677778888","version":"2.1.104","message":{"role":"user","content":[{"type":"text","text":"What is 2 + 2?"}]}}
{"type":"assistant","entrypoint":"cli","cwd":"/tmp/tui-test","sessionId":"bbbb2222-3333-4444-5555-666677778888","version":"2.1.104","message":{"role":"assistant","model":"claude-haiku-4-5-20251001","stop_reason":"end_turn","content":[{"type":"text","text":"Failed to authenticate. API Error: 401 Invalid authentication credentials"}]}}
+2
View File
@@ -0,0 +1,2 @@
{"type":"user","entrypoint":"cli","cwd":"/tmp/tui-test","sessionId":"aaaa1111-2222-3333-4444-555566667777","version":"2.1.104","message":{"role":"user","content":[{"type":"text","text":"What is 2 + 2?"}]}}
{"type":"assistant","entrypoint":"cli","cwd":"/tmp/tui-test","sessionId":"aaaa1111-2222-3333-4444-555566667777","version":"2.1.104","message":{"role":"assistant","model":"claude-haiku-4-5-20251001","stop_reason":"end_turn","content":[{"type":"text","text":"Please run /login · API Error: 401 Invalid authentication credentials"}]}}
+2
View File
@@ -0,0 +1,2 @@
{"type":"user","entrypoint":"cli","cwd":"/tmp/tui-test","sessionId":"bbbb1111-2222-3333-4444-555566667777","version":"2.1.104","message":{"role":"user","content":[{"type":"text","text":"Say PONG and nothing else."}]}}
{"type":"assistant","entrypoint":"cli","cwd":"/tmp/tui-test","sessionId":"bbbb1111-2222-3333-4444-555566667777","version":"2.1.104","message":{"role":"assistant","model":"claude-haiku-4-5-20251001","stop_reason":"end_turn","content":[{"type":"text","text":"PONG"}]}}
+321
View File
@@ -0,0 +1,321 @@
import { rmSync } from "node:fs";
// TUI warm pane pool (docs/plans/2026-07-13-tui-latency backlog #3).
//
// WHAT IT IS: a small set of PRE-BOOTED `claude` panes, each already sitting at its
// input bar, so a request does not pay the cold boot. Opt-in: OCP_TUI_POOL_SIZE=0
// (default) disables it entirely and the request path is byte-for-byte today's.
//
// ── SINGLE-USE IS THE LOAD-BEARING RULE ─────────────────────────────────────
// A pooled pane serves EXACTLY ONE turn and is then killed and replaced in the
// background. Each pane carries its OWN fresh `--session-id`, fixed at boot, and the
// turn locates its transcript by that id. So OCP's one-session-per-request model is
// preserved: a session's transcript still holds exactly one logical exchange.
// That is what keeps lib/tui/transcript.mjs's extractLatestAssistantText (which returns
// the LAST text-bearing assistant entry in the whole file, not "text since the matching
// user line") correct — see the scoping note there. A pane MUST NEVER serve a second
// turn, and a session MUST NEVER be reset with /clear and reused: either would put two
// exchanges in one transcript and leak the earlier turn's text into the later turn's
// answer. Nothing here reuses a pane; keep it that way.
//
// ── WHY IT'S WORTH MORE THAN THE BOOT TIME ──────────────────────────────────
// Measured on this host (n=6 through OCP, Sonnet 4.6, --effort low): the cold path
// spends ~1.23 s reaching the input bar, but ALSO ~2.9 s inside the first turn beyond
// what claude itself reports as the turn duration — post-input-bar init that a pane
// which has been idle for a few seconds has already finished. A warm pane recovers both.
//
// ── COST (bounded, and paid whether or not a request arrives) ───────────────
// Each warm pane is a LIVE `claude` process (plus its tmux pane) sitting idle. Peak
// process count is (pool size) + (OCP_TUI_MAX_CONCURRENT in-flight turns) + (panes
// currently booting as replacements). Pool size is clamped to POOL_MAX_SIZE.
//
// Pure + injectable (bootPane / killPane / paneHealthy / now) so test-features.mjs can
// assert acquire / miss / refill / TTL / reaper-exemption with no tmux and no claude.
// Hard cap on OCP_TUI_POOL_SIZE. Each pane is an idle claude process; 4 is already a
// lot of resident memory on a small host (a Pi serving a family) for zero in-flight work.
export const POOL_MAX_SIZE = 4;
// A warm pane older than this is dropped on acquire rather than handed out. The periodic
// reap tick (server.mjs) drains the pool every 15 min anyway, so this only bites when
// that tick kept getting skipped because the TUI path was never idle. Guards against
// handing out a pane whose `claude` has been sitting so long it may have drifted
// (auto-compaction prompts, an idle-disconnect banner, an expired in-pane token).
export const POOL_MAX_AGE_MS = 10 * 60 * 1000;
// Clamp the operator-supplied size into [0, POOL_MAX_SIZE]. A garbage value disables the
// pool rather than guessing — an unparseable size must never silently boot 4 processes.
export function resolvePoolSize(raw) {
const n = parseInt(raw, 10);
if (!Number.isFinite(n) || n <= 0) return 0;
return Math.min(n, POOL_MAX_SIZE);
}
export class TuiPanePool {
// size: target number of warm panes (0 = disabled).
// maxAgeMs: per-pane TTL (see POOL_MAX_AGE_MS).
// mintPane: () => ({ sessionId, name }) — mints the identity of the NEXT pane. The POOL,
// not the boot function, owns this: the tmux session springs into existence the
// instant bootPane starts, so the pool must already know its NAME (see
// _bootingPane below). Deriving the name from the sessionId also makes `tmux ls`
// correlate to the transcript file.
// bootPane: async (model, {sessionId, name}) => { name, sessionId, model, bootedAt } —
// boots ONE pane under exactly that identity and resolves only once it is
// input-ready; throws if it never becomes ready.
// killPane: (name) => void — tmux kill-session. MUST be synchronous (see drain).
// paneHealthy:(name) => bool — pane still exists AND is still at its input bar.
constructor({ size, maxAgeMs = POOL_MAX_AGE_MS, mintPane, bootPane, killPane, paneHealthy, now = Date.now, log = () => {} }) {
this.size = Math.max(0, Math.min(parseInt(size, 10) || 0, POOL_MAX_SIZE));
// Fail fast at CONSTRUCTION, not at request time. refill() is called synchronously from
// the request path (runTuiTurn), so a missing collaborator would otherwise surface as a
// 500 on a live request instead of a loud error at boot.
if (this.size > 0) {
for (const [k, fn] of [["mintPane", mintPane], ["bootPane", bootPane], ["killPane", killPane], ["paneHealthy", paneHealthy]]) {
if (typeof fn !== "function") throw new TypeError(`TuiPanePool: ${k} must be a function`);
}
}
this.maxAgeMs = maxAgeMs;
this._mintPane = mintPane;
this._bootPane = bootPane;
this._killPane = killPane;
this._paneHealthy = paneHealthy;
this._now = now;
this._log = log;
this._panes = []; // warm, available panes: { name, sessionId, model, bootedAt }
// The pane currently BOOTING, BY NAME ({sessionId, name, model}) — or null.
//
// WHY A NAME AND NOT A COUNT (this is a fixed bug, don't regress it): bootTuiPane creates
// the tmux session SYNCHRONOUSLY and only THEN waits up to POOL_BOOT_MS (20 s) for the
// input bar. So for up to 20 s there is a LIVE pooled tmux session. When the pool tracked
// only a count, it could not NAME that session, so:
// - liveNames() could not spare it and the periodic reap sweep KILLED it (and
// kill-server'd on top), leaving the pool empty with nothing scheduled and firing the
// very tui_pool_boot_failed WARN operators are told to alert on; and
// - drain() could not kill it, so on shutdown it ORPHANED a live authenticated `claude`
// (the boot's .then that was supposed to clean up never runs — gracefulShutdown calls
// process.exit in the same tick).
// Both are fixed by holding the identity here, before the session exists.
this._bootingPane = null;
// Generation counter. Bumped whenever an in-flight boot is CANCELLED (drain / model
// switch). A boot compares the generation it started under against the current one:
// if they differ, its pane was already killed by us and its settle is inert — in
// particular a rejection is a CANCELLATION, not an operator-visible boot failure.
this._gen = 0;
this._paused = false; // true while drained; refill() is a no-op until resume()
this.warmModel = null; // the model the pool currently warms — learned from traffic (see acquire)
this.hits = 0; // requests served by a warm pane
this.misses = 0; // requests that fell back to the cold path
this.boots = 0; // panes successfully pre-booted
this.bootFailures = 0; // pre-boots that genuinely never reached the input bar
this.cancelled = 0; // in-flight boots WE killed (drain / model switch) — not failures
this.dropped = 0; // panes discarded unused (unhealthy / expired / wrong model / drained /
// cancelled — a cancelled in-flight boot also lands here via _drop)
}
get enabled() { return this.size > 0; }
get warm() { return this._panes.length; }
get booting() { return this._bootingPane ? 1 : 0; }
// The reaper's spare set: the EXACT names of every pane the pool currently owns and has NOT
// handed out — the warm ones AND the one currently booting (whose tmux session is already
// live; see _bootingPane). See the POOL/REAPER INVARIANT in lib/tui/session.mjs.
// Fail-safe by construction: a pane leaves this set the instant it is acquired, dropped, or
// cancelled, and if the pool is empty (or the process restarted) the set is empty — so an
// orphaned pooled pane looks exactly like any other stale session and IS reaped.
liveNames() {
const names = new Set(this._panes.map((p) => p.name));
if (this._bootingPane) names.add(this._bootingPane.name);
return names;
}
// Take a warm pane for `model`, or null (caller must fall back to the cold path — a MISS
// is always safe, never an error). Synchronous: paneHealthy is a cheap tmux capture.
//
// The pool warms the MOST RECENTLY REQUESTED model (`warmModel`). There is no boot-time
// pre-warm and no configured model: OCP cannot know which model the next caller wants, and
// pre-booting a process for a model nobody asks for is pure waste. Consequence, stated
// plainly: the FIRST request after start (and the first after a model switch) is always a
// MISS. The pool pays off for the steady repeat traffic it exists to serve.
acquire(model) {
if (!this.enabled) return null;
// Retarget on a model switch: --model is fixed at spawn, so panes for another model are
// useless. Drop them now (they are replaced by the next refill) rather than holding
// processes for a model that is no longer being asked for. This includes any pane
// currently BOOTING for the old model — its tmux session already exists, so leaving it to
// die on resolve would both hold a useless process and block the next refill (one boot at
// a time) for up to POOL_BOOT_MS.
if (model !== this.warmModel) {
for (const p of this._panes) { this._drop(p, "model_switch"); }
this._panes = [];
this._cancelBooting("model_switch");
this.warmModel = model;
}
while (this._panes.length) {
const p = this._panes.shift();
if (this._now() - p.bootedAt > this.maxAgeMs) { this._drop(p, "expired"); continue; }
if (!this._paneHealthy(p.name)) { this._drop(p, "unhealthy"); continue; }
this.hits++;
return p; // caller OWNS it now: it is out of the registry (so out of the spare set),
// and the caller's finally MUST kill it. Single-use — never returned here.
}
this.misses++;
return null;
}
// Bring the pool back up to `size` warm panes for `warmModel`. Fire-and-forget: never
// awaited on the request path and never throws into it.
//
// SLOT ACCOUNTING: a refill boot deliberately does NOT take a TuiSemaphore slot. Those
// slots bound concurrent *turns* (each up to the 120 s wallclock) and belong to real
// requests; charging a background pre-boot against them would let the pool starve the
// traffic it exists to speed up. It cannot leak a slot either, because it never holds one.
//
// SERIALIZED, ONE BOOT AT A TIME (and re-kicked on success until the pool is at target).
// An earlier version launched all `want` boots at once; live at size=2 that put two cold
// `claude` boots plus an in-flight turn on the CPU together, and a refill overran even the
// generous pool readiness cap (tui_pool_boot_failed). Booting sequentially keeps each boot
// near its uncontended ~1.2 s, bounds the CPU burst the pool can cause, and still has the
// replacement pane warm long before the next request arrives.
//
// A genuinely FAILED boot deliberately does NOT re-kick the chain — that is the backoff. A
// persistently failing boot (bad claude binary, no auth) would otherwise spin, respawning
// forever. The next natural trigger (the following request's refill, or the reap tick's
// resume) retries it. A CANCELLED boot is different: we killed it on purpose, nothing is
// wrong, and resume() is expected to start a fresh one immediately.
refill() {
if (!this.enabled || this._paused || !this.warmModel) return;
if (this._bootingPane) return; // one boot in flight at a time
if (this._panes.length >= this.size) return; // already at target
const model = this.warmModel;
const gen = this._gen;
// Mint the identity BEFORE booting: bootPane creates the tmux session synchronously, so
// the pool must be able to name (and therefore spare, and kill) it from this moment on.
const ident = this._mintPane();
this._bootingPane = { ...ident, model };
let enlisted = false;
Promise.resolve()
.then(() => this._bootPane(model, ident))
.then((pane) => {
// The world may have moved while we booted. If our generation was cancelled, kill the
// pane here rather than ASSUMING _cancelBooting already did.
//
// Why not just `return`: _cancelBooting kills by name, but the tmux session only EXISTS
// once _bootPane has actually run — and _bootPane is queued on a microtask (above). A
// caller that does refill() and then drain() in the SAME synchronous block would have
// _cancelBooting find nothing to kill (a no-op), bump the generation, and then this
// microtask would create the session, boot it fine, and — under a bare `return` — walk
// away from a LIVE authenticated `claude` that nothing owns. That is M1b in a new costume.
// No current call site does that, so this is defense-in-depth, not a live bug — 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 would reach it.
//
// Killing an already-dead session is a harmless no-op (_drop swallows it), so this is
// idempotent whether or not _cancelBooting got there first.
if (gen !== this._gen) { this._drop(pane, "cancelled_late"); return; }
// Otherwise: still possible the pool filled or retargeted without a cancellation.
if (this._paused || model !== this.warmModel || this._panes.length >= this.size) {
this._drop(pane, "stale_boot");
return;
}
this._panes.push(pane);
this.boots++;
enlisted = true;
})
.catch((e) => {
// A rejection from a CANCELLED generation is not a fault: it is almost always
// "tui_pane_not_ready", thrown because WE killed the pane out from under the boot.
// Counting it as a bootFailure would fire the exact WARN operators are told to alert
// on, for a completely healthy drain. Stay silent — _cancelBooting already counted
// this as a cancellation, so do NOT count it again here.
if (gen !== this._gen) return;
this.bootFailures++;
this._log("warn", "tui_pool_boot_failed", { model, error: e && e.message });
})
.finally(() => {
// ONLY the current generation's boot owns the booting slot. A stale settle must not
// clear a slot that a newer boot (started by resume()) already holds.
if (gen === this._gen) this._bootingPane = null;
if (enlisted) this.refill(); // continue toward target, still one at a time
});
}
// Kill the in-flight boot's pane, SYNCHRONOUSLY, and invalidate its generation. Returns 1
// if there was one, else 0. The tmux session already exists (bootPane created it before it
// started waiting for readiness), so this is a real kill, not a cancellation flag.
_cancelBooting(reason) {
if (!this._bootingPane) return 0;
this._gen++; // the in-flight boot's settle is now inert
this._drop(this._bootingPane, reason); // synchronous kill-session
this._bootingPane = null;
this.cancelled++;
return 1;
}
// Kill every pane the pool owns — warm AND currently booting — and stop refilling. Returns
// how many were killed.
//
// Called (a) before the periodic reap sweep — reapStaleTuiSessions can only reap defunct
// `claude` zombies via kill-server, and kill-server is suppressed while any live pooled pane
// exists (including a booting one), so without this drain the pool would permanently disable
// zombie reaping; and (b) on graceful shutdown, so no pane outlives the process as an orphan.
//
// EVERY KILL HERE IS SYNCHRONOUS, and that is load-bearing. It is NOT safe to leave the
// booting pane to clean itself up on resolve: gracefulShutdown calls process.exit() in the
// same tick as this drain (TUI panes are children of the tmux SERVER, not of node, so
// node's activeProcesses set is empty on a TUI host and the "wait for children" path exits
// immediately). A .then()/.catch() scheduled here would never run, and the pane would
// survive as an orphaned, authenticated, idle `claude`.
drain() {
this._paused = true;
let n = this._panes.length;
for (const p of this._panes) this._drop(p, "drain");
this._panes = [];
n += this._cancelBooting("drain_booting");
return n;
}
// Undo drain() and start refilling again. Because drain() CANCELLED the in-flight boot
// (rather than leaving it pending), the booting slot is free and this really does start a
// fresh boot — the pool is never left empty with nothing scheduled.
resume() {
this._paused = false;
this.refill();
}
// /health surface (additive).
stats() {
return {
size: this.size,
warm: this._panes.length,
booting: this.booting,
model: this.warmModel,
hits: this.hits,
misses: this.misses,
boots: this.boots,
bootFailures: this.bootFailures,
cancelled: this.cancelled,
dropped: this.dropped,
};
}
_drop(pane, reason) {
this.dropped++;
try { this._killPane(pane.name); } catch { /* already gone */ }
// F5: every drop path (expired / unhealthy / model_switch / drain / cancelled_late /
// stale_boot) ends up here, and the reap tick drains the WHOLE pool on every tick — so
// without this, every warm pane's sink orphans in streamDir with no GC path (killPane only
// reaches the tmux session, never the pane's OWN files). Best-effort: pane.streamFile is
// undefined for a still-booting identity (the sink path is only known once bootPane
// resolves) and rmSync(force:true) is already a no-op on a missing file, so this never
// throws into the reaper regardless of which drop path got here.
if (pane.streamFile) {
try { rmSync(pane.streamFile, { force: true }); } catch { /* best-effort GC */ }
}
this._log("info", "tui_pool_pane_dropped", { name: pane.name, reason });
}
}
+192
View File
@@ -0,0 +1,192 @@
// TUI-path concurrency limiter (audit finding C-4).
//
// WHY THIS EXISTS, SEPARATE FROM server.mjs's MAX_CONCURRENT:
// The global MAX_CONCURRENT gate lives in spawnClaudeProcess() (the -p / stream-json
// path). callClaudeTui() NEVER calls spawnClaudeProcess — it calls runTuiTurn(), which
// boots a full interactive `claude` inside a fresh tmux session. So nothing bounded the
// TUI path: N concurrent TUI requests spawned N simultaneous cold-boot tmux+claude
// processes. On a small host (a Pi 4 serving a family) a burst of ~5 is an OOM risk, and
// it also multiplies subscription rate-limit pressure. This is an INDEPENDENT limiter for
// the TUI path that mirrors MAX_CONCURRENT's intent without coupling to it (the two pools
// are different shapes: a stream-json spawn is cheap and fast; a TUI turn is a heavy
// cold-boot + up to 120s wallclock).
//
// QUEUE vs REJECT: we QUEUE (await a slot), mirroring the spirit of MAX_CONCURRENT's
// intent not to drop requests, rather than rejecting immediately. To avoid unbounded
// memory growth from a runaway client, the wait queue itself is bounded by maxQueue
// (default: a generous multiple of the concurrency limit). When the queue is full, run()
// rejects with a tui_queue_full error (the caller surfaces it as a 503) — a deterministic
// backpressure signal rather than silent OOM.
//
// Pure + importable so test-features.mjs can assert the bound directly (no server boot).
// Thrown by acquire() when the caller-supplied AbortSignal fires before a slot was granted
// (audit finding F2 — a client that disconnects while queued must never receive a slot; the
// queue entry is spliced out, not just flagged, so `queued` accounting stays exact). Distinct
// `name` lets callers (server.mjs acquireClaudeSlot) tell "client went away" apart from
// "queue is full" without string-matching the message.
export class SemaphoreAbortError extends Error {
constructor(message) { super(message); this.name = "SemaphoreAbortError"; }
}
export class TuiSemaphore {
// limit: max concurrent slots. maxQueue: max waiters before run() rejects with backpressure.
constructor(limit, { maxQueue } = {}) {
this.limit = Math.max(1, parseInt(limit, 10) || 1);
// Default queue cap: 32× the limit. Large enough that real family-burst traffic never
// hits it, small enough that a pathological flood can't grow the queue without bound.
this.maxQueue = Number.isFinite(maxQueue) ? maxQueue : this.limit * 32;
this._inflight = 0;
this._waiters = []; // FIFO queue of resolve callbacks waiting for a slot
}
get inflight() { return this._inflight; }
get queued() { return this._waiters.length; }
// Runtime-adjust the concurrency limit (audit finding F1 — a PATCH /settings maxConcurrent
// change must actually take effect, not just be ignored until every currently-inflight task
// happens to finish). Lowering the limit is handled lazily by release() (see below) — it
// simply stops re-granting until inflight drains under the new, lower limit. Raising the
// limit has immediate headroom, so we wake as many queued waiters as now fit.
setLimit(limit) {
this.limit = Math.max(1, parseInt(limit, 10) || 1);
while (this._inflight < this.limit && this._waiters.length > 0) {
const next = this._waiters.shift();
this._inflight++;
next();
}
}
// Acquire a slot. Resolves once a slot is free (immediately if under the limit, otherwise
// when an in-flight task releases). Rejects synchronously-ish if the wait queue is full.
// `signal` (optional AbortSignal, F2) lets the caller cancel a QUEUED wait — e.g. wired to
// a client's socket "close" event so a request that disconnects before a slot is granted
// is removed from the queue instead of eventually being handed a slot for a dead socket.
// If `signal` is already aborted, reject immediately without ever touching the queue.
acquire(signal) {
if (signal?.aborted) {
return Promise.reject(new SemaphoreAbortError("acquire aborted before requesting a slot"));
}
if (this._inflight < this.limit) {
this._inflight++;
return Promise.resolve();
}
if (this._waiters.length >= this.maxQueue) {
return Promise.reject(new Error(
`tui_queue_full: TUI concurrency limit (${this.limit}) reached and wait queue ` +
`(${this.maxQueue}) is full`));
}
return new Promise((resolve, reject) => {
let waiter; // the FIFO entry — captured so onAbort can find + splice exactly this one
const onAbort = () => {
const idx = this._waiters.indexOf(waiter);
if (idx === -1) return; // already granted a slot (shifted out by release()/setLimit) — too late to cancel
this._waiters.splice(idx, 1); // remove, not just flag — keeps `queued` accounting exact
reject(new SemaphoreAbortError("acquire aborted while queued"));
};
waiter = () => {
signal?.removeEventListener("abort", onAbort);
resolve();
};
signal?.addEventListener("abort", onAbort, { once: true });
this._waiters.push(waiter);
});
}
// Release a slot. Always frees the caller's own slot first, then re-grants it to the next
// waiter ONLY if the (post-decrement) inflight count is still under the current limit (F1
// fix). This is what makes a runtime-lowered limit actually bite: if the limit was lowered
// while over-subscribed, releases stop re-granting and inflight drains toward the new limit
// instead of a freed slot being handed straight back out at the old, higher occupancy.
release() {
if (this._inflight > 0) this._inflight--;
if (this._inflight < this.limit) {
const next = this._waiters.shift();
if (next) {
this._inflight++;
next();
}
}
}
// Run fn() under one slot. Releases in a finally so a throw (PR-A's honesty gates,
// wallclock truncation, paste-not-landed, tmux spawn failure) NEVER leaks a slot.
// `signal` (optional, F2) is forwarded to acquire() so a queued run() can be cancelled.
async run(fn, signal) {
await this.acquire(signal);
try {
return await fn();
} finally {
this.release();
}
}
}
// ── TUI drift observability (audit C-5) — pure helpers, importable for testing ──
// Record an observed cc_entrypoint into the (mutable) tuiStats counter. Sets lastEntrypoint
// unconditionally and increments entrypointMismatches when the spawn was supposed to be
// subscription-pool ("cli") but the transcript reported something else (a silent drift to
// the metered Agent SDK pool — the audit's top risk after the 6/15 billing flip).
// Returns true iff this observation was a mismatch (so the caller can also emit a log).
export function recordTuiEntrypoint(tuiStats, observed, expectedMode = "cli") {
tuiStats.lastEntrypoint = observed ?? null;
const mismatch = expectedMode === "cli" && observed !== "cli";
if (mismatch) tuiStats.entrypointMismatches++;
return mismatch;
}
// Build the additive /health `tui` block (ADR 0007 PR-B amendment). Pure: given the
// config + live counters, returns the exact object embedded in /health. New fields only —
// behaviour-preserving for existing /health consumers (grandfathered B.2 under ADR 0006).
//
// `pool` (optional, warm pane pool — lib/tui/pool.mjs): a TuiPanePool, or null/undefined
// when the pool is off (the default). Reported as `pool: null` when off so the block's
// shape stays stable, and as the pool's stats (size / warm / hits / misses / …) when on —
// the operator's window onto both the hit rate and the standing idle-process cost.
//
// Streaming fields (backlog #2, OCP_TUI_STREAM) are ADDITIVE too:
// streamEnabled — is real (MessageDisplay-hook) SSE streaming on for TUI turns?
// streamTurns — streamed turns ATTEMPTED, counted before the truncation/auth-banner
// gates run (F6) — so a turn REFUSED by those gates still shows up
// here, which is exactly the turn an operator most wants visible.
// Counting only turns that survived the gates would silently exclude
// a turn's worst-case outcome from its own denominator.
// streamDeltas — MessageDisplay hook fires OBSERVED, including held-back ones (F6) —
// NOT only the ones forwarded to a client. This is what makes
// streamZeroDeltaTurns meaningful: a turn can have streamDeltas
// incrementing while still emitting nothing to the client (fully held
// back, e.g. a short answer), which is healthy, vs. a hook that fired
// zero times at all, which is not (see streamZeroDeltaTurns).
// streamTopUps — turns where the delta stream was a safe PREFIX of the transcript but
// not equal to it; OCP topped up from the transcript and served T.
// Benign but worth watching — a persistent rate means the hook is
// losing fires.
// streamDivergences — turns REFUSED because emitted bytes were not a prefix of the
// transcript. THE field to alert on for CORRECTNESS: it means the hook
// and the transcript disagreed and OCP chose to fail rather than serve
// unverifiable text.
// streamZeroDeltaTurns — streamed turns where the hook fired ZERO times (F7). THE field to
// alert on for AVAILABILITY: streamTopUps climbing is one fire dropped
// here and there (benign); this climbing means the hook is not firing
// AT ALL — e.g. `--settings` silently stopped registering it (a claude
// version bump), or F3's truncated-script failure mode — and every
// streamed turn is quietly degrading to fully-buffered with no error.
export function buildTuiHealthBlock({ enabled, entrypointMode, maxConcurrent, streamEnabled = false }, tuiStats, semaphore, pool = null) {
return {
enabled,
entrypointMode, // cli | auto | off
lastEntrypoint: tuiStats.lastEntrypoint, // last observed cc_entrypoint, or null
entrypointMismatches: tuiStats.entrypointMismatches,
inflight: semaphore.inflight, // current concurrent TUI turns
queued: semaphore.queued, // turns waiting for a slot
maxConcurrent,
pool: pool ? pool.stats() : null, // warm pane pool, or null when disabled
streamEnabled,
streamTurns: tuiStats.streamTurns ?? 0,
streamDeltas: tuiStats.streamDeltas ?? 0,
streamTopUps: tuiStats.streamTopUps ?? 0,
streamDivergences: tuiStats.streamDivergences ?? 0,
streamZeroDeltaTurns: tuiStats.streamZeroDeltaTurns ?? 0,
};
}
+782
View File
@@ -0,0 +1,782 @@
// TUI-mode session driver: hosts an interactive `claude` in tmux, submits one
// serialized prompt, awaits the transcript reader, tears down. OCP-specific.
//
// Authority: claude CLI v2.1.158 interactive mode (no -p / no --output-format
// => cc_entrypoint=cli). Submission recipe validated by spikes T3/T6 on PI231.
// See docs/superpowers/specs/2026-05-30-tui-mode-production-design.md.
//
// Trust handling: rather than answer the trust-folder dialog interactively (which
// only appears on a cwd's FIRST encounter — sending a defensive "1" to an already
// trusted cwd would inject a stray prompt turn), we PRE-TRUST the scratch cwd by
// seeding <home>/.claude.json. Every turn then boots dialog-free and identical.
import { spawnSync } from "node:child_process";
import { mkdtempSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync, statSync, renameSync, symlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { readTuiTranscript } from "./transcript.mjs";
import { prepareStreamHook, streamFilePath, parseDeltaChunk } from "./stream.mjs";
// F7 fix (audit finding, LOW): the prefix used to be a bare, host-wide constant
// ("ocp-tui-"), so 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) would boot-reap and potentially kill-server the OTHER
// instance's LIVE sessions: the coexistence guard below only ever spared foreign
// PRODUCT prefixes (olp-tui-*), never a second ocp-tui-* instance on a different port.
//
// Fix: scope the prefix to the instance's own listen port. The port is the natural
// stable per-instance discriminator on one host (two OCP instances cannot share a
// port), so `ocp-tui-<port>-` uniquely namespaces this instance's sessions and makes
// a same-host sibling OCP instance look exactly like a foreign product (olp-tui-*) to
// the coexistence guard — its `ocp-tui-<otherPort>-*` sessions never match our own
// prefix and are therefore never reaped/kill-server'd by us.
//
// LEGACY_SESSION_PREFIX / LEGACY_SESSION_NAME_RE describe the OLD bare-prefix shape
// (pre-this-fix), retained ONLY for the boot-time legacy-zombie migration handled in
// reapStaleTuiSessions (see comment there). No code path in this version ever CREATES
// a legacy-shaped session name again — sessionPrefixForPort() is the only session-name
// prefix constructor used going forward.
export const LEGACY_SESSION_PREFIX = "ocp-tui-";
// Exact legacy shape: LEGACY_SESSION_PREFIX + sessionId.slice(0, 8), where sessionId is
// a randomUUID() — so the suffix is always exactly 8 lowercase hex characters with NO
// further separator. The new port-scoped shape always inserts a "-" between the port
// digits and the 8-hex suffix (see sessionPrefixForPort), so this regex can never match
// a new-shape name: a new-shape suffix is `<port digits>-<8 hex>` (contains a literal
// "-"), which `[0-9a-f]{8}$` anchored immediately after the prefix cannot satisfy.
export const LEGACY_SESSION_NAME_RE = /^ocp-tui-[0-9a-f]{8}$/;
// Build this instance's own session-name prefix, scoped by its listen port so a
// second OCP instance on the same host (different port) is never mistaken for "ours".
export function sessionPrefixForPort(port) {
return `ocp-tui-${port}-`;
}
const TMUX = process.env.OCP_TUI_TMUX_BIN || "tmux";
const defaultTmux = (args, opts = {}) =>
spawnSync(TMUX, args, { encoding: "utf8", ...opts });
// Kill ONLY our own stale sessions. Scoped to sessionPrefixForPort(port) so a co-hosted
// OLP test instance's `olp-tui-*` sessions — AND a co-hosted second OCP instance's
// `ocp-tui-<otherPort>-*` sessions — are never touched (F7 fix).
//
// Defunct-reaping (PI231 incident): the pane's `claude` process is a child of the
// long-lived tmux SERVER daemon, NOT of the OCP node process — `tmux new-session -d`
// returns the instant the server forks the pane, so node never becomes its parent and
// therefore can NEVER waitpid()/reap it (a SIGKILL still needs the *parent* to reap, and
// here that parent is the tmux server). `kill-session` destroys the session but the server
// can leave the pane's `claude` (and any grandchildren claude spawned) as `<defunct>`
// zombies that only the server can reap. Over many per-request spawn+teardown cycles these
// accumulate (live evidence on PI231: 25 defunct `<claude>` over 30 days; `tmux kill-server`
// dropped it 25→3). The only node-reachable action that ACTUALLY reaps them — rather than
// merely re-signalling — is to stop the tmux server: when the server exits, the kernel
// reparents its surviving children to init (PID 1), which reaps them immediately.
//
// `port` (required) is this instance's own listen port (server.mjs's PORT / lib/constants.mjs
// DEFAULT_PORT resolution) — the SPOT for "which sessions are ours."
//
// ── POOL/REAPER INVARIANT (warm pane pool — lib/tui/pool.mjs) ───────────────────────────
// A warm pooled pane is one of OUR OWN `ocp-tui-<port>-*` sessions that is ALIVE AND IDLE
// BY DESIGN — and the periodic sweep runs precisely when the instance is idle, i.e. exactly
// when the pool is full. Without an exemption the sweep would kill every warm pane on every
// tick (and kill-server on top). The exemption is `spare`: a set of EXACT session names the
// caller declares live. Three properties, all load-bearing:
//
// 1. A LIVE POOLED PANE IS NEVER REAPED — INCLUDING ONE THAT IS STILL BOOTING. It is in
// `spare` (the pool's live registry), so it is skipped by name. The booting case is not
// a footnote, it is the one that bit us: bootTuiPane creates the tmux session
// SYNCHRONOUSLY and only then waits up to POOL_BOOT_MS for the input bar, so a pooled
// session can be live for ~20 s before its boot resolves. The pool therefore mints the
// pane's NAME up front and holds it in `_bootingPane`, so liveNames() can name — and
// spare — a session whose boot has not finished. (An earlier version tracked only a
// COUNT of in-flight boots; the sweep could not name that session and killed it.)
// 2. A LEAKED/ORPHANED POOLED PANE IS STILL REAPED. Membership is by EXACT NAME from a
// live in-memory registry — NOT by "looks pooled" (name shape). A pane the pool no
// longer owns (handed out, dropped, cancelled, or left behind by a previous process
// generation — whose registry died with it) is absent from `spare` and is killed like
// any other stale session. Fail-safe: forgetting to pass `spare` reaps MORE, never less.
// 3. KILL-SERVER NEVER KILLS A LIVE POOL PANE. A spared session suppresses kill-server
// exactly as a foreign session does (it is a live child of the tmux server). The
// consequence — that a permanently-full pool would permanently disable the defunct-
// zombie reaping that ONLY kill-server can do — is resolved in server.mjs by DRAINING
// the pool immediately before the sweep, so `spare` is empty on the normal tick and
// kill-server still fires. `spare` is the belt-and-braces: a reap call site that
// forgets to drain still cannot kill a live pane.
//
// `spare` (default: none) — iterable of session names, or a Set. Ignored when the pool is off.
//
// `includeLegacy` (default false): when true, sessions matching the exact OLD bare-prefix
// shape (LEGACY_SESSION_NAME_RE) are ALSO treated as ours for kill-session purposes. This is
// the boot-time legacy migration: an operator upgrading past this fix could otherwise be left
// with orphaned bare-prefix zombie sessions from the PREVIOUS (pre-fix) process generation of
// this SAME instance, since no live instance of the new version ever creates that shape again
// — a legacy-shaped session found at boot is therefore presumed to be this instance's own
// leftover, not a stranger's. Passed true ONLY from the one-time boot-reap call site in
// server.mjs; the periodic idle-reap sweep does NOT set it, so a lingering legacy session
// during steady-state is conservatively treated as foreign (correctly blocking kill-server)
// rather than assumed to be ours on every 15-minute tick. Residual (accepted, documented):
// if a genuinely-still-running PRE-FIX OCP instance is coexisting on the same host at the
// exact moment a new instance boots, its live legacy-shaped session could be 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 (the actual F7 finding).
export function reapStaleTuiSessions({ tmux = defaultTmux, port, includeLegacy = false, spare = null } = {}) {
const r = tmux(["list-sessions", "-F", "#{session_name}"]);
if (!r || r.status !== 0) return 0; // no tmux server / no sessions
const names = String(r.stdout || "").split("\n").map((s) => s.trim()).filter(Boolean);
const ownPrefix = sessionPrefixForPort(port);
const spared = spare instanceof Set ? spare : new Set(spare || []);
let killed = 0;
let othersRemain = false;
let sparedLive = 0;
for (const name of names) {
// Property 1+2: exemption is by EXACT NAME from the pool's live registry. A pooled-
// LOOKING name that is not in the registry is an orphan and falls through to the
// normal kill path below.
if (spared.has(name)) { sparedLive++; continue; }
const isOwn = name.startsWith(ownPrefix);
const isLegacyOwn = includeLegacy && LEGACY_SESSION_NAME_RE.test(name);
if (isOwn || isLegacyOwn) {
tmux(["kill-session", "-t", name]);
killed++;
} else {
othersRemain = true; // a session we do NOT own (olp-tui-*, a sibling ocp-tui-<otherPort>-*,
// or — outside includeLegacy — a legacy-shaped name) — never kill-server
}
}
// Reap defunct `claude` zombies: safe ONLY when the server is now ours-only/empty.
// kill-server is what actually reaps (server exit reparents survivors to init); a
// per-session kill cannot, since node is not the zombies' parent.
//
// Property 3: a SPARED session is a live child of this tmux server, so kill-server would
// kill it — it therefore suppresses kill-server exactly as a foreign session does. On the
// normal sweep the pool is drained first, so sparedLive is 0 and kill-server still fires.
if (!othersRemain && sparedLive === 0) {
tmux(["kill-server"]);
}
return killed;
}
// ── Task 5: runTuiTurn ───────────────────────────────────────────────────
// Boot + paste-settle timing. Conservative defaults validated on PI231; env-tunable.
const BOOT_MS = parseInt(process.env.OCP_TUI_BOOT_MS || "4000", 10); // max wait for input-ready
// Readiness cap for a POOL pre-boot. Deliberately far more generous than BOOT_MS: BOOT_MS is
// tight because a client is blocked on it, whereas a warm-pane boot happens in the background
// with nobody waiting. Observed live at size=2: a refill booting alongside an in-flight turn
// exceeded 4000 ms and was discarded (tui_pool_boot_failed), quietly costing hit rate for a
// pane that was merely slow, not broken. Scales with OCP_TUI_BOOT_MS if an operator raises it.
export const POOL_BOOT_MS = BOOT_MS * 5;
const READY_POLL_MS = parseInt(process.env.OCP_TUI_READY_POLL_MS || "400", 10); // readiness / paste-verify poll interval
const PASTE_VERIFY_MS = parseInt(process.env.OCP_TUI_PASTE_VERIFY_MS || "5000", 10); // max wait for pasted prompt to render
// Hook-sink drain interval when streaming. 100ms: the hook fires at BLOCK granularity
// (~5-7 fires per answer, seconds apart), so a finer poll buys nothing and a coarser one
// would add visible lag to the first delta. Cheap — one readFileSync of a small file.
const STREAM_POLL_MS = parseInt(process.env.OCP_TUI_STREAM_POLL_MS || "100", 10);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Capture the visible tmux pane as plain text (for readiness / paste verification).
function tuiCapturePane(tmux, tmuxName) {
const r = tmux(["capture-pane", "-p", "-t", tmuxName]);
return (r && typeof r.stdout === "string") ? r.stdout : "";
}
// True once claude's input bar is rendered and ready for keystrokes.
function tuiInputReady(pane) {
return /\? for shortcuts/.test(pane);
}
// True once the pasted prompt has POSITIVELY landed in the input box. We only trust
// affirmative signals — NOT "the placeholder is gone", which is unreliable (claude's
// placeholder uses a curly quote `"`, randomized example text, and renders the big paste
// a beat after paste-buffer returns; a "placeholder-gone" heuristic false-positived on the
// still-empty box and made us submit Enter into nothing → issue #130 hang). Landed iff:
// (a) the bracketed-paste indicator "[Pasted text" is present (large/multi-line paste), OR
// (b) the prompt's own leading text appears in the pane (short/literal paste).
function tuiPromptLanded(pane, prompt) {
const flatPane = pane.replace(/\s+/g, " ");
if (flatPane.includes("[Pasted text")) return true;
const firstLine = String(prompt).split("\n").map(s => s.trim()).find(Boolean) || "";
const needle = firstLine.replace(/\s+/g, " ").slice(0, 24);
// C-4/#133: threshold lowered 3 → 2. A prompt whose first non-blank line is 12
// chars ("hi", "ok") previously NEVER matched (needle.length >= 3) and never
// surfaced "[Pasted text", so EVERY short prompt 5s-failed with tui_paste_not_landed
// (live-reproduced: "hi"). The input box starts EMPTY (the curly-quote placeholder
// is excluded by the affirmative-signal design above), so a >=2-char needle present
// in the pane is the pasted prompt, not placeholder noise — false-positive risk is
// low. We keep >=2 rather than >=1 because a single visible char is more likely to
// collide with incidental glyphs in claude's chrome (borders, the "" prompt mark);
// 2 chars is the floor that lands real prompts while staying conservative.
return needle.length >= 2 && flatPane.includes(needle);
}
async function pollUntil(fn, { timeoutMs, intervalMs }) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try { if (fn()) return true; } catch { /* ignore, keep polling */ }
await sleep(intervalMs);
}
return false;
}
// Single-quote escaper for sh -c arguments.
function shq(s) {
return `'${String(s).replace(/'/g, "'\\''")}'`;
}
// Pre-trust the scratch cwd by seeding the trust record in <home>/.claude.json so
// the trust-folder dialog never appears. Verified-live trust shape:
// projects["<cwd>"] = { hasTrustDialogAccepted: true, allowedTools: [], ... }
// Idempotent + best-effort: a missing/unreadable .claude.json must not abort a
// turn (a fresh cwd would then show the dialog once; the boot wait tolerates it).
// Must run BEFORE the session boots so claude reads the trusted record at startup.
export function ensureTuiCwdTrusted(home, cwd) {
if (!home || !cwd) return;
const path = `${home}/.claude.json`;
let j, mode;
try {
j = JSON.parse(readFileSync(path, "utf8"));
mode = statSync(path).mode & 0o777;
} catch { return; }
j.projects = j.projects || {};
const entry = j.projects[cwd] || {};
if (entry.hasTrustDialogAccepted === true) return; // already trusted, no rewrite
entry.hasTrustDialogAccepted = true;
if (!Array.isArray(entry.allowedTools)) entry.allowedTools = [];
j.projects[cwd] = entry;
// Atomic write (temp + rename on the same fs), preserving mode, so a crash
// mid-write can never truncate the user's real ~/.claude.json. We seed ONLY the
// per-project trust flag — NOT bypassPermissionsModeAccepted: the driver never
// passes --dangerously-skip-permissions, so the bypass dialog cannot appear, and
// onboarding completion is an A-path precondition (the host already runs claude).
// NOTE: when the A-path moves to a dedicated scratch HOME (task #26), this writes
// a file we fully own, removing the real-config-mutation concern entirely.
try {
const tmp = `${path}.ocp-tui.${process.pid}.tmp`;
writeFileSync(tmp, JSON.stringify(j, null, 2), { mode });
renameSync(tmp, path);
} catch { /* best effort */ }
}
// Resolve the HOME the TUI `claude` runs under. Three intents, decided by the env
// token + an explicit OCP_TUI_HOME override:
//
// - ENV-TOKEN MODE (default when CLAUDE_CODE_OAUTH_TOKEN is set AND OCP_TUI_HOME is
// unset): a CREDENTIAL-FREE scratch home at `<realHome>/.ocp-tui/home`. There is
// deliberately NO .credentials.json (no symlink, no copy), so the only credential
// claude can find is the long-lived env token (passed by buildTuiCmd). This is what
// actually FORCES env-token auth — see the prepareTuiHome comment for why passing
// the token alone is insufficient.
// - EXPLICIT OVERRIDE: whatever OCP_TUI_HOME names (back-compat; an operator who set it
// keeps exactly that home).
// - REAL-HOME (default when the env token is unset): the operator's real home, shared
// credentials.json — byte-for-byte the pre-fix behaviour for credentials.json hosts.
//
// Pure + deterministic so server.mjs and the tests share one decision. `configuredHome`
// is the raw OCP_TUI_HOME value (undefined/empty => unset).
export const DEFAULT_TUI_SCRATCH_HOME = (realHome) => `${realHome}/.ocp-tui/home`;
export function resolveTuiHome({ realHome, configuredHome, envTokenSet }) {
if (configuredHome) return configuredHome; // explicit override wins (back-compat)
if (envTokenSet) return DEFAULT_TUI_SCRATCH_HOME(realHome); // credential-free scratch
return realHome; // legacy real-home default
}
// Prepare the HOME claude runs under. Three modes:
// - real-home (tuiHome === realHome OR falsy): no isolation; just trust the cwd
// in the real ~/.claude.json. The legacy default when no env token is set.
// - ENV-TOKEN scratch-home (envTokenMode === true): a dedicated HOME with a seeded
// .claude.json (onboarded + trusts only the scratch cwd) and its own projects/ dir,
// and DELIBERATELY NO .credentials.json (no symlink, no copy). claude then has no
// credentials file to read, so it authenticates via CLAUDE_CODE_OAUTH_TOKEN (passed
// by buildTuiCmd) — which is authoritative precisely because nothing shadows it.
// - legacy scratch-home (envTokenMode falsy, tuiHome !== realHome): the historical
// mode that SYMLINKS the real .credentials.json. Retained only for an operator who
// explicitly set OCP_TUI_HOME without an env token; see the caveat below.
//
// WHY ENV-TOKEN MODE IS THE FIX (proven live on PI231, claude 2.1.104):
// env token passed + a broken ~/.claude/.credentials.json present → 401.
// env token passed + credentials.json moved aside → real answer.
// Interactive `claude` PREFERS .credentials.json over the env var (unlike `-p`, where the
// env token wins), so a stale/corrupt credentials.json SHADOWS the env token. Passing the
// token is necessary but insufficient; the TUI claude must run in a HOME with NO
// credentials.json so the env token is the only credential. This ALSO ends the refresh-
// corruption incident at the root: with no credentials file, claude never runs the token-
// refresh path, so the single-use refresh token can never be rotated (and corrupted) by the
// spawn+kill cycle. (This RESOLVES — not reintroduces — the ADR 0007 scratch-home concern:
// the old caveat was about a SYMLINKED credentials.json being forked on refresh; here there
// is no credentials file to fork and no refresh ever happens.)
//
// ⚠️ LEGACY SCRATCH-HOME CAVEAT (envTokenMode falsy, symlink path): claude rewrites
// .credentials.json on token refresh, REPLACING the symlink with a regular-file copy → the
// scratch home FORKS the OAuth credentials and a refresh can invalidate the real-home token.
// That path is therefore safe only with a DEDICATED OAuth or for ephemeral use. The env-token
// mode above avoids this entirely.
//
// Idempotent + best-effort: any failure degrades toward the dialog/cap, never corrupts.
// Run BEFORE the session boots.
export function prepareTuiHome(realHome, tuiHome, cwd, { envTokenMode = false } = {}) {
if (!tuiHome || tuiHome === realHome) { ensureTuiCwdTrusted(realHome, cwd); return; }
try {
const claudeDir = `${tuiHome}/.claude`;
mkdirSync(`${claudeDir}/projects`, { recursive: true });
if (!envTokenMode) {
// Legacy mode ONLY: symlink the real credentials (never copy the token); refresh if
// missing. Env-token mode deliberately skips this — no credentials file at all.
const link = `${claudeDir}/.credentials.json`;
if (!existsSync(link)) {
try { symlinkSync(`${realHome}/.claude/.credentials.json`, link); } catch { /* best effort */ }
}
}
// Seed .claude.json ONCE (if absent): onboarded + trust ONLY the scratch cwd.
// In env-token mode start from a MINIMAL config (do NOT copy the real ~/.claude.json —
// a credential-isolated home should not inherit the operator's account/config state);
// in legacy mode carry the onboarded real config minus the user's project history.
const seedPath = `${tuiHome}/.claude.json`;
if (!existsSync(seedPath)) {
let base = {};
if (!envTokenMode) {
try { base = JSON.parse(readFileSync(`${realHome}/.claude.json`, "utf8")); } catch { /* fresh */ }
}
base.hasCompletedOnboarding = true;
base.projects = { [cwd]: { hasTrustDialogAccepted: true, allowedTools: [] } };
writeFileSync(seedPath, JSON.stringify(base, null, 2), { mode: 0o600 });
}
} catch { /* best effort */ }
// Ensure the cwd is trusted in the scratch config (idempotent; atomic).
ensureTuiCwdTrusted(tuiHome, cwd);
}
// Build interactive claude argv: NO -p, NO --output-format (=> cc_entrypoint=cli).
// MCP hard-disabled: --strict-mcp-config (no --mcp-config) is the only mechanism
// that stops account-attached managed MCP from connecting (spec §5.2 / T6),
// belt-and-braces with --disallowedTools "mcp__*".
// A-PATH ONLY: built-in tools are left enabled (acceptable single-user). Deployment B
// (guest keys) MUST additionally pass --tools "" per spec §5.2(2) as the credential
// wall before this argv is reachable for owner_tier=guest — guard that in PR-3 wiring.
//
// `stream` (optional, OCP_TUI_STREAM): { file, settings } — when present, the pane gets
// (a) OCP_TUI_STREAM_FILE in its env — read by the static MessageDisplay hook script to
// decide WHERE to append this pane's deltas. Delivered as env (not baked into the
// settings file) so the settings file stays STATIC and a pre-booted warm pane works.
// Verified live: a claude hook inherits the pane's environment.
// (b) --settings <file> — registers the MessageDisplay hook.
// VERIFIED LIVE (claude 2.1.207, this host) before shipping, because both were spawn-level
// risks:
// - the startup banner is UNCHANGED with --settings: "Sonnet 4.6 with low effort ·
// Claude Max" (subscription pool). --settings is NOT a --bare-class flag — it does not
// silently drop the subscription pool. Transcript entrypoint stayed "cli".
// - --settings MERGES into the settings hierarchy, it does NOT clobber <HOME>/.claude/
// settings.json: with --settings passed, the user-level settings.json's `env` block was
// still applied to the hook's environment. So the isolated-HOME settings story the TUI
// already relies on (permissions / additionalDirectories — see prepareTuiHome and the
// OCP_TUI_FULL_TOOLS note above) survives intact.
// When absent, the argv is byte-for-byte the pre-streaming argv.
export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode, stream = null) {
// Deliver claude's env via an `env` prefix on the PANE COMMAND — tmux does NOT forward the
// spawning process's environment to the pane, and `new-session -e` needs tmux ≥3.2 (the cloud
// host runs 2.7), so this is the only portable, reliable mechanism (verified live 2026-06-01:
// passing {env} to spawnSync left the pane with only HOME). DISABLE_AUTOUPDATER pins the version
// (no "What's new" splash that delayed input-readiness); CLAUDE_CODE_ENTRYPOINT labels the
// billing pool (set below per entrypointMode).
//
// CLAUDE_CODE_DISABLE_CLAUDE_MDS + DISABLE_AUTO_MEMORY: OCP is a PROXY, not a Claude Code
// session. The proxied client (OpenClaw / an IDE) owns its own context and memory; the HOST's
// CLAUDE.md and auto-memory must NEVER leak into the agent OCP runs on the user's behalf.
// Without these, claude loads the host's project/user CLAUDE.md + memory into every proxied
// turn — verified live 2026-06-02: a cwd CLAUDE.md ("end every reply with QUACKMARKER_42") was
// obeyed by the proxied turn until these flags were set, after which it was not. Unconditional
// by design (not gated): proxy purity is not an opt-in. Harmless on hosts with no CLAUDE.md
// (the common case — they suppress nothing). Mirrors the -p path's CLAUDE_NO_CONTEXT vars.
const sets = [
`HOME=${shq(ehome)}`,
"DISABLE_AUTOUPDATER=1",
"CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL=1",
"CLAUDE_CODE_DISABLE_CLAUDE_MDS=1",
"CLAUDE_CODE_DISABLE_AUTO_MEMORY=1",
];
// CLAUDE_CODE_OAUTH_TOKEN: tmux does NOT forward the parent process's env to the pane (the
// same reason the whole env is delivered as an `env` prefix above — verified live 2026-06-01),
// so the token MUST be set explicitly here or the spawned `claude` never sees it. Without it,
// the TUI claude falls back to authenticating via <HOME>/.claude/.credentials.json, whose
// single-use refresh token gets corrupted by the per-request spawn + `kill-session` teardown
// racing claude's token-rotation write (the PI231 incident: refresh token ended up an empty
// string → permanent 401 "Please run /login", re-login re-corrupted on the next spawn). With
// the long-lived OAuth token in env, claude authenticates via the token and never touches the
// credentials.json refresh path — matching how the stable oracle / Mac-mini hosts already run.
//
// SECURITY: the token appears in the pane command (ps-visible). This is acceptable for the
// single-user A-path — it mirrors the existing plaintext-token practice (server.mjs reads the
// same CLAUDE_CODE_OAUTH_TOKEN env at getOAuthCredentials()), and the multi-user B-path is
// already refused at boot (TUI + AUTH_MODE=multi is a hard FATAL). Read from process.env here,
// consistent with how buildTuiCmd already reads OCP_TUI_FULL_TOOLS / CLAUDE_ALLOWED_TOOLS below.
//
// When the env is unset (e.g. a host that intentionally relies on credentials.json), no token
// is added — behaviour is byte-for-byte unchanged from before this fix.
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
sets.push(`CLAUDE_CODE_OAUTH_TOKEN=${shq(process.env.CLAUDE_CODE_OAUTH_TOKEN)}`);
}
// Streaming sink: the pane's own per-session delta file (see the `stream` note above).
if (stream && stream.file) sets.push(`OCP_TUI_STREAM_FILE=${shq(stream.file)}`);
const unset = ["CLAUDECODE", "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"];
if (entrypointMode === "cli") sets.push("CLAUDE_CODE_ENTRYPOINT=cli");
else if (entrypointMode === "auto") unset.push("CLAUDE_CODE_ENTRYPOINT"); // let claude self-classify via TTY
const envPrefix = ["env", ...unset.map((u) => `-u ${u}`), ...sets].join(" ");
// Tool surface.
// DEFAULT (safe): hard-disable MCP (--strict-mcp-config + --disallowedTools mcp__*);
// built-in tools stay on, acceptable for single-user A-path.
// OCP_TUI_FULL_TOOLS=1: grant the SAME tool surface as the -p A-path
// (--allowedTools [+ --mcp-config]), so a SINGLE-USER / trusted TUI deployment can
// run a tool-using agent (e.g. an OpenClaw assistant that needs Bash/Read/Write/MCP)
// on the subscription pool. ALWAYS uses --allowedTools (CLAUDE_SKIP_PERMISSIONS /
// --dangerously-skip-permissions is intentionally removed: claude v2.1.x shows an
// interactive bypass-acceptance screen in headless tmux that nothing can answer →
// the turn hangs until the wallclock cap, bricks the pane; not recoverable without a
// human at a keyboard). Use scratch-home settings.json additionalDirectories instead.
let toolArgs;
if (process.env.OCP_TUI_FULL_TOOLS === "1") {
toolArgs = [];
const allowed = (process.env.CLAUDE_ALLOWED_TOOLS ||
"Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch,Agent")
.split(",").map((s) => s.trim()).filter(Boolean);
// shq EACH token: buildTuiCmd returns a SHELL STRING (run by tmux via sh -c), unlike
// buildCliArgs which returns an argv array to spawn(). claude accepts scoped specifiers
// like "Bash(npm run test:*)" / "Read(~/**)" whose ( ) * ~ would break/inject the shell
// command if pasted bare. (operator-self-injection only — guests can't reach TUI.)
if (allowed.length) toolArgs.push("--allowedTools", ...allowed.map(shq));
if (process.env.CLAUDE_MCP_CONFIG) toolArgs.push("--mcp-config", shq(process.env.CLAUDE_MCP_CONFIG));
} else {
toolArgs = ["--strict-mcp-config", "--disallowedTools", shq("mcp__*")];
}
// Effort: pass --effort EXPLICITLY. Without it, the pane's claude inherits a
// HOME-dependent effortLevel — real-home mode inherits the operator's
// ~/.claude/settings.json (whatever they set for their own interactive use),
// env-token scratch mode inherits claude's built-in default (prepareTuiHome never
// writes effortLevel) — so latency silently depends on which HOME mode
// resolveTuiHome() picked AND on an unrelated operator setting. Pinning it here
// removes both. Measured (docs/plans/2026-07-13-tui-latency): explicit low cuts
// direct-spawn TTFT p50 10.35s → 6.17s (40%) and collapses the spread ~15×;
// banner-verified to stay on the subscription pool (`· Claude Max`).
// OCP_TUI_EFFORT=inherit restores the pre-flag argv byte-for-byte (no --effort).
// An unknown value falls back to the default rather than reaching claude's argv:
// a typo'd --effort value must not risk a spawn-time usage error in the pane.
const EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"]; // claude 2.1.207 --help
const effortRaw = (process.env.OCP_TUI_EFFORT || "low").trim().toLowerCase();
let effortArgs;
if (effortRaw === "inherit") {
effortArgs = [];
} else if (EFFORT_LEVELS.includes(effortRaw)) {
effortArgs = ["--effort", effortRaw];
} else {
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.
const settingsArgs = stream && stream.settings ? ["--settings", shq(stream.settings)] : [];
return [
envPrefix,
shq(claudeBin),
"--model", shq(model),
"--session-id", sessionId,
...toolArgs,
...effortArgs,
...settingsArgs,
].join(" ");
}
// Is a pane alive AND still sitting at its input bar? Used by the warm pool to decide,
// at hand-out time, whether a pre-booted pane is still usable (a dead/degraded pane must
// become a MISS → cold path, never a hung turn). capture-pane exits non-zero when the
// session no longer exists, so this covers "pane gone" and "pane not ready" in one call.
export function tuiPaneHealthy(tmux, tmuxName) {
const r = tmux(["capture-pane", "-p", "-t", tmuxName]);
if (!r || r.status !== 0 || typeof r.stdout !== "string") return false;
return tuiInputReady(r.stdout);
}
// Pool pane names carry a "p" marker after the port-scoped prefix:
// turn pane: ocp-tui-<port>-<8hex> (unchanged)
// pool pane: ocp-tui-<port>-p<8hex>
// Purely for operator legibility (`tmux ls` shows which panes are warm). It is NOT the
// reaper's exemption mechanism — that is the exact-name spare set (see the POOL/REAPER
// INVARIANT above), so a pooled-LOOKING orphan is still reaped. Both shapes start with
// sessionPrefixForPort(port), so both remain reapable as "ours", and neither can match
// LEGACY_SESSION_NAME_RE.
export function poolPaneName(port, sessionId) {
return sessionPrefixForPort(port) + "p" + sessionId.slice(0, 8);
}
// Boot ONE interactive `claude` pane and wait for its input bar. Shared by the cold
// request path (runTuiTurn) and the warm pool (lib/tui/pool.mjs) so a pooled pane is
// spawned with byte-for-byte the same argv, HOME, cwd and trust preparation as a
// cold-booted one — the pool must not become a second, drifting spawn path.
//
// Each pane gets its OWN fresh randomUUID() --session-id, fixed at boot. That is what
// keeps a pooled pane single-use-safe: its transcript holds exactly one exchange.
//
// requireReady: the cold path tolerates a readiness timeout (it falls through and lets
// the paste-verify decide — pre-existing behaviour, unchanged). The POOL sets it, because
// a pane that never reached its input bar is worthless as a warm pane and must not be
// enlisted: throw, let the pool count a bootFailure, and leave the request path to
// cold-boot as usual.
// bootMs: max wait for the input bar. Defaults to BOOT_MS (the REQUEST path's cap, which is
// deliberately tight — a client is blocked on it). The POOL passes POOL_BOOT_MS instead: a
// background pre-boot has nobody waiting on it, and capping it at the request-path's 4 s
// made real refills fail (observed live: a refill booting alongside an in-flight turn took
// >4 s and was discarded, silently lowering the hit rate). Slow != broken for a pre-boot.
// `sessionId` / `name` (both optional): the caller may supply the pane's identity instead of
// letting bootTuiPane mint it. The POOL does, because it must know the tmux session's NAME
// before this function runs — the session is created synchronously below, well before the
// readiness wait returns, so a pool that only learned the name on resolve could neither spare
// the session from the reaper nor kill it on shutdown. Supplying BOTH also keeps the name's
// hex suffix equal to the session-id's, so `tmux ls` correlates to the transcript file.
// `streamDir` (optional, OCP_TUI_STREAM): install claude's MessageDisplay hook on this pane.
// Done HERE, at boot — not at turn time — and that is the whole reason streaming survives the
// WARM POOL: the hook script + settings file are STATIC (one pair per streamDir), and the only
// per-turn thing, the sink path, is derived from the pane's own --session-id, which is fixed
// right here. So a pre-booted pane already carries its hook and its own sink and streams exactly
// like a cold-booted one; nothing request-specific is ever baked into the spawn.
export async function bootTuiPane({
model, claudeBin, home, realHome, cwd, port, entrypointMode = "cli",
tmux = defaultTmux, sessionId = null, name = null, requireReady = false, bootMs = BOOT_MS,
streamDir = null,
}) {
const sid = sessionId || randomUUID();
// Port-scoped session name (F7 fix) — see sessionPrefixForPort / reapStaleTuiSessions
// for why this instance's own listen port is the namespace discriminator.
const tmuxName = name || (sessionPrefixForPort(port) + sid.slice(0, 8));
const ehome = home || process.env.HOME; // HOME claude runs under (scratch or real)
const rhome = realHome || process.env.HOME; // real home (OAuth + onboarded config source)
// Env-token-only mode: the env token is set AND claude runs in an isolated home
// (ehome !== rhome). In that case the scratch home must be CREDENTIAL-FREE (no
// .credentials.json) so the env token — passed by buildTuiCmd — is the only credential
// and is therefore authoritative (interactive claude otherwise PREFERS a credentials.json,
// shadowing the env token; proven live on PI231). server.mjs derives TUI_HOME via
// resolveTuiHome() so this isolated home is the DEFAULT once CLAUDE_CODE_OAUTH_TOKEN is set.
const envTokenMode = !!process.env.CLAUDE_CODE_OAUTH_TOKEN && ehome !== rhome;
// Ensure scratch cwd exists, then prepare the (scratch or real) HOME + trust the
// cwd — before claude boots.
if (!existsSync(cwd)) mkdirSync(cwd, { recursive: true });
prepareTuiHome(rhome, ehome, cwd, { envTokenMode });
// Streaming sink for THIS pane (see the streamDir note above). rmSync first so a
// re-used session-id can never replay a previous turn's deltas.
let streamFile = null, streamSettings = null;
if (streamDir) {
streamFile = streamFilePath(streamDir, sid);
streamSettings = prepareStreamHook(streamDir);
try { rmSync(streamFile, { force: true }); } catch { /* start from a fresh sink */ }
}
// Minimal env for spawnSync (tmux itself). The pane's claude env comes exclusively
// from the `env` prefix string built inside buildTuiCmd — tmux does NOT forward the
// spawning process's env to the pane, so the {env} here is intentionally minimal.
const env = { ...process.env };
env.HOME = ehome; // tmux needs HOME; all claude-specific vars go via buildTuiCmd prefix
// Boot the interactive session inside tmux, rooted at the scratch cwd.
// Capture the result: if tmux new-session fails (status !== 0) there is no PTY, no
// interactive spawn — abort BEFORE the boot wait rather than paste into a non-existent
// session or issue a billing request without a verified interactive context.
const spawnResult = tmux(
["new-session", "-d", "-s", tmuxName, "-x", "220", "-y", "50", "-c", cwd,
buildTuiCmd(claudeBin, model, sid, ehome, entrypointMode,
streamFile ? { file: streamFile, settings: streamSettings } : null)],
{ env },
);
if (!spawnResult || spawnResult.status !== 0) {
throw new Error("tui_spawn_failed: tmux session not created");
}
// Wait until claude's input bar is actually ready (not a blind sleep).
// bootMs is the MAX readiness wait, not a fixed delay.
const ready = await pollUntil(() => tuiInputReady(tuiCapturePane(tmux, tmuxName)),
{ timeoutMs: bootMs, intervalMs: READY_POLL_MS });
if (!ready) {
if (requireReady) {
try { tmux(["kill-session", "-t", tmuxName]); } catch { /* already gone */ }
throw new Error("tui_pane_not_ready: input bar did not appear within " + bootMs + "ms");
}
// Cold path (pre-existing behaviour): readiness timed out; rely on paste-verify.
console.error("[tui] input_not_ready", tmuxName);
}
return { name: tmuxName, sessionId: sid, model, ehome, streamFile, bootedAt: Date.now() };
}
// Full per-request TUI lifecycle:
// 1. Take a WARM pane from the pool if one is available for this model (opt-in;
// OCP_TUI_POOL_SIZE=0 => always null => steps 2-3 below are exactly today's path).
// A pooled pane is SINGLE-USE: it already carries its own fresh --session-id, it
// serves this one turn, and it is killed in the finally like any other pane.
// 2. On a MISS: pre-trust the scratch cwd, boot an interactive `claude` in a fresh tmux
// session in the scratch cwd, poll capture-pane until the `? for shortcuts` input bar
// appears (bootTuiPane). BOOT_MS is the max wait, not a fixed delay.
// 3. Write prompt to a 0600 temp file (no shell injection from prompt content).
// 4. Paste the prompt via tmux load-buffer + paste-buffer -p (bracketed paste) —
// reliable for large multi-line prompts where send-keys -l is not (issue #130).
// Poll-verify the prompt landed in the input (placeholder gone / [Pasted text]);
// fast-fail with tui_paste_not_landed if it never lands (prevents the 120s
// wallclock "stuck typing" hang). Then submit with a SEPARATE Enter key event.
// 5. Block on the native JSONL transcript (located by THIS pane's session-id) until
// terminal marker or wall-clock cap.
// 6. Always teardown: kill session + rm temp dir (even on throw), and kick a background
// pool refill so the next request finds a warm pane.
// Returns { text, entrypoint } from readTuiTranscript (entrypoint is the billing-pool
// classifier, e.g. "cli", or null if the transcript did not include a turn_duration).
//
// STREAMING (OCP_TUI_STREAM, default off). Pass `onDelta` and `streamDir`, and the pane's
// MessageDisplay hook (installed by bootTuiPane; see lib/tui/stream.mjs) appends each raw
// delta payload to the pane's own sink. This driver polls that sink and invokes onDelta(payload)
// per fire while the turn is still generating. A WARM pane already carries its sink from boot
// (pane.streamFile), so the pooled and cold paths stream identically.
//
// `streamDir` IS PASSED TO THE COLD BOOT UNCONDITIONALLY (not gated on `onDelta`) — F4 fix. The
// spawn argv is this project's billing-classification surface: a caller with OCP_TUI_STREAM on
// but THIS particular request non-streaming (stream:false) must still get the SAME argv whether
// it lands on a pool HIT or a cold-boot MISS, because a pre-booted pool pane cannot know in
// advance whether the request it will eventually serve wants streaming — it installs the hook
// unconditionally whenever the pool is warming at all (see server.mjs's bootPane closure). Gating
// the cold boot's hook install on `onDelta` made a stream:false request's argv depend on whether
// it happened to hit the pool or miss it — the exact drift this surface cannot tolerate. Whether
// the hook is actually POLLED is a separate, correctly-scoped decision: see `streaming` below,
// gated on onDelta && streamFile, so a non-streaming turn never reads its own sink even though
// the hook is running.
//
// The transcript stays AUTHORITATIVE regardless: it is still the terminal-turn signal, still the
// source of the returned `text`, and still the input to the caller's honesty gates. The delta
// stream is a low-latency MIRROR of it, never a replacement, and the caller asserts the two
// agree. With onDelta AND streamDir both omitted, nothing here changes: no poll, no hook.
//
// `abortSignal` (optional): aborts the transcript wait, so a client that disconnects mid-turn
// tears the pane down NOW (the finally below) instead of holding the pane — and therefore the
// caller's semaphore slot — until the turn or the wallclock cap ends.
export async function runTuiTurn({
prompt,
model,
claudeBin,
home,
realHome,
cwd,
port,
wallclockMs = 120000,
entrypointMode = "cli",
tmux = defaultTmux,
pool = null, // TuiPanePool | null — null (default) === today's cold-boot-only path
onPane = null, // optional observer: ({ warm }) => void, for logging/metrics
onDelta = null, // (payload) => void — invoked per MessageDisplay hook fire, mid-turn
streamDir = null, // hook sink dir, passed to the COLD boot UNCONDITIONALLY (F4 — see above);
// a warm pane brings its own, fixed at its own boot
abortSignal = null,
}) {
// 1. Warm pane, or cold boot. A MISS is never an error — it is exactly today's path.
let pane = pool ? pool.acquire(model) : null;
const warm = !!pane;
// Kick the refill IMMEDIATELY (not after the turn): the replacement pane then boots
// CONCURRENTLY with this turn and is warm by the time the next request arrives. Also
// runs on a MISS — acquire() has just retargeted the pool to this model, so the miss
// that cold-boots today warms the pool for the next caller. Fire-and-forget; it takes
// no TuiSemaphore slot (see pool.refill's SLOT ACCOUNTING note).
if (pool) pool.refill();
if (onPane) { try { onPane({ warm }); } catch { /* observer must never break a turn */ } }
if (!pane) {
// streamDir passed AS-IS (not gated on onDelta) — F4: see the STREAMING comment above.
pane = await bootTuiPane({ model, claudeBin, home, realHome, cwd, port, entrypointMode, tmux,
streamDir });
}
const tmuxName = pane.name;
const sessionId = pane.sessionId; // THIS pane's own session-id — one session, one turn
const ehome = pane.ehome || home || process.env.HOME;
// Streaming state is read off the PANE, not recomputed here — a warm pane fixed its sink at
// boot, and a cold one just did the same above. If the pool was booted WITHOUT a streamDir
// while onDelta is set, streamFile is null and the turn degrades to buffered: correct, just
// not fast. (server.mjs wires the same streamDir into both paths so that cannot happen.)
const streamFile = pane.streamFile || null;
const streaming = !!(onDelta && streamFile);
const streamCursor = { consumed: 0 };
let streamStopped = false;
let pollTimer = null;
// Drain every complete line appended since the last drain. Never throws into the turn: a
// malformed line is skipped by parseDeltaChunk, and an onDelta that throws is contained.
const drainDeltas = () => {
if (!streaming) return;
let text;
try { text = readFileSync(streamFile, "utf8"); } catch { return; } // absent until the first fire
const { deltas, consumed } = parseDeltaChunk(text, streamCursor.consumed);
streamCursor.consumed = consumed;
for (const d of deltas) {
try { onDelta(d); } catch { /* a sink error must never abort the turn */ }
}
};
// Write prompt to a temp file (mode 0600) so the content never touches argv.
const tmpDir = mkdtempSync(`${tmpdir()}/ocp-tui-`);
const promptFile = `${tmpDir}/prompt.txt`;
writeFileSync(promptFile, prompt, { mode: 0o600 });
try {
// 3. Paste the prompt via a tmux PASTE BUFFER with bracketed paste (-p), NOT
// `send-keys -l`. send-keys of a large multi-line prompt is unreliable: the
// embedded newlines arrive as separate key events (effectively repeated Enter),
// so a big OpenClaw-style prompt never lands and the turn hangs to the wallclock
// (issue #130 — reproduced at ~300 lines; fixed by bracketed paste). load-buffer
// reads the file directly (no shell arg limit, no `"$(cat)"`), and paste-buffer -p
// wraps it in bracketed-paste markers so claude ingests it atomically as ONE paste
// ("[Pasted text #N +M lines]"). -d deletes the buffer afterward. Buffer name is the
// per-session tmuxName, so concurrent turns never collide.
tmux(["load-buffer", "-b", tmuxName, promptFile]);
tmux(["paste-buffer", "-b", tmuxName, "-t", tmuxName, "-p", "-d"]);
// Verify the prompt POSITIVELY landed before submitting; poll (a large bracketed paste
// takes a beat to render the "[Pasted text]" indicator). This is load-bearing: firing
// Enter before the paste renders submits an empty box → the turn hangs to the wallclock
// (issue #130). Fast-fail if it never lands → deterministic error in seconds.
const landed = await pollUntil(() => tuiPromptLanded(tuiCapturePane(tmux, tmuxName), prompt),
{ timeoutMs: PASTE_VERIFY_MS, intervalMs: READY_POLL_MS });
if (!landed) {
throw new Error("tui_paste_not_landed: prompt did not reach claude's input within " + PASTE_VERIFY_MS + "ms");
}
// Submit (separate Enter key event).
tmux(["send-keys", "-t", tmuxName, "Enter"]);
// 5a. Streaming only: start polling the hook sink. Runs CONCURRENTLY with the
// transcript wait below — the deltas are what make the answer visible while the
// turn is still generating; the transcript is what makes it authoritative.
if (streaming) {
const loop = () => {
if (streamStopped) return;
drainDeltas();
pollTimer = setTimeout(loop, STREAM_POLL_MS);
};
pollTimer = setTimeout(loop, STREAM_POLL_MS);
}
// 5b. Block on the native transcript (resolved by THIS pane's session-id) until terminal.
// Returns { text, entrypoint, truncated } from readTuiTranscript.
const result = await readTuiTranscript({ home: ehome, sessionId, wallclockMs, abortSignal });
// 5c. FINAL drain. The terminal marker can land between two poll ticks, so the last
// delta(s) may still be unread — without this the tail would be missing from the
// stream and every turn would need a transcript top-up.
streamStopped = true;
if (pollTimer) clearTimeout(pollTimer);
drainDeltas();
return result;
} finally {
// 6. Teardown — always, even on throw (including an abortSignal disconnect, which is
// exactly why the pane cannot outlive a client that walked away). A pooled pane is
// torn down here exactly like a cold-booted one: SINGLE-USE, never returned (pool.mjs).
streamStopped = true;
if (pollTimer) clearTimeout(pollTimer);
try { tmux(["kill-session", "-t", tmuxName]); } catch { /* already gone */ }
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
if (streamFile) { try { rmSync(streamFile, { force: true }); } catch { /* best effort */ } }
}
}
+288
View File
@@ -0,0 +1,288 @@
// TUI-mode real SSE streaming — the `MessageDisplay` hook sink.
//
// WHAT THIS IS. `claude` fires a **MessageDisplay** hook per rendered block of the
// assistant's reply, handing the hook the RAW MARKDOWN SOURCE of an incremental
// `delta` on stdin. Registered via `--settings` on the ordinary interactive TUI spawn
// (NO -p, NO --bare — the billing pool is untouched), it is the only byte-faithful
// incremental source the interactive CLI exposes. Everything here consumes that hook
// surface AS EMITTED — forwarding, not inventing.
//
// ALIGNMENT.md: **Class B**. We consume claude's own hook payload and re-emit it in the
// OpenAI chat/completions streaming shapes OCP already speaks (ADR 0006). There is no
// `cli.js` citation because no `cli.js` function is being mirrored: the TUI spawn is
// OCP-owned surface (ADR 0007), and the hook payload is claude's own published contract.
//
// THE VERIFIED CONTRACT (docs/plans/2026-07-13-tui-latency/streaming-spike.md, and
// independently reproduced on claude 2.1.207 / sonnet-4-6 / banner `· Claude Max`):
//
// payload (stdin, one JSON object per fire):
// { hook_event_name:"MessageDisplay", session_id, transcript_path, prompt_id, cwd,
// turn_id, message_id, index, final, delta }
//
// - deltas carry the raw markdown source (`## `, `**`, ```javascript all present)
// - concat(deltas of one message) === T, byte-exactly (T = extractLatestAssistantText)
// - T.startsWith(concat(deltas[0..n])) at EVERY n (prefix-stable)
// - block-level granularity (~5-7 fires per answer), NOT token-level
// - only `text` blocks fire it — thinking blocks are excluded (what OCP wants)
//
// ⚠️ THE HOOK IS SYNCHRONOUS. The hook's source sets `forceSyncExecution: true` —
// `claude` BLOCKS on every fire. The hook script must therefore write and exit, doing
// NO work inline. Measured cost of the script below: p50 7.2 ms / p90 14.7 ms per fire,
// i.e. ~50 ms added blocking across a whole ~7-delta turn against a 6-10 s turn. That is
// noise, so a plain append is the right sink — a FIFO would be faster on paper but a FIFO
// blocks its writer until a reader attaches, which would hand `claude` a way to hang.
//
// WARM-POOL COMPATIBILITY (load-bearing — a warm pane pool is a separate in-flight PR).
// The hook script and the settings file are BOTH STATIC: one copy per stream dir, written
// once, never per-request. The per-turn destination is carried in the PANE'S OWN ENV as
// `OCP_TUI_STREAM_FILE` (verified live: a hook inherits the pane's environment), and the
// path is derived from the session-id — which for a pre-booted pane is fixed at BOOT.
// Nothing about a request is baked into the settings file at spawn time, so a pane booted
// before its request arrives streams exactly the same way.
import { writeFileSync, mkdirSync, renameSync } from "node:fs";
import { detectTuiUpstreamError } from "./transcript.mjs";
// Default holdback before the first byte is released to the client. See TuiDeltaAssembler.
export const DEFAULT_HOLDBACK_CHARS = 100;
// Resolve OCP_TUI_STREAM_HOLDBACK to a SAFE value. The whole C-1 auth-banner guarantee rests
// on the holdback being at least the default banner detector's max message length — which is
// exactly DEFAULT_HOLDBACK_CHARS. So this is a FLOOR, not a hint: a smaller value (or a NaN
// typo like "unlimited"/"5MB") would let a real banner fragment release before the terminal
// detector could classify the whole message, silently reopening the leak the assembler exists
// to prevent. The env var's own doc says "Only raise it"; this enforces that instead of trusting
// it. Returns { value, clamped } so the caller can warn when it had to clamp — a silent floor is
// less honest than a noticed one.
export function resolveStreamHoldback(raw, floor = DEFAULT_HOLDBACK_CHARS) {
const parsed = parseInt(raw ?? "", 10);
if (!Number.isFinite(parsed)) return { value: floor, clamped: raw != null && String(raw).trim() !== "" };
if (parsed < floor) return { value: floor, clamped: true };
return { value: parsed, clamped: false };
}
// The hook script. POSIX sh, no interpreter startup beyond /bin/sh, one fork (`cat`).
//
// - `printf` is a shell BUILTIN in sh/dash/bash, so the newline costs no fork.
// - the `{ cat; printf '\n'; } >>` group opens the file ONCE and appends both writes
// through the same O_APPEND fd, so a payload and its terminator can never be split
// by another writer. (They never race anyway: one file per pane, and MessageDisplay
// is synchronous within a pane.)
// - a payload JSON can never contain a literal newline — JSON.stringify escapes them —
// so "one line == one payload" holds, and a torn write is always a trailing partial
// line, which parseDeltaChunk() leaves unconsumed until it completes.
// - NO OCP_TUI_STREAM_FILE (e.g. a pane booted with streaming off, or any other claude
// session that happens to load this settings file) => swallow stdin and exit 0. The
// hook must NEVER fail or block: claude is waiting on it.
export const HOOK_SCRIPT = `#!/bin/sh
# OCP TUI streaming sink — claude fires this per MessageDisplay block and BLOCKS on it.
# Write and exit. Never do work here.
[ -n "\$OCP_TUI_STREAM_FILE" ] || exec cat >/dev/null
{ cat; printf '\\n'; } >> "\$OCP_TUI_STREAM_FILE"
`;
// The --settings payload registering the hook. Static: no per-request data.
export function buildStreamSettings(hookScriptPath) {
return { hooks: { MessageDisplay: [{ hooks: [{ type: "command", command: hookScriptPath }] }] } };
}
export const hookScriptPath = (streamDir) => `${streamDir}/md-hook.sh`;
export const streamSettingsPath = (streamDir) => `${streamDir}/settings.json`;
// One file per session-id. For a pre-booted (warm) pane the session-id is fixed at boot,
// so this path is knowable at boot — which is what keeps the pool compatible.
export const streamFilePath = (streamDir, sessionId) => `${streamDir}/${sessionId}.jsonl`;
// Atomic write: temp file + rename (same-directory, same-filesystem, so rename is atomic on
// POSIX). A process killed mid-`writeFileSync` leaves the TEMP file half-written, never the
// real path — `path` always names either the old complete content or the new complete
// content, never a torn one. That matters specifically for md-hook.sh: it is SYNCHRONOUS
// (claude blocks on every fire), so a truncated script would still pass `existsSync`, still
// get exec'd, and fail/hang on every single MessageDisplay fire with no operator-visible
// symptom short of streaming going silently dead (F7's streamZeroDeltaTurns is the backstop
// for exactly that). Mirrors ensureTuiCwdTrusted's tmp+renameSync pattern in session.mjs.
function writeFileAtomic(path, content, mode) {
const tmp = `${path}.${process.pid}.tmp`;
writeFileSync(tmp, content, { mode });
renameSync(tmp, path);
}
// Write the static hook script + settings file into `streamDir`. UNCONDITIONAL, not
// write-if-missing: these files persist across OCP restarts at `streamDir`, so a host that
// booted once under an older version and never had its stream dir cleared would otherwise be
// silently stuck on a stale HOOK_SCRIPT / buildStreamSettings() forever — no future OCP
// upgrade could ever reach it. Safe to call every boot: the content is static (no per-request
// data), so a same-content rewrite is the overwhelmingly common case and costs two tiny
// atomic writes, not a per-turn expense. Returns the settings path to hand to `claude
// --settings`.
export function prepareStreamHook(streamDir) {
mkdirSync(streamDir, { recursive: true });
const script = hookScriptPath(streamDir);
const settings = streamSettingsPath(streamDir);
writeFileAtomic(script, HOOK_SCRIPT, 0o700);
writeFileAtomic(settings, JSON.stringify(buildStreamSettings(script), null, 2), 0o600);
return settings;
}
// Parse newly-appended sink lines. `consumed` is the number of COMPLETE lines already
// taken; only lines terminated by "\n" are complete, so a payload caught mid-write stays
// unconsumed until its terminator lands. Returns the fresh MessageDisplay payloads plus
// the new consumed count. Pure — the caller owns the cursor.
export function parseDeltaChunk(text, consumed = 0) {
const lines = String(text ?? "").split("\n");
const complete = lines.slice(0, -1); // the tail after the last "\n" is a partial line
const deltas = [];
for (const line of complete.slice(consumed)) {
const t = line.trim();
if (!t) continue;
try {
const o = JSON.parse(t);
if (o && o.hook_event_name === "MessageDisplay" && typeof o.delta === "string") deltas.push(o);
} catch { /* not ours / not parseable — skip, never throw into the request path */ }
}
return { deltas, consumed: complete.length };
}
// ── The assembler: hook deltas → client bytes, with the honesty gates intact ──
//
// Two jobs, both load-bearing.
//
// 1. THE AUTH-BANNER HOLDBACK (C-1 / issue #133 must survive streaming).
// The interactive CLI renders an auth failure as ordinary assistant TEXT — so an
// expired-credential turn fires MessageDisplay with the BANNER as its delta, and a
// naive forwarder would stream "Please run /login · API Error: 401 …" to the client as
// a normal answer, exactly the silent-error case C-1 exists to prevent.
// detectTuiUpstreamError() classifies a WHOLE message, so it cannot be run per-delta.
// Instead we HOLD BACK the first `holdbackChars` characters. The default detector only
// ever fires on a message of <= 100 chars (TUI_ERR_MAX_LEN — real banners are 69 and 73),
// so once the TRIMMED accumulation EXCEEDS 100 chars the final text cannot be a banner by
// that detector's own length rule, and releasing is safe. An answer that never exceeds the
// holdback is simply delivered whole at terminal — i.e. exactly today's buffered
// behaviour, gates and all.
// THE GUARANTEE HAS TWO HALVES, both required — neither alone is sufficient:
// (i) Nothing is emitted for a message until its trimmed accumulation exceeds the
// detector's max banner length. This is what keeps the FIRST message of a turn
// safe: a banner-length message can never clear the holdback.
// (ii) Once a message boundary follows an emit (`restartedAfterEmit`), push() stops
// emitting ENTIRELY for the rest of the turn — a SECOND message (e.g. an
// auth-failure banner rendered mid-turn, after tool-using prose already streamed)
// gets zero bytes forwarded, not just a fresh holdback of its own. finalize() then
// refuses the whole turn (SSE error frame, no cache) precisely because the first
// message's bytes are unretractable and unverifiable against T. Without this half,
// (i) alone only protects the FIRST message per turn — see F1.
// ⚠️ Soundness is w.r.t. the DEFAULT detector. An operator who REPLACES it via
// CLAUDE_TUI_ERROR_PATTERNS with a pattern that can match a longer message must raise
// OCP_TUI_STREAM_HOLDBACK past their longest banner; server.mjs warns at boot. That is the
// one case (i) does not cover — (ii) still applies regardless. Even past both, the
// terminal gate still refuses to cache a banner and still ends the stream on an SSE error
// frame rather than finish_reason:"stop" — the holdback is the first of two layers, not
// the only one.
//
// 2. MESSAGE SCOPING (keeps `concat === T` the RIGHT assertion).
// The transcript's T is extractLatestAssistantText() — the LAST text-bearing assistant
// entry, not every assistant entry. A tool-using turn therefore has TWO messages
// (prose → tool_use → answer) and T is only the second. So the assembler scopes to the
// CURRENT message_id: when a new message_id appears and NOTHING has been emitted yet,
// the held text is DISCARDED — the transcript is about to discard it too, so this keeps
// us byte-identical to the buffered path instead of streaming prose the buffered path
// would have dropped. When a new message_id appears AFTER we have already emitted, the
// bytes are gone and cannot be retracted: finalize() then reports !ok and the caller
// fails the turn loudly (SSE error frame, no cache, counted on /health). Fail-loud is
// the correct posture — a proxy that silently serves text the transcript disagrees with
// is the exact class of bug ALIGNMENT.md exists to prevent.
// Sentinel for "no message seen yet". Deliberately not null/undefined — see the constructor.
const NO_MESSAGE_YET = Symbol("no-message-yet");
export class TuiDeltaAssembler {
constructor({ holdbackChars = DEFAULT_HOLDBACK_CHARS, detectError = detectTuiUpstreamError } = {}) {
this.holdbackChars = holdbackChars;
this.detectError = detectError;
this.emitted = ""; // bytes ALREADY written to the client — unretractable
this.pending = ""; // held back, not yet written
this.released = false;
// NOT null: a payload may legitimately carry message_id === null, and if the sentinel were
// also null the FIRST such payload would compare equal to it, register no boundary, and
// leave `messages` at 0 — which used to disarm the restartedAfterEmit guard below entirely.
// A unique object is === to nothing a JSON payload can produce, so the first fire ALWAYS
// registers as message 1, whatever its message_id is (or isn't).
this.messageId = NO_MESSAGE_YET;
this.deltas = 0; // hook fires seen
this.messages = 0; // distinct message_ids seen
this.restartedAfterEmit = false;
}
// All hook bytes for the CURRENT message (emitted + still held).
get full() { return this.emitted + this.pending; }
// Feed one MessageDisplay payload. Returns the text to emit NOW, or null (held back).
push(payload) {
const delta = payload && typeof payload.delta === "string" ? payload.delta : "";
const mid = payload ? payload.message_id : null;
if (mid !== this.messageId) {
this.messageId = mid;
this.messages++;
if (this.emitted === "") {
this.pending = ""; // safe: the transcript will drop this message too
} else {
// A boundary while bytes are ALREADY out is unrecoverable, full stop — the count of
// messages seen so far is irrelevant. The old `else if (this.messages > 1)` guard was
// the sole reason a null-message_id first payload could disarm F1: it left `messages`
// at 0, so the real boundary evaluated 1 > 1 === false and never armed. The invariant
// is "a boundary occurred while emitted !== ''", and that is exactly what this says.
this.restartedAfterEmit = true; // unrecoverable — finalize() will refuse the turn
}
}
this.deltas++;
// F1: once a message boundary has followed an emit, the turn is ALREADY unrecoverable —
// finalize() will refuse it (see restartedAfterEmit above). `this.released` stays true
// from the FIRST message's release and, uncorrected, lets every later message's deltas
// stream straight through unfiltered — exactly the auth-banner-mid-turn leak this class
// exists to prevent. Stop emitting HERE, permanently, for the rest of the turn: there is
// nothing left to gain from continuing to forward bytes for a turn that will be refused,
// and every byte forwarded now is one more the client cannot be told to un-see.
if (this.restartedAfterEmit) return null;
if (!delta) return null;
if (this.released) {
this.emitted += delta;
return delta;
}
this.pending += delta;
// Release only once the TRIMMED accumulation is past the banner detector's reach.
// detectTuiUpstreamError() trims before measuring length (TUI_ERR_MAX_LEN is a trimmed-
// length bound), so gating release on the UNTRIMMED pending.length let a run of >
// holdbackChars whitespace trim down to "" — detectError("") sees nothing to classify,
// returns null, and release fires with the holdback never having actually screened
// anything. Trimming here keeps both sides of the check talking about the same string.
if (this.pending.trim().length > this.holdbackChars && this.detectError(this.pending) == null) {
const out = this.pending;
this.pending = "";
this.released = true;
this.emitted += out;
return out;
}
return null;
}
// Reconcile against the AUTHORITATIVE transcript text T. Call only AFTER the truncation
// and auth-banner gates have passed. Returns:
// { ok:true, tail, exact } — tail is the remaining text to emit (may be ""). `exact`
// is concat(deltas) === T; when false we still serve exactly
// T, having topped up from the transcript, and the caller
// counts a topUp.
// { ok:false, ... } — what we already emitted is NOT a prefix of T. The client
// holds bytes the transcript disagrees with; the caller must
// NOT cache and must end the stream on an SSE error frame.
finalize(T) {
const text = typeof T === "string" ? T : "";
const full = this.full;
if (!text.startsWith(this.emitted)) {
return { ok: false, tail: null, exact: false, emitted: this.emitted.length, transcript: text.length };
}
return {
ok: true,
tail: text.slice(this.emitted.length),
exact: full === text,
emitted: this.emitted.length,
transcript: text.length,
};
}
}
+301
View File
@@ -0,0 +1,301 @@
// Transcript reader for TUI-mode. Reads claude's native JSONL session transcript
// and returns the latest assistant turn's text once the turn is terminal.
//
// Authority: claude CLI v2.1.157 — interactive session transcript at
// <HOME>/.claude/projects/<CWD with every "/" -> "-">/<--session-id>.jsonl
// Completion marker: a line {"type":"system","subtype":"turn_duration",...}.
// See docs/superpowers/specs/2026-05-30-tui-mode-production-design.md §4.
import { readFileSync, existsSync, readdirSync } from "node:fs";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Locate a session's transcript by its UUID across every projects subdir, without
// reconstructing the encoded cwd. Robust to whatever encoding claude applies.
// Returns the path, or null if not present yet (it appears once the turn starts).
// TODO: add a CI fixture-contract test (a captured real transcript) so schema drift
// in the claude JSONL format fails loudly rather than silently degrading.
export function findTranscriptPath(home, sessionId) {
if (!home || !sessionId) return null;
const root = `${home}/.claude/projects`;
let dirs;
try { dirs = readdirSync(root); } catch { return null; }
for (const d of dirs) {
const candidate = `${root}/${d}/${sessionId}.jsonl`;
if (existsSync(candidate)) return candidate;
}
return null;
}
// Parse NDJSON text into objects; skip blank lines and partial/forming lines
// (the live transcript is read mid-write, so the last line may be incomplete).
export function parseTranscriptLines(text) {
const out = [];
for (const line of text.split("\n")) {
const t = line.trim();
if (!t) continue;
try { out.push(JSON.parse(t)); } catch { /* partial line being written */ }
}
return out;
}
// A line marks the assistant turn complete when EITHER:
// (a) {type:"system", subtype:"turn_duration"} — emitted by newer claude builds
// (e.g. 2.1.159), OR
// (b) {type:"assistant"} whose message.stop_reason is a FINAL reason
// ("end_turn" / "stop_sequence" / "max_tokens"). This is the API-level
// end-of-turn signal, present across claude builds whose transcripts do NOT
// emit turn_duration (e.g. 2.1.114 — verified live on the cloud host). Without
// it OCP can't detect completion on those builds and hangs to the wallclock,
// then returns only partial text (issue #130, cloud/server-side symptom).
//
// stop_reason "tool_use" is deliberately NOT terminal: the model is mid-turn (it will
// run a tool and continue with a later assistant entry). Matching on a FINAL
// stop_reason — not on the mere presence of a tool_use — keeps tool-using turns intact.
// (The v3.17.1 narrowing dropped a buggy "tool_use is terminal" rule; this restores
// cross-version completion detection without bringing that bug back.)
const TERMINAL_STOP_REASONS = new Set(["end_turn", "stop_sequence", "max_tokens"]);
export function isTerminalLine(obj) {
if (!obj || typeof obj !== "object") return false;
if (obj.type === "system" && obj.subtype === "turn_duration") return true;
if (obj.type === "assistant" && obj.message && typeof obj.message === "object") {
return TERMINAL_STOP_REASONS.has(obj.message.stop_reason);
}
return false;
}
// Text of the LAST assistant turn: concatenate its text content blocks
// (ignore thinking/tool_use blocks). Later assistant entries overwrite earlier.
// Fixture-confirmed shape: top-level type:"assistant", message.content[] array.
//
// Scoping: this returns the FINAL text-bearing assistant entry in the whole file,
// not "text since the matching user line" (spec §4.2). Those are equivalent ONLY
// under OCP's one-session-per-request model (a fresh --session-id => a fresh
// transcript holding one logical exchange). If a future warm-pool ever reuses a
// session WITHOUT a fresh session-id / clear, earlier-turn text could leak — that
// author must add user-line scoping here. See spec §7.2.
//
// STATUS (warm pool, lib/tui/pool.mjs — the "future warm-pool" this note anticipated):
// the pool does NOT reuse sessions, so the precondition above still holds and no
// user-line scoping was added. Each pooled pane is booted with its OWN fresh
// randomUUID() --session-id (bootTuiPane) and is SINGLE-USE: it serves exactly one turn
// and is then killed and replaced. One session still means one logical exchange, so the
// last assistant entry is still that request's answer.
// The warning therefore stands UNCHANGED for anyone who later wants a pane to serve a
// SECOND turn (or to reset one with /clear and reuse it): that is a leak, and it needs
// user-line scoping HERE before it can be safe. Do not relax pool.mjs's single-use rule
// without doing that work first.
export function extractLatestAssistantText(events) {
let text = "";
for (const ev of events) {
if (!ev || ev.type !== "assistant") continue;
const content = ev.message && ev.message.content;
if (!Array.isArray(content)) continue;
const parts = content
.filter((b) => b && b.type === "text" && typeof b.text === "string")
.map((b) => b.text);
if (parts.length) text = parts.join("");
}
return text;
}
// Returns the entrypoint string (e.g. "cli") used for the billing-pool assertion,
// or null if absent. Lets callers assert the subscription-classified path.
//
// Resolution order (C-3, issue #133):
// 1. PREFER the turn_duration system line's `entrypoint` — the authoritative
// end-of-turn classifier emitted by builds that produce turn_duration
// (e.g. claude-2.1.104/2.1.157 on PI231).
// 2. FALL BACK to the `entrypoint` field on ANY ordinary transcript line
// (assistant / user / attachment / system) — present on BOTH emitting and
// non-emitting builds. Some claude builds (e.g. certain Mac mini transcripts)
// do NOT emit a turn_duration line at all; reading ONLY turn_duration made the
// caller's tui_entrypoint_mismatch assertion (server.mjs) get got:null every
// turn and go blind. The entrypoint value is identical across line types within
// a single interactive session (fixture-confirmed: every line in
// complete-haiku.jsonl carrying `entrypoint` reads "cli"), so the fallback
// yields the same classifier. Last-writer-wins on the fallback.
export function verifyEntrypoint(events) {
let fallback = null;
for (const ev of events) {
if (!ev || typeof ev !== "object") continue;
if (ev.type === "system" && ev.subtype === "turn_duration" && ev.entrypoint != null) {
return ev.entrypoint; // authoritative — short-circuit
}
if (ev.entrypoint != null) fallback = ev.entrypoint;
}
return fallback;
}
// ── C-1: honest AUTH-FAILURE banner detection (issue #133) ───────────────
// When the interactive `claude` CLI hits an in-session error it does NOT crash —
// it renders the error as ordinary assistant text in the transcript. The specific
// failure C-1 exists to catch is R-1: EXPIRED / INVALID credentials, where every
// turn comes back as the same one-line auth-failure banner and OCP, none the wiser,
// caches that banner (server.mjs setCachedResponse), shares it via singleflight, and
// records a model SUCCESS — so a hard auth error is silently served (and cached for
// the 5-min TTL) as a real answer. The two live-reproduced banners on PI231
// (2026-06-10) are:
// "Please run /login · API Error: 401 Invalid authentication credentials" (69 chars)
// "Failed to authenticate. API Error: 401 Invalid authentication credentials" (73 chars)
//
// WHY THE SCOPE IS NARROW (conservatism — the load-bearing design choice).
// An earlier generalised rule (^<short-prefix>?API Error:\s*\d{3}\b.*$) was TOO
// BROAD: its unbounded `.*` tail let any short prefix + "API Error: NNN" + an
// arbitrarily long sentence match, so it KILLED legitimate long answers that merely
// DISCUSS an API error (e.g. "API Error: 500 happened because the server was
// overloaded. To fix this, retry with exponential backoff …"). That is the worst
// outcome: a false-positive costs the user a missing answer AND a double-burn retry,
// whereas the rare false-negative (caching one transient error for the 5-min TTL) is
// cheap and self-healing. So C-1 is reframed from "detect ANY API error" to "detect
// a claude-CLI AUTHENTICATION-FAILURE banner", and when unsure it PASSES (does not
// kill). Transient 5xx server errors are deliberately NOT detected — they are not the
// R-1 case and the conservative choice is to let them through.
//
// THE SIGNAL — a turn is an auth-failure banner only if ALL of these hold over the
// WHOLE trimmed assistant text (a conjunction; any one failing => PASS):
// 1. SHORT whole-message. Real banners are one short line (the two live samples are
// 69 and 73 chars). Cap = TUI_ERR_MAX_LEN (100) — headroom over 73 for a
// slightly longer future banner, while still rejecting multi-sentence prose. A
// long answer that happens to discuss auth (no code chars, e.g. 226 chars) is
// rejected on length alone.
// 2. Contains "API Error: 4\d{2}" — auth failures are 4xx (401/403). This rejects
// transient 5xx ("API Error: 500/503 …") and bare "HTTP 401 means unauthorized."
// (no "API Error:" core).
// 3. Contains an auth KEYWORD — authenticat | /login | credential (case-insensitive).
// This rejects answers that quote a 4xx but are not auth banners, e.g.
// "To debug a 401: the server returns API Error: 401 Unauthorized …"
// ("Unauthorized" is authoriz-, not authenticat-; no /login, no credential).
// 4. Contains NO backtick or quote char (` ' "). A real CLI banner is plain text;
// backticked/quoted text signals an answer that is QUOTING the error rather than
// being the banner, e.g. "You'll see `API Error: 401` … run /login to fix it."
// (75 chars — passes 1-3 but is excluded here). This is the conservative tie-
// breaker for short instructional answers.
//
// Worked matrix (all required cases pass — see test-features.mjs C-1 block):
// KILL: "Please run /login · API Error: 401 Invalid authentication credentials"
// KILL: "Failed to authenticate. API Error: 401 Invalid authentication credentials"
// PASS: "API Error: 500 happened because the server was overloaded. …" (not 4xx)
// PASS: "Failed to parse the config. Here are the API Error: 401 details …" (too long + no auth-kw)
// PASS: "To debug a 401: … API Error: 401 Unauthorized, then you refresh …" (no auth-kw)
// PASS: "Here is the handler … It logs the string API Error: 503 …" (not 4xx)
// PASS: "You'll see `API Error: 401` … run /login to fix it." (has backtick)
// PASS: "HTTP 401 means unauthorized." (no API Error core)
// PASS: "The capital of France is Paris." (nothing matches)
//
// OPERATOR OVERRIDE (unchanged): CLAUDE_TUI_ERROR_PATTERNS lets an operator REPLACE
// the default auth-banner detector with their own newline- or `||`-separated JS regex
// source strings (each auto-anchored ^…$ over the trimmed text, case-insensitive). A
// non-empty override uses ONLY those regexes (the narrowed default is bypassed); an
// empty / whitespace-only override DISABLES detection entirely (escape hatch).
// Whole-message length cap for the default auth-banner detector. Real banners are
// 69/73 chars; 100 gives headroom while still rejecting multi-sentence prose.
const TUI_ERR_MAX_LEN = 100;
// 4xx "API Error:" core — auth failures are 4xx (401/403), never 5xx.
const TUI_ERR_4XX = /API Error:\s*4\d{2}\b/i;
// Auth keyword — the message must be about authentication, not just quote a 4xx.
const TUI_ERR_AUTH_KW = /authenticat|\/login|credential/i;
// Code/quote chars — their presence signals prose QUOTING an error, not the banner.
const TUI_ERR_CODE_CHAR = /[`'"]/;
// Default detector: returns true iff `trimmed` IS a claude-CLI auth-failure banner
// (all four signals above). Conservative — any signal failing => false (PASS).
function isDefaultAuthFailureBanner(trimmed) {
if (trimmed.length > TUI_ERR_MAX_LEN) return false; // 1. short whole-message
if (!TUI_ERR_4XX.test(trimmed)) return false; // 2. 4xx API Error core
if (!TUI_ERR_AUTH_KW.test(trimmed)) return false; // 3. auth keyword
if (TUI_ERR_CODE_CHAR.test(trimmed)) return false; // 4. no code/quote chars
return true;
}
// Compile an OPERATOR-SUPPLIED pattern set (override path only). Each source is
// anchored ^…$ over the trimmed text and matched case-insensitively (`s` so `.` spans
// a multi-line banner). A pattern that fails to compile is skipped (never throws into
// the request path).
function compileTuiErrorPatterns(raw) {
const sources = String(raw).split(/\r?\n|\|\|/).map((s) => s.trim()).filter(Boolean);
const out = [];
for (const src of sources) {
try { out.push(new RegExp(`^(?:${src})$`, "is")); } catch { /* skip bad pattern */ }
}
return out;
}
// Returns the matched banner text (the trimmed assistant text) if `text` IS a claude-
// CLI auth-failure banner in its entirety, else null. `patternsRaw` defaults to
// process.env.CLAUDE_TUI_ERROR_PATTERNS:
// - undefined → narrowed default auth-banner detector (isDefaultAuthFailureBanner).
// - non-empty → operator regex override REPLACES the default.
// - empty/ws → detection disabled (escape hatch).
export function detectTuiUpstreamError(text, patternsRaw = process.env.CLAUDE_TUI_ERROR_PATTERNS) {
if (typeof text !== "string") return null;
const trimmed = text.trim();
if (!trimmed) return null;
if (patternsRaw == null) {
return isDefaultAuthFailureBanner(trimmed) ? trimmed : null;
}
// Operator override path: empty/whitespace disables; otherwise use only their regexes.
const patterns = compileTuiErrorPatterns(patternsRaw);
if (patterns.length === 0) return null;
for (const re of patterns) {
if (re.test(trimmed)) return trimmed;
}
return null;
}
// Block until the session transcript is terminal (turn_duration / final
// stop_reason) or the wall-clock cap elapses, polling the file (no fs.watch —
// robust over NFS / editors). Returns { text, entrypoint, truncated }:
// - text: latest assistant text.
// - entrypoint: billing-pool classifier (see verifyEntrypoint), or null.
// - truncated: FALSE when a terminal marker was reached (the turn completed);
// TRUE when the wall-clock cap was hit with partial text but NO
// terminal marker (the turn is INCOMPLETE — what we have is a
// cut-off prefix). (C-2, issue #133.)
//
// Why `truncated` matters: previously the terminal-marker path and the
// cap-with-partial-text path BOTH returned `{text, entrypoint}` identically, so
// callClaudeTui could not tell a complete answer from a truncated one and cached +
// returned the partial as finish_reason:stop (silent success). The caller now
// throws on `truncated` so a cut-off turn is neither cached nor counted as success.
// The field is additive — existing call sites that ignore it keep working.
//
// On cap with NO text at all, still throws (unchanged) — there is nothing to return.
//
// No quiescence heuristic by design: a long Opus thinking turn stalls transcript
// growth and a "file stable for N s" rule would false-abort it (spec §4.3).
// Resolution: pass an explicit `transcriptPath` (used by unit tests), OR pass
// `home` + `sessionId` to resolve by glob each poll (production) — the transcript
// file does not exist until the turn starts, so resolution happens inside the loop.
// `abortSignal` (optional): when it fires, stop waiting and throw TuiAbortError. The one
// caller that passes it is the STREAMING TUI path, which ties it to the client's socket:
// a client that disconnects mid-turn should not leave the pane running (and the caller's
// concurrency slot held) until the turn or the 120s cap ends. runTuiTurn's finally does the
// teardown. Omitted => the loop is byte-for-byte the pre-streaming loop.
export async function readTuiTranscript({ transcriptPath: p, home, sessionId, wallclockMs = 120000, pollMs = 250, abortSignal = null }) {
const deadline = Date.now() + wallclockMs;
let lastText = "";
let lastEntrypoint = null;
while (Date.now() < deadline) {
if (abortSignal && abortSignal.aborted) {
const err = new Error("tui_aborted: client disconnected before the turn completed");
err.name = "TuiAbortError";
throw err;
}
const resolved = p || findTranscriptPath(home, sessionId);
if (resolved && existsSync(resolved)) {
const events = parseTranscriptLines(readFileSync(resolved, "utf8"));
lastText = extractLatestAssistantText(events) || lastText;
const ep = verifyEntrypoint(events);
if (ep != null) lastEntrypoint = ep;
// Terminal marker reached → the turn is COMPLETE.
if (events.some(isTerminalLine)) return { text: lastText, entrypoint: lastEntrypoint, truncated: false };
}
await sleep(pollMs);
}
// Cap elapsed with no terminal marker. If we have partial text, flag it truncated
// so the caller rejects it (don't cache / don't count as success). No text at all
// → throw (nothing to return).
if (lastText) return { text: lastText, entrypoint: lastEntrypoint, truncated: true };
throw new Error("tui_transcript_timeout: no assistant text within wallclock cap");
}
+18 -2
View File
@@ -2,6 +2,14 @@
"$schema": "./models.schema.json",
"version": 1,
"models": [
{
"id": "claude-opus-4-8",
"displayName": "Claude Opus 4.8",
"openclawName": "Claude Opus 4.8 (via CLI)",
"reasoning": true,
"contextWindow": 200000,
"maxTokens": 16384
},
{
"id": "claude-opus-4-7",
"displayName": "Claude Opus 4.7",
@@ -18,6 +26,14 @@
"contextWindow": 200000,
"maxTokens": 16384
},
{
"id": "claude-sonnet-5",
"displayName": "Claude Sonnet 5",
"openclawName": "Claude Sonnet 5 (via CLI)",
"reasoning": true,
"contextWindow": 200000,
"maxTokens": 16384
},
{
"id": "claude-sonnet-4-6",
"displayName": "Claude Sonnet 4.6",
@@ -36,8 +52,8 @@
}
],
"aliases": {
"opus": "claude-opus-4-7",
"sonnet": "claude-sonnet-4-6",
"opus": "claude-opus-4-8",
"sonnet": "claude-sonnet-5",
"haiku": "claude-haiku-4-5-20251001"
},
"legacyAliases": {
+117 -50
View File
@@ -8,21 +8,18 @@ set -euo pipefail
PROXY="http://127.0.0.1:3456"
# Auth header for multi-key mode: reads from OCP_ADMIN_KEY env or ~/.ocp/admin-key file
_AUTH_HEADER=""
# Auth args for multi-key mode: reads from OCP_ADMIN_KEY env or ~/.ocp/admin-key file
# Using a bash array preserves word boundaries — no eval needed.
_AUTH_ARGS=()
if [[ -n "${OCP_ADMIN_KEY:-}" ]]; then
_AUTH_HEADER="-H \"Authorization: Bearer $OCP_ADMIN_KEY\""
_AUTH_ARGS=(-H "Authorization: Bearer $OCP_ADMIN_KEY")
elif [[ -f "$HOME/.ocp/admin-key" ]]; then
_AUTH_HEADER="-H \"Authorization: Bearer $(cat "$HOME/.ocp/admin-key")\""
_AUTH_ARGS=(-H "Authorization: Bearer $(cat "$HOME/.ocp/admin-key")")
fi
# Wrapper: curl with optional auth
_curl() {
if [[ -n "$_AUTH_HEADER" ]]; then
eval curl "$_AUTH_HEADER" "$@"
else
curl "$@"
fi
curl "${_AUTH_ARGS[@]}" "$@"
}
_json() { python3 -m json.tool 2>/dev/null || cat; }
@@ -576,21 +573,42 @@ Usage:
ocp restart Restart the Claude proxy service
ocp restart gateway Restart the OpenClaw gateway
(briefly disconnects all Telegram/Discord bots)
Note (macOS): restart does a full launchctl bootout + bootstrap, NOT
`kickstart -k`. bootout+bootstrap re-reads the plist's EnvironmentVariables,
so an env change you made (e.g. CLAUDE_BIND, CLAUDE_CODE_OAUTH_TOKEN) actually
takes effect. `kickstart -k` only re-execs the process and reuses launchd's
cached env, so env edits would be silently ignored. (Linux systemctl already
re-reads its EnvironmentFile on restart.)
EOF
}
# macOS only: reload a launchd agent via bootout + bootstrap so plist
# EnvironmentVariables are re-read (kickstart -k would reuse the cached env).
# Args: <uid> <label> <plist-path>. Returns 0 iff bootstrap succeeds.
_launchd_reload() {
local uid="$1" label="$2" plist="$3"
[[ -f "$plist" ]] || return 1
# bootout may legitimately fail if the agent is not currently loaded — that's fine,
# we only require the subsequent bootstrap to succeed (the load that re-reads env).
launchctl bootout "gui/$uid/$label" 2>/dev/null || true
launchctl bootstrap "gui/$uid" "$plist" 2>/dev/null
}
cmd_restart() {
if [[ "${1:-}" == "gateway" ]]; then
echo "Restarting gateway..."
openclaw gateway restart 2>&1
else
echo "Restarting proxy..."
# Try current service name, then legacy, then manual restart
# Try current service name, then legacy, then manual restart.
# macOS: bootout+bootstrap (re-reads plist EnvironmentVariables — see cmd_restart_help).
# Linux: systemctl --user restart already re-reads its EnvironmentFile.
local uid
uid=$(id -u)
if launchctl kickstart -k "gui/$uid/dev.ocp.proxy" 2>/dev/null; then
if _launchd_reload "$uid" "dev.ocp.proxy" "$HOME/Library/LaunchAgents/dev.ocp.proxy.plist"; then
true
elif launchctl kickstart -k "gui/$uid/ai.openclaw.proxy" 2>/dev/null; then
elif _launchd_reload "$uid" "ai.openclaw.proxy" "$HOME/Library/LaunchAgents/ai.openclaw.proxy.plist"; then
true
elif systemctl --user restart ocp-proxy 2>/dev/null; then
true
@@ -604,7 +622,12 @@ cmd_restart() {
self_r="${BASH_SOURCE[0]}"
while [[ -L "$self_r" ]]; do self_r="$(readlink "$self_r")"; done
script_dir="$(cd "$(dirname "$self_r")" && pwd)"
nohup node "$script_dir/server.mjs" >> "$HOME/.ocp/logs/proxy.log" 2>&1 &
# env -u strips test-only key-store redirection vars (A4): if the invoking shell had
# NODE_ENV=test + OCP_DIR_OVERRIDE exported (e.g. from a debugging session), this manual
# fallback would otherwise inherit them and start the daemon against a scratch/empty key
# store — a silent auth outage in AUTH_MODE=multi. The plist/systemd paths strip these via
# plist-merge's NEVER_PRESERVE; this covers the one direct-launch path OCP controls.
DISABLE_AUTOUPDATER=1 env -u NODE_ENV -u OCP_DIR_OVERRIDE nohup node "$script_dir/server.mjs" >> "$HOME/.ocp/logs/proxy.log" 2>&1 &
fi
sleep 3
if curl -sf --max-time 5 "$PROXY/health" > /dev/null 2>&1; then
@@ -695,25 +718,35 @@ for e in d.get('errors', []):
# ── update ──────────────────────────────────────────────────────────────
cmd_update_help() {
cat <<'EOF'
ocp update — Update OCP to the latest version
ocp update — Smart upgrade dispatcher
Pulls the latest code from GitHub, restarts the proxy service,
and optionally syncs the plugin to the OpenClaw extensions directory.
Runs `ocp doctor` internally to choose the right path:
• Patch bump (same minor): light path (git pull + npm install + restart)
• Cross-minor (e.g. v3.10→v3.14): full path with snapshot + post-flight
• Old version (< v3.4.0): fresh-install (asks first; AI passes --yes)
Usage:
ocp update Pull latest and restart
ocp update --check Check for updates without applying
ocp update Smart auto-pick path
ocp update --check Show available updates, don't apply
ocp update --dry-run Preview the plan, don't mutate
ocp update --target v3.13.0 Pin a specific version
ocp update --yes Skip y/N prompts (AI agents pass this)
ocp update --rollback Restore the most recent upgrade snapshot
ocp update --rollback --list List available snapshots
ocp update --rollback <path> Restore a specific snapshot
ocp update --rollback --dry-run Preview rollback plan
ocp update --rollback --gc Delete old snapshots (keep last 5, or <30 days)
ocp update --rollback --gc --dry-run Preview what would be deleted
EOF
}
cmd_update() {
local script_dir self
self="${BASH_SOURCE[0]}"
# Resolve symlinks (e.g. ~/.local/bin/ocp → real location)
while [[ -L "$self" ]]; do self="$(readlink "$self")"; done
script_dir="$(cd "$(dirname "$self")" && pwd)"
# Check-only mode
# Pass through --check fast path (existing behaviour)
if [[ "${1:-}" == "--check" ]]; then
cd "$script_dir"
git fetch origin main --quiet 2>/dev/null || true
@@ -730,71 +763,104 @@ cmd_update() {
echo " Status: ✓ Up to date"
else
echo " Status: $behind commit(s) behind"
echo ""
echo " Run 'ocp update' to apply."
fi
return 0
fi
echo "Updating OCP..."
echo ""
# Rollback path
if [[ "${1:-}" == "--rollback" ]]; then
shift
exec node "$script_dir/scripts/upgrade.mjs" --rollback "$@"
fi
# 1. Pull latest
# Doctor-driven path selection
local kind
kind=$(node "$script_dir/scripts/doctor.mjs" --json 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin)['next_action']['kind'])" 2>/dev/null || echo "unknown")
case "$kind" in
noop)
echo "Already at latest. Nothing to do."
return 0
;;
update)
_cmd_update_light "$script_dir"
;;
upgrade|fresh_install)
exec node "$script_dir/scripts/upgrade.mjs" "$@"
;;
fix_oauth|fix_service)
echo "Pre-upgrade check failed: $kind"
echo "Run \`ocp doctor\` for details and ai_executable steps."
return 1
;;
*)
echo "Unknown doctor kind: $kind. Run \`ocp doctor --json\` to inspect."
return 1
;;
esac
}
# Existing light-path body extracted into a helper so cmd_update can call it conditionally.
_cmd_update_light() {
local script_dir="$1"
echo "Updating OCP (light path)..."
cd "$script_dir"
local old_ver
local old_ver new_ver
old_ver=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "?")
echo " Pulling latest from GitHub..."
if ! git pull origin main --ff-only 2>&1 | sed 's/^/ /'; then
echo " ✗ Git pull failed. Resolve conflicts manually in: $script_dir"
return 1
fi
local new_ver
new_ver=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "?")
if [[ "$old_ver" == "$new_ver" ]]; then
echo " ✓ Already at latest (v$new_ver)"
else
echo " ✓ Updated v$old_ver → v$new_ver"
fi
# 2. Sync plugin to extensions dir
# Sync plugin (existing logic preserved)
local ext_dir="$HOME/.openclaw/extensions/ocp"
if [[ -d "$ext_dir" && -d "$script_dir/ocp-plugin" ]]; then
echo ""
echo " Syncing OCP plugin..."
cp "$script_dir/ocp-plugin/index.js" "$ext_dir/index.js" 2>/dev/null
cp "$script_dir/ocp-plugin/package.json" "$ext_dir/package.json" 2>/dev/null
cp "$script_dir/ocp-plugin/openclaw.plugin.json" "$ext_dir/openclaw.plugin.json" 2>/dev/null
echo " ✓ Plugin synced to $ext_dir"
echo " ✓ Plugin synced"
fi
# 3. Sync OpenClaw registry from models.json (non-fatal)
if command -v node >/dev/null 2>&1 && [[ -f "$script_dir/scripts/sync-openclaw.mjs" ]]; then
echo ""
echo " Syncing OpenClaw registry..."
if ! node "$script_dir/scripts/sync-openclaw.mjs" 2>&1 | sed 's/^/ /'; then
echo " ⚠ OpenClaw sync failed (non-fatal, continuing)"
fi
node "$script_dir/scripts/sync-openclaw.mjs" 2>&1 | sed 's/^/ /' || echo " ⚠ OpenClaw sync failed (non-fatal)"
fi
# 4. Restart proxy
echo ""
echo " Restarting proxy..."
cmd_restart > /dev/null 2>&1
sleep 2
}
if curl -sf --max-time 5 "$PROXY/health" > /dev/null 2>&1; then
local running_ver
running_ver=$(curl -sf --max-time 5 "$PROXY/health" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['version'])" 2>/dev/null || echo "?")
echo " ✓ Proxy running (v$running_ver)"
else
echo " ⚠ Proxy not responding — check: ocp health"
fi
cmd_doctor_help() {
cat <<'EOF'
ocp doctor — Health & upgrade-readiness check
echo ""
echo "Done."
Runs a series of checks (Node version, git state, service health,
OAuth token, plist customisation, OpenClaw provider) and emits either
human-readable PASS/WARN/FAIL output or a JSON next_action that
AI agents can execute.
Usage:
ocp doctor Human-readable output
ocp doctor --json JSON for AI agents and ocp update internal use
ocp doctor --check oauth Fast path: OAuth check only
EOF
}
cmd_doctor() {
local script_dir self
self="${BASH_SOURCE[0]}"
while [[ -L "$self" ]]; do self="$(readlink "$self")"; done
script_dir="$(cd "$(dirname "$self")" && pwd)"
exec node "$script_dir/scripts/doctor.mjs" "$@"
}
# ── help ─────────────────────────────────────────────────────────────────
@@ -862,6 +928,7 @@ case "$subcmd" in
lan) cmd_lan ;;
connect) cmd_connect "$@" ;;
restart) cmd_restart "${1:-}" ;;
update) cmd_update "${1:-}" ;;
doctor) cmd_doctor "$@" ;;
update) cmd_update "$@" ;;
*) echo "Unknown command: $subcmd"; echo ""; cmd_help; exit 1 ;;
esac
+43 -18
View File
@@ -122,11 +122,17 @@ provider = {
"models": []
}
# Model metadata mapping (prefix match for versioned IDs like claude-haiku-4-5-20251001)
# Model metadata mapping. Prefix match on the model FAMILY (claude-opus / -sonnet /
# -haiku), not a pinned version. A version-pinned prefix like "claude-sonnet-4"
# silently misses "claude-sonnet-5" and falls through to the non-reasoning /
# 8k-output default (PR #152 review) — every future Sonnet/Opus/Haiku bump would
# re-trip it. Family prefixes classify any versioned ID correctly with no per-model
# edit. (ADR 0003: models.json is the SPOT for model existence; /v1/models does not
# expose reasoning/maxTokens, so family classification stays here.)
model_meta = {
"claude-opus-4": {"name": "Claude Opus (OCP)", "reasoning": True, "maxTokens": 16384},
"claude-sonnet-4": {"name": "Claude Sonnet (OCP)", "reasoning": True, "maxTokens": 16384},
"claude-haiku-4": {"name": "Claude Haiku (OCP)", "reasoning": False, "maxTokens": 8192},
"claude-opus": {"name": "Claude Opus (OCP)", "reasoning": True, "maxTokens": 16384},
"claude-sonnet": {"name": "Claude Sonnet (OCP)", "reasoning": True, "maxTokens": 16384},
"claude-haiku": {"name": "Claude Haiku (OCP)", "reasoning": False, "maxTokens": 8192},
}
def get_model_meta(mid):
@@ -178,11 +184,11 @@ config.setdefault("agents", {})
config["agents"].setdefault("defaults", {})
config["agents"]["defaults"].setdefault("models", {})
# Build alias map (prefix match)
# Build alias map (family prefix match — version-agnostic, see model_meta note)
alias_prefixes = {
"claude-opus-4": "Claude Opus",
"claude-sonnet-4": "Claude Sonnet",
"claude-haiku-4": "Claude Haiku",
"claude-opus": "Claude Opus",
"claude-sonnet": "Claude Sonnet",
"claude-haiku": "Claude Haiku",
}
for mid in model_ids:
@@ -196,8 +202,13 @@ for mid in model_ids:
# Handle primary/backup
if priority == "1":
# OCP as primary — pick the best model (prefer sonnet for daily use)
primary_model = provider_name + "/claude-sonnet-4-6" if "claude-sonnet-4-6" in model_ids else provider_name + "/" + model_ids[0]
# OCP as primary — pick the best model (prefer the latest Sonnet for daily use,
# tracking the `sonnet` alias default in models.json; fall back across versions).
_sonnet_pref = ["claude-sonnet-5", "claude-sonnet-4-6"]
primary_model = next(
(provider_name + "/" + m for m in _sonnet_pref if m in model_ids),
provider_name + "/" + model_ids[0],
)
config["agents"]["defaults"].setdefault("model", {})
config["agents"]["defaults"]["model"]["primary"] = primary_model
# Keep existing fallbacks
@@ -506,9 +517,11 @@ main() {
echo ""
# Step 2.5: auto-discover anonymous key from /health (issue #12 §14 Path A).
# When the OCP admin set PROXY_ANONYMOUS_KEY, the server advertises it via
# /health.anonymousKey. If the user didn't pass --key, use it automatically so
# `ocp-connect <host>` works zero-config for OpenClaw multi-agent setups.
# The server advertises anonymousKey in /health ONLY when the admin has set
# PROXY_ADVERTISE_ANON_KEY=1 (default off — /health is unauthenticated, so
# advertising exposes the shared key to any LAN-reachable device; issue #109).
# Localhost callers always receive it regardless. When the field is absent,
# ocp-connect falls back to anonymous access / interactive --key (step 3 below).
if [[ -z "$key" ]]; then
local anon_key
anon_key=$(echo "$health_json" | python3 -c "
@@ -582,11 +595,19 @@ print(k if k else '')
if [[ "${SHELL:-}" == */fish ]]; then
echo " Note: fish shell detected. Writing to ~/.bashrc — add to fish config manually."
rc_files+=("$HOME/.bashrc")
elif $is_mac; then
# macOS: default shell since Catalina (2019) is zsh.
# Always write ~/.zshrc (create if absent — zsh tolerates an empty file).
# Only write ~/.bashrc if it already exists (don't surprise users with new files).
[[ -f "$HOME/.bashrc" ]] && rc_files+=("$HOME/.bashrc")
# zshrc: always include on macOS; create the file if it doesn't exist yet
[[ -f "$HOME/.zshrc" ]] || touch "$HOME/.zshrc"
rc_files+=("$HOME/.zshrc")
else
# Always write both on macOS (default shell is zsh but some tools source bashrc)
# Linux / other: write to whichever rc files already exist or match current shell
[[ -f "$HOME/.bashrc" || "${SHELL:-}" == */bash ]] && rc_files+=("$HOME/.bashrc")
[[ -f "$HOME/.zshrc" || "${SHELL:-}" == */zsh ]] && rc_files+=("$HOME/.zshrc")
# If neither exists, create for current shell
# If neither exists, fall back to creating one for the current shell
[[ ${#rc_files[@]} -eq 0 ]] && rc_files+=("$HOME/.${SHELL##*/}rc")
fi
@@ -624,11 +645,12 @@ PYEOF
{
echo ""
echo "# OCP LAN (added by ocp connect)"
echo "export OPENAI_BASE_URL=$base_url/v1"
echo "export OPENAI_BASE_URL='$base_url/v1'"
if [[ -n "$key" ]]; then
echo "export OPENAI_API_KEY=$key"
echo "export OPENAI_API_KEY='$key'"
fi
} >> "$rc_file"
chmod 600 "$rc_file" 2>/dev/null || true
done
echo " Shell config:"
@@ -661,6 +683,7 @@ PYEOF
echo "OPENAI_API_KEY=$key"
fi
} > "$env_dir/ocp.conf"
chmod 600 "$env_dir/ocp.conf" 2>/dev/null || true
echo ""
echo " System-level (systemd):"
echo " ✓ $env_dir/ocp.conf"
@@ -705,7 +728,9 @@ PYEOF
echo ""
echo " Done. Reload your shell to apply:"
echo " source $rc_file"
for rc_file in "${rc_files[@]}"; do
echo " source $rc_file"
done
}
main "$@"
+23 -10
View File
@@ -1,9 +1,19 @@
/**
* OCP Plugin registers /ocp as a native slash command in OpenClaw gateway.
* Calls the local claude-proxy at http://127.0.0.1:3456 and formats the response.
* Calls the local claude-proxy and formats the response.
*
* Port resolution (in priority order):
* 1. OCP_PROXY_URL env (full URL, e.g. http://10.0.0.5:3456)
* 2. CLAUDE_PROXY_PORT env (port only; localhost assumed)
* 3. Fallback: http://127.0.0.1:3456 (OCP server source default since v1.0)
*
* If a particular host's OCP plist injects a non-default CLAUDE_PROXY_PORT,
* the OpenClaw launchd plist for that host must also inject the same
* CLAUDE_PROXY_PORT into the plugin's env, or the plugin will fall back to
* 3456 and miss the server.
*/
const PROXY = "http://127.0.0.1:3456";
const PROXY = process.env.OCP_PROXY_URL
|| (process.env.CLAUDE_PROXY_PORT ? `http://127.0.0.1:${process.env.CLAUDE_PROXY_PORT}` : "http://127.0.0.1:3456");
// Wrap output in monospace code block for Telegram/Discord alignment
function mono(text) { return "```\n" + text + "\n```"; }
@@ -198,31 +208,34 @@ async function cmdTest() {
async function cmdRestart(args) {
const target = (args || "").trim().toLowerCase();
const { execSync } = await import("node:child_process");
const uid = typeof process.getuid === "function" ? process.getuid() : 501;
const macProxy = `launchctl kickstart -k gui/${uid}/dev.ocp.proxy`;
const macGateway = `launchctl kickstart -k gui/${uid}/ai.openclaw.gateway`;
try {
if (target === "gateway") {
execSync("launchctl kickstart -k gui/501/ai.openclaw.gateway", { timeout: 15000 });
execSync(macGateway, { timeout: 15000 });
return "✓ Gateway restarted";
} else if (target === "all") {
execSync("launchctl kickstart -k gui/501/ai.openclaw.proxy", { timeout: 15000 });
execSync(macProxy, { timeout: 15000 });
// Gateway restart will kill this plugin too, so do it last
execSync("launchctl kickstart -k gui/501/ai.openclaw.gateway", { timeout: 15000 });
execSync(macGateway, { timeout: 15000 });
return "✓ Proxy + Gateway restarted";
} else {
execSync("launchctl kickstart -k gui/501/ai.openclaw.proxy", { timeout: 15000 });
execSync(macProxy, { timeout: 15000 });
return "✓ Proxy restarted";
}
} catch (e) {
// Try systemd for Linux
// Linux: systemd user services
try {
if (target === "gateway") {
execSync("systemctl --user restart openclaw-gateway", { timeout: 15000 });
return "✓ Gateway restarted";
} else {
execSync("systemctl --user restart openclaw-proxy 2>/dev/null || pkill -f 'node.*server.mjs' && sleep 2 && cd ~/.openclaw/projects/*/; node server.mjs &", { timeout: 15000, shell: true });
execSync("systemctl --user restart ocp-proxy", { timeout: 15000 });
return "✓ Proxy restarted";
}
} catch (e2) {
return `✗ Restart failed: ${e2.message?.slice(0, 100)}`;
return `✗ Restart failed: ${e2.message?.slice(0, 100)}. Run \`ocp restart\` on the server host manually.`;
}
}
}
+2 -2
View File
@@ -2,7 +2,7 @@
"id": "ocp",
"name": "OCP Commands",
"description": "Slash commands for the OpenClaw Proxy — /ocp usage, /ocp settings, /ocp health, etc.",
"version": "3.12.0",
"version": "3.16.2",
"configSchema": {
"type": "object",
"additionalProperties": false,
@@ -10,7 +10,7 @@
"proxyUrl": {
"type": "string",
"default": "http://127.0.0.1:3456",
"description": "URL of the Claude proxy"
"description": "URL of the Claude proxy. Overridable via OCP_PROXY_URL or CLAUDE_PROXY_PORT env."
}
}
}
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "ocp",
"version": "3.12.0",
"version": "3.16.2",
"description": "Slash commands for the OpenClaw Proxy",
"main": "index.js",
"type": "module",
@@ -9,6 +9,7 @@
"openclaw": {
"type": "plugin",
"id": "ocp",
"pluginManifest": "openclaw.plugin.json"
"pluginManifest": "openclaw.plugin.json",
"extensions": ["./index.js"]
}
}
-182
View File
@@ -1,182 +0,0 @@
# openclaw-claude-proxy v2.3.0
Use your **Claude Pro / Max** subscription as an **OpenAI-compatible local endpoint**.
`openclaw-claude-proxy` accepts OpenAI-style chat completion requests, then runs them through the local `claude` CLI. That means tools which only know how to talk to an OpenAI API can still use Claude models through a local base URL.
## Why v2 matters
v2 is not just a bugfix release. It changes the runtime model:
- **On-demand spawning** instead of fragile warm pools
- **Session resume** support for multi-turn conversations
- **Faster fallback** with first-byte timeout + lower default request timeout
- **Full tool access** via configurable allowed tools
- **MCP config + system prompt pass-through**
- **Health / sessions / diagnostics endpoints**
- **Safe coexistence with Claude Code channel / interactive mode**
## The short pitch
If Claude's new channel workflow feels useful, OCP v2 now covers the same practical ground for many local agent/tooling setups:
- multi-turn continuity
- tool-enabled Claude runs
- local orchestration
- stable process isolation
- coexistence with your normal Claude Code workflow
And it adds a few advantages that channel users usually still want:
- **OpenAI-compatible HTTP API** for existing tools
- **Works with OpenClaw, Cursor, Continue, Open WebUI, LangChain, and anything with custom base URL support**
- **Explicit health checks and diagnostics**
- **Model/provider failover can happen outside Claude itself**
- **No lock-in to a single client UX**
## Coexistence with Claude Code channel
This is the important part: **OCP v2 does not replace Claude Code channel, and it does not need to. They can coexist on the same machine.**
### Claude Code channel / interactive mode
- persistent interactive workflow
- MCP protocol / in-process experience
- great when you are directly driving Claude Code
### OCP v2
- local HTTP server on `localhost`
- OpenAI-compatible API surface
- per-request `claude -p` execution with session resume when you want continuity
- ideal for external tools, routers, orchestrators, OpenClaw providers, and local automation
### Practical takeaway
Use both:
- use **Claude Code channel** when you want Claude's native interactive workflow
- use **OCP v2** when another app expects an OpenAI-style API but you still want to use Claude
They solve adjacent problems, not identical ones.
## Unique advantages of OCP v2
1. **API compatibility**
- Drop into tools that already support OpenAI-compatible endpoints.
- No need to wait for each tool to add native Claude channel support.
2. **Routing freedom**
- Put OCP behind OpenClaw or another router.
- Mix Claude with fallback providers outside the Claude client itself.
3. **Operational visibility**
- `/health`, `/sessions`, recent errors, auth state, resolved binary path, timeout config.
- Much easier to debug than a black-box local integration.
4. **Safer runtime model**
- v2 removes the old pre-spawn pool crash loop.
- No stale workers, no degraded warm pool states, fewer hidden failure modes.
5. **Configurable tools and behavior**
- allowed tools
- skip permissions mode
- system prompt append
- MCP config passthrough
- session TTL
- concurrency limits
## Install
```bash
git clone https://github.com/dtzp555-max/openclaw-claude-proxy
cd openclaw-claude-proxy
npm install
node server.mjs
```
Default base URL:
```text
http://127.0.0.1:3456/v1
```
## Quick OpenAI-compatible config
```json
{
"baseURL": "http://127.0.0.1:3456/v1",
"apiKey": "anything"
}
```
If `PROXY_API_KEY` is unset, auth is disabled. If you set it, pass it as a Bearer token.
## Environment variables
| Variable | Default | Purpose |
|---|---:|---|
| `CLAUDE_PROXY_PORT` | `3456` | Listen port |
| `CLAUDE_BIN` | auto-detect | Claude CLI binary path |
| `CLAUDE_TIMEOUT` | `120000` | Overall per-request timeout |
| `CLAUDE_FIRST_BYTE_TIMEOUT` | `30000` | Abort if Claude produces no stdout quickly |
| `CLAUDE_ALLOWED_TOOLS` | expanded set | Comma-separated allowed tools |
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass permission checks |
| `CLAUDE_SYSTEM_PROMPT` | unset | Append a system prompt to every request |
| `CLAUDE_MCP_CONFIG` | unset | Path to MCP config JSON |
| `CLAUDE_SESSION_TTL` | `3600000` | Session TTL |
| `CLAUDE_MAX_CONCURRENT` | `5` | Max concurrent Claude processes |
| `PROXY_API_KEY` | unset | Optional Bearer token auth |
## Endpoints
- `GET /health`
- `GET /v1/models`
- `POST /v1/chat/completions`
- `GET /sessions`
- `DELETE /sessions`
## Example health response highlights
`/health` reports useful operational state such as:
- resolved Claude binary path
- whether the binary is executable
- auth status
- timeouts
- current sessions
- recent errors
- basic request stats
## Version highlights
### v2.3.0
- clarified v2 positioning and coexistence story in docs
- officially documents faster fallback defaults
- recommends OCP v2 as the API bridge layer for Claude-powered tools
### v2.2.0
- first-byte timeout
- reduced default timeout for faster fallback
### v2.0.0
- on-demand architecture
- session management
- full tool access
- MCP + system prompt passthrough
- concurrency control
- coexistence with Claude Code interactive mode
## When to use OCP v2 vs Claude channel
Choose **OCP v2** when:
- your app only supports OpenAI-compatible endpoints
- you want routing / failover outside Claude
- you want explicit health checks and local diagnostics
- you want Claude available to multiple local tools through one endpoint
Choose **Claude channel** when:
- you are primarily living inside Claude Code itself
- you want Claude's native interactive workflow directly
Use **both together** when you want the best of both worlds.
---
If you already pay for Claude Pro or Max, OCP v2 turns that subscription into a practical local API bridge for the rest of your tooling stack.
-28
View File
@@ -1,28 +0,0 @@
{
"name": "openclaw-claude-proxy",
"version": "2.4.0",
"description": "OpenAI-compatible proxy for Claude CLI v2 — per-model circuit breaker, adaptive first-byte timeout, structured logging",
"type": "module",
"bin": {
"openclaw-claude-proxy": "./server.mjs"
},
"scripts": {
"start": "node server.mjs",
"setup": "node setup.mjs"
},
"keywords": [
"openclaw",
"claude",
"proxy",
"openai",
"anthropic"
],
"license": "MIT",
"engines": {
"node": ">=18"
},
"repository": {
"type": "git",
"url": "https://github.com/dtzp555-max/openclaw-claude-proxy"
}
}
-643
View File
@@ -1,643 +0,0 @@
#!/usr/bin/env node
/**
* openclaw-claude-proxy v2.4.0 OpenAI-compatible proxy for Claude CLI
*
* Translates OpenAI chat/completions requests into `claude -p` CLI calls,
* letting you use your Claude Pro/Max subscription as an OpenClaw model provider.
*
* v2.4.0:
* - Per-model circuit breaker: consecutive timeouts temporarily mark a model as degraded
* - Adaptive first-byte timeout: scales by model tier + prompt size
* - Structured JSON logging for key events (easier to parse/alert on)
* - On-demand spawning (no pool), session management, full tool access
*
* Env vars:
* CLAUDE_PROXY_PORT listen port (default: 3456)
* CLAUDE_BIN path to claude binary (default: auto-detect)
* CLAUDE_TIMEOUT per-request timeout in ms (default: 120000)
* CLAUDE_FIRST_BYTE_TIMEOUT base first-byte timeout in ms (default: 45000)
* CLAUDE_ALLOWED_TOOLS comma-separated tools to allow (default: expanded set)
* CLAUDE_SKIP_PERMISSIONS "true" to bypass all permission checks (default: false)
* CLAUDE_SYSTEM_PROMPT system prompt appended to all requests
* CLAUDE_MCP_CONFIG path to MCP server config JSON file
* CLAUDE_SESSION_TTL session TTL in ms (default: 3600000 = 1h)
* CLAUDE_MAX_CONCURRENT max concurrent claude processes (default: 5)
* CLAUDE_BREAKER_THRESHOLD consecutive timeouts before circuit opens (default: 3)
* CLAUDE_BREAKER_COOLDOWN ms to wait before retrying after circuit opens (default: 60000)
* PROXY_API_KEY Bearer token for API auth (optional)
*/
import { createServer } from "node:http";
import { spawn, execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { readFileSync, accessSync, constants } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
// ── Resolve claude binary ───────────────────────────────────────────────
// Priority: CLAUDE_BIN env > well-known paths > which lookup
// Fail-fast if not found — never start with an unresolvable binary.
function resolveClaude() {
if (process.env.CLAUDE_BIN) {
try {
accessSync(process.env.CLAUDE_BIN, constants.X_OK);
return process.env.CLAUDE_BIN;
} catch {
console.error(`FATAL: CLAUDE_BIN="${process.env.CLAUDE_BIN}" is set but not executable.`);
process.exit(1);
}
}
const candidates = [
"/opt/homebrew/bin/claude",
"/usr/local/bin/claude",
"/usr/bin/claude",
join(process.env.HOME || "", ".local/bin/claude"),
];
for (const p of candidates) {
try { accessSync(p, constants.X_OK); console.warn(`[init] CLAUDE_BIN not set, resolved to ${p}`); return p; } catch {}
}
try {
const resolved = execFileSync("which", ["claude"], { encoding: "utf8", timeout: 5000 }).trim();
if (resolved) { console.warn(`[init] CLAUDE_BIN not set, resolved via which: ${resolved}`); return resolved; }
} catch {}
console.error(
"FATAL: claude binary not found.\n" +
" Set CLAUDE_BIN=/path/to/claude or ensure claude is in PATH.\n" +
" Checked: " + candidates.join(", ")
);
process.exit(1);
}
// ── Configuration ───────────────────────────────────────────────────────
const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10);
const CLAUDE = resolveClaude();
const TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "120000", 10);
const BASE_FIRST_BYTE_TIMEOUT = parseInt(process.env.CLAUDE_FIRST_BYTE_TIMEOUT || "45000", 10);
const PROXY_API_KEY = process.env.PROXY_API_KEY || "";
const SKIP_PERMISSIONS = process.env.CLAUDE_SKIP_PERMISSIONS === "true";
const ALLOWED_TOOLS = (process.env.CLAUDE_ALLOWED_TOOLS ||
"Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch,Agent"
).split(",").map(s => s.trim()).filter(Boolean);
const SYSTEM_PROMPT = process.env.CLAUDE_SYSTEM_PROMPT || "";
const MCP_CONFIG = process.env.CLAUDE_MCP_CONFIG || "";
const SESSION_TTL = parseInt(process.env.CLAUDE_SESSION_TTL || "3600000", 10);
const MAX_CONCURRENT = parseInt(process.env.CLAUDE_MAX_CONCURRENT || "5", 10);
const BREAKER_THRESHOLD = parseInt(process.env.CLAUDE_BREAKER_THRESHOLD || "3", 10);
const BREAKER_COOLDOWN = parseInt(process.env.CLAUDE_BREAKER_COOLDOWN || "60000", 10);
const VERSION = _pkg.version;
const START_TIME = Date.now();
// ── Structured logging helper ───────────────────────────────────────────
function logEvent(level, event, data = {}) {
const entry = { ts: new Date().toISOString(), level, event, ...data };
if (level === "error" || level === "warn") {
console.error(JSON.stringify(entry));
} else {
console.log(JSON.stringify(entry));
}
}
// ── Per-model circuit breaker ───────────────────────────────────────────
// Tracks consecutive timeouts per model. When threshold is reached, the
// model is marked "open" (degraded) for BREAKER_COOLDOWN ms. During that
// window, requests for this model fail fast with a clear error instead of
// waiting for yet another timeout that would block the gateway.
const breakers = new Map(); // cliModel → { failures, state, openedAt }
function getBreakerState(cliModel) {
if (!breakers.has(cliModel)) {
breakers.set(cliModel, { failures: 0, state: "closed", openedAt: 0 });
}
const b = breakers.get(cliModel);
// Auto-recover: if cooldown has elapsed, transition to half-open
if (b.state === "open" && Date.now() - b.openedAt >= BREAKER_COOLDOWN) {
b.state = "half-open";
logEvent("info", "breaker_half_open", { model: cliModel, cooldownMs: BREAKER_COOLDOWN });
}
return b;
}
function breakerRecordSuccess(cliModel) {
const b = getBreakerState(cliModel);
if (b.failures > 0 || b.state !== "closed") {
logEvent("info", "breaker_reset", { model: cliModel, previousFailures: b.failures, previousState: b.state });
}
b.failures = 0;
b.state = "closed";
b.openedAt = 0;
}
function breakerRecordTimeout(cliModel) {
const b = getBreakerState(cliModel);
b.failures++;
logEvent("warn", "breaker_failure", { model: cliModel, consecutiveFailures: b.failures, threshold: BREAKER_THRESHOLD });
if (b.failures >= BREAKER_THRESHOLD && b.state !== "open") {
b.state = "open";
b.openedAt = Date.now();
logEvent("error", "breaker_open", { model: cliModel, failures: b.failures, cooldownMs: BREAKER_COOLDOWN });
}
}
// ── Model mapping ───────────────────────────────────────────────────────
// Maps request model IDs and aliases to canonical claude CLI model IDs.
const MODEL_MAP = {
"claude-opus-4-6": "claude-opus-4-6",
"claude-sonnet-4-6": "claude-sonnet-4-6",
"claude-haiku-4-5-20251001": "claude-haiku-4-5-20251001",
"claude-opus-4": "claude-opus-4-6",
"claude-haiku-4": "claude-haiku-4-5-20251001",
"claude-haiku-4-5": "claude-haiku-4-5-20251001",
"opus": "claude-opus-4-6",
"sonnet": "claude-sonnet-4-6",
"haiku": "claude-haiku-4-5-20251001",
};
const MODELS = [
{ id: "claude-opus-4-6", name: "Claude Opus 4.6" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
{ id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5" },
];
// ── Session management ──────────────────────────────────────────────────
// Maps conversation IDs (from caller) to Claude CLI session UUIDs.
// Enables --resume for multi-turn conversations, reducing token waste.
const sessions = new Map(); // conversationId → { uuid, messageCount, lastUsed, model }
setInterval(() => {
const now = Date.now();
for (const [id, s] of sessions) {
if (now - s.lastUsed > SESSION_TTL) {
sessions.delete(id);
console.log(`[session] expired ${id.slice(0, 12)}... (idle ${Math.round((now - s.lastUsed) / 60000)}m)`);
}
}
}, 60000);
// ── Stats & diagnostics ─────────────────────────────────────────────────
const stats = {
totalRequests: 0,
activeRequests: 0,
errors: 0,
timeouts: 0,
sessionHits: 0,
sessionMisses: 0,
oneOffRequests: 0,
};
const recentErrors = []; // last 20 errors
function trackError(msg) {
stats.errors++;
recentErrors.push({ time: new Date().toISOString(), message: String(msg).slice(0, 200) });
if (recentErrors.length > 20) recentErrors.shift();
}
// ── Auth health check ───────────────────────────────────────────────────
let authStatus = { ok: null, lastCheck: 0, message: "" };
async function checkAuth() {
try {
const env = { ...process.env };
delete env.CLAUDECODE;
delete env.ANTHROPIC_API_KEY;
delete env.ANTHROPIC_BASE_URL;
delete env.ANTHROPIC_AUTH_TOKEN;
execFileSync(CLAUDE, ["auth", "status"], { encoding: "utf8", timeout: 10000, env });
authStatus = { ok: true, lastCheck: Date.now(), message: "authenticated" };
} catch (e) {
const msg = (e.stderr || e.message || "").slice(0, 200);
authStatus = { ok: false, lastCheck: Date.now(), message: msg };
console.error(`[auth] check failed: ${msg}`);
}
}
// Check auth on start and every 10 minutes
checkAuth();
setInterval(checkAuth, 600000);
// ── Build CLI arguments ─────────────────────────────────────────────────
function buildCliArgs(cliModel, sessionInfo) {
const args = ["-p", "--model", cliModel, "--output-format", "text"];
// Session handling
if (sessionInfo?.resume) {
args.push("--resume", sessionInfo.uuid);
} else if (sessionInfo?.uuid) {
args.push("--session-id", sessionInfo.uuid);
} else {
args.push("--no-session-persistence");
}
// Permissions
if (SKIP_PERMISSIONS) {
args.push("--dangerously-skip-permissions");
} else if (ALLOWED_TOOLS.length > 0) {
args.push("--allowedTools", ...ALLOWED_TOOLS);
}
// System prompt
if (SYSTEM_PROMPT) {
args.push("--append-system-prompt", SYSTEM_PROMPT);
}
// MCP config
if (MCP_CONFIG) {
args.push("--mcp-config", MCP_CONFIG);
}
return args;
}
// ── Format messages to prompt text ──────────────────────────────────────
function messagesToPrompt(messages) {
return messages.map((m) => {
const text = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
if (m.role === "system") return `[System] ${text}`;
if (m.role === "assistant") return `[Assistant] ${text}`;
return text;
}).join("\n\n");
}
// Model tier multipliers for first-byte timeout.
// Opus is much slower to produce first token, especially with large contexts.
const MODEL_TIMEOUT_TIERS = {
"opus": { base: 60000, perPromptChar: 0.00015 }, // 60s base + ~15s per 100k chars
"sonnet": { base: 45000, perPromptChar: 0.00008 }, // 45s base + ~8s per 100k chars
"haiku": { base: 30000, perPromptChar: 0.00005 }, // 30s base + ~5s per 100k chars
};
function getModelTier(cliModel) {
if (cliModel.includes("opus")) return "opus";
if (cliModel.includes("haiku")) return "haiku";
return "sonnet";
}
function computeFirstByteTimeout(cliModel, promptLength) {
const tier = MODEL_TIMEOUT_TIERS[getModelTier(cliModel)];
const timeout = tier.base + Math.floor(promptLength * tier.perPromptChar);
return Math.min(timeout, Math.max(TIMEOUT - 5000, 10000));
}
// ── Call claude CLI ─────────────────────────────────────────────────────
// On-demand spawning: each request spawns a fresh `claude -p` process.
// No pool = no crash loops, no stale workers, no degraded states.
// Stdin is written immediately so there's no 3s stdin timeout issue.
function callClaude(model, messages, conversationId) {
return new Promise((resolve, reject) => {
if (stats.activeRequests >= MAX_CONCURRENT) {
return reject(new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`));
}
const cliModel = MODEL_MAP[model] || model;
// Circuit breaker check: fail fast if model is in open state
const breaker = getBreakerState(cliModel);
if (breaker.state === "open") {
const remainingMs = BREAKER_COOLDOWN - (Date.now() - breaker.openedAt);
logEvent("warn", "breaker_rejected", { model: cliModel, remainingCooldownMs: remainingMs });
return reject(new Error(`circuit breaker open for ${cliModel}: ${breaker.failures} consecutive timeouts, retry in ${Math.ceil(remainingMs / 1000)}s`));
}
stats.activeRequests++;
stats.totalRequests++;
let sessionInfo = null;
let prompt;
// ── Session logic ──
if (conversationId && sessions.has(conversationId)) {
// Resume existing session: only send the latest user message
const session = sessions.get(conversationId);
session.lastUsed = Date.now();
sessionInfo = { uuid: session.uuid, resume: true };
stats.sessionHits++;
const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
prompt = lastUserMsg
? (typeof lastUserMsg.content === "string" ? lastUserMsg.content : JSON.stringify(lastUserMsg.content))
: "";
session.messageCount = messages.length;
console.log(`[session] resume conv=${conversationId.slice(0, 12)}... uuid=${session.uuid.slice(0, 8)}... msgs=${messages.length} prompt_chars=${prompt.length}`);
} else if (conversationId) {
// New session: send all messages, persist session for future --resume
const uuid = randomUUID();
sessions.set(conversationId, { uuid, messageCount: messages.length, lastUsed: Date.now(), model: cliModel });
sessionInfo = { uuid, resume: false };
stats.sessionMisses++;
prompt = messagesToPrompt(messages);
console.log(`[session] new conv=${conversationId.slice(0, 12)}... uuid=${uuid.slice(0, 8)}... msgs=${messages.length}`);
} else {
// One-off request, no session
stats.oneOffRequests++;
prompt = messagesToPrompt(messages);
}
const cliArgs = buildCliArgs(cliModel, sessionInfo);
const env = { ...process.env };
delete env.CLAUDECODE;
delete env.ANTHROPIC_API_KEY;
delete env.ANTHROPIC_BASE_URL;
delete env.ANTHROPIC_AUTH_TOKEN;
const proc = spawn(CLAUDE, cliArgs, { env, stdio: ["pipe", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
const t0 = Date.now();
const firstByteTimeoutMs = computeFirstByteTimeout(cliModel, prompt.length);
let settled = false;
let gotFirstByte = false;
function settle(err, result) {
if (settled) return;
settled = true;
clearTimeout(timer);
clearTimeout(firstByteTimer);
stats.activeRequests--;
if (err) {
trackError(err.message || String(err));
// If session resume failed, remove session so next request starts fresh
if (sessionInfo?.resume && conversationId) {
console.warn(`[session] resume failed for ${conversationId.slice(0, 12)}..., removing stale session`);
sessions.delete(conversationId);
}
reject(err);
} else {
resolve(result);
}
}
proc.stdout.on("data", (d) => {
if (!gotFirstByte) {
gotFirstByte = true;
clearTimeout(firstByteTimer);
console.log(`[claude] first-byte model=${cliModel} elapsed=${Date.now() - t0}ms`);
}
stdout += d;
});
proc.stderr.on("data", (d) => (stderr += d));
proc.on("close", (code, signal) => {
const elapsed = Date.now() - t0;
if (settled) {
logEvent("warn", "late_close", { model: cliModel, code, signal: signal || "none", elapsed });
return;
}
if (code !== 0) {
logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) });
settle(new Error(stderr.slice(0, 300) || stdout.slice(0, 300) || `claude exit ${code}`));
} else {
breakerRecordSuccess(cliModel);
logEvent("info", "claude_ok", { model: cliModel, chars: stdout.length, elapsed, session: conversationId ? conversationId.slice(0, 12) + "..." : "none" });
settle(null, stdout.trim());
}
});
proc.on("error", (err) => {
console.error(`[claude] spawn error: ${err.message}`);
settle(err);
});
// Write prompt to stdin immediately — no idle timeout issue
proc.stdin.write(prompt);
proc.stdin.end();
logEvent("info", "claude_spawned", { model: cliModel, promptChars: prompt.length, firstByteTimeout: firstByteTimeoutMs, tier: getModelTier(cliModel), session: conversationId ? conversationId.slice(0, 12) + "..." : "none" });
// First-byte timeout: abort early if Claude CLI produces no output
const firstByteTimer = setTimeout(() => {
if (!gotFirstByte && !settled) {
stats.timeouts++;
breakerRecordTimeout(cliModel);
logEvent("error", "first_byte_timeout", { model: cliModel, timeoutMs: firstByteTimeoutMs, promptChars: prompt.length });
try { proc.kill("SIGTERM"); } catch {}
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
settle(new Error(`first-byte timeout after ${firstByteTimeoutMs}ms`));
}
}, firstByteTimeoutMs);
// Overall request timeout with graceful kill
const timer = setTimeout(() => {
if (settled) return;
stats.timeouts++;
breakerRecordTimeout(cliModel);
logEvent("error", "request_timeout", { model: cliModel, timeoutMs: TIMEOUT });
try { proc.kill("SIGTERM"); } catch {}
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
settle(new Error(`timeout after ${TIMEOUT}ms`));
}, TIMEOUT);
});
}
// ── Response helpers ────────────────────────────────────────────────────
function jsonResponse(res, status, data) {
if (res.headersSent || res.writableEnded || res.destroyed) return;
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(data));
}
function sendSSE(res, data) {
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
function streamResponse(res, id, model, content) {
if (res.headersSent || res.writableEnded || res.destroyed) return;
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
});
const created = Math.floor(Date.now() / 1000);
sendSSE(res, {
id, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
});
for (let i = 0; i < content.length; i += 500) {
sendSSE(res, {
id, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: { content: content.slice(i, i + 500) }, finish_reason: null }],
});
}
sendSSE(res, {
id, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
});
res.write("data: [DONE]\n\n");
res.end();
}
function completionResponse(res, id, model, content) {
jsonResponse(res, 200, {
id, object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model,
choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }],
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
});
}
// ── Handle chat completions ─────────────────────────────────────────────
async function handleChatCompletions(req, res) {
let body = "";
for await (const chunk of req) body += chunk;
let parsed;
try { parsed = JSON.parse(body); } catch { return jsonResponse(res, 400, { error: "Invalid JSON" }); }
const messages = parsed.messages || parsed.input || [{ role: "user", content: parsed.prompt || "" }];
const model = parsed.model || "claude-sonnet-4-6";
const stream = parsed.stream;
// Session ID: from request body, header, or null (one-off)
const conversationId = parsed.session_id || parsed.conversation_id || req.headers["x-session-id"] || req.headers["x-conversation-id"] || null;
if (!messages?.length) return jsonResponse(res, 400, { error: "messages required" });
try {
const content = await callClaude(model, messages, conversationId);
const id = `chatcmpl-${randomUUID()}`;
if (stream) {
streamResponse(res, id, model, content);
} else {
completionResponse(res, id, model, content);
}
} catch (err) {
console.error(`[proxy] error: ${err.message}`);
if (res.headersSent || res.writableEnded || res.destroyed) {
try { res.end(); } catch {}
return;
}
jsonResponse(res, 500, { error: { message: err.message, type: "proxy_error" } });
}
}
// ── HTTP server ─────────────────────────────────────────────────────────
const server = createServer(async (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Session-Id, X-Conversation-Id");
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
// Bearer token auth (skip for /health and when PROXY_API_KEY is not set)
if (PROXY_API_KEY && req.url !== "/health") {
const auth = req.headers["authorization"] || "";
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
if (token !== PROXY_API_KEY) {
return jsonResponse(res, 401, { error: { message: "Unauthorized: invalid or missing Bearer token", type: "auth_error" } });
}
}
// GET /v1/models
if (req.url === "/v1/models" && req.method === "GET") {
return jsonResponse(res, 200, {
object: "list",
data: MODELS.map((m) => ({
id: m.id, object: "model", owned_by: "anthropic",
created: Math.floor(Date.now() / 1000),
})),
});
}
// POST /v1/chat/completions
if (req.url === "/v1/chat/completions" && req.method === "POST") {
return handleChatCompletions(req, res);
}
// GET /health — comprehensive diagnostics
if (req.url === "/health") {
let binaryOk = false;
try { accessSync(CLAUDE, constants.X_OK); binaryOk = true; } catch {}
const uptimeMs = Date.now() - START_TIME;
const sessionList = [];
for (const [id, s] of sessions) {
sessionList.push({
id: id.slice(0, 12) + "...",
model: s.model,
messages: s.messageCount,
idleMs: Date.now() - s.lastUsed,
});
}
return jsonResponse(res, 200, {
status: binaryOk && authStatus.ok !== false ? "ok" : "degraded",
version: VERSION,
architecture: "on-demand (v2)",
uptime: uptimeMs,
uptimeHuman: `${Math.floor(uptimeMs / 3600000)}h ${Math.floor((uptimeMs % 3600000) / 60000)}m`,
claudeBinary: CLAUDE,
claudeBinaryOk: binaryOk,
auth: authStatus,
config: {
timeout: TIMEOUT,
firstByteTimeout: BASE_FIRST_BYTE_TIMEOUT,
maxConcurrent: MAX_CONCURRENT,
sessionTTL: SESSION_TTL,
allowedTools: SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS,
systemPrompt: SYSTEM_PROMPT ? `${SYSTEM_PROMPT.slice(0, 50)}...` : "(none)",
mcpConfig: MCP_CONFIG || "(none)",
},
stats,
sessions: sessionList,
recentErrors: recentErrors.slice(-5),
});
}
// DELETE /sessions — clear all sessions
if (req.url === "/sessions" && req.method === "DELETE") {
const count = sessions.size;
sessions.clear();
return jsonResponse(res, 200, { cleared: count });
}
// GET /sessions — list active sessions
if (req.url === "/sessions" && req.method === "GET") {
const list = [];
for (const [id, s] of sessions) {
list.push({ id, uuid: s.uuid, model: s.model, messages: s.messageCount, lastUsed: new Date(s.lastUsed).toISOString() });
}
return jsonResponse(res, 200, { sessions: list });
}
// Catch-all POST
if (req.method === "POST") {
return handleChatCompletions(req, res);
}
jsonResponse(res, 404, { error: "Not found. Endpoints: GET /v1/models, POST /v1/chat/completions, GET /health, GET|DELETE /sessions" });
});
// ── Start ───────────────────────────────────────────────────────────────
server.listen(PORT, "0.0.0.0", () => {
console.log(`openclaw-claude-proxy v${VERSION} listening on http://0.0.0.0:${PORT}`);
console.log(`Architecture: on-demand spawning (no pool)`);
console.log(`Models: ${MODELS.map((m) => m.id).join(", ")}`);
console.log(`Claude binary: ${CLAUDE}`);
console.log(`Timeout: ${TIMEOUT}ms (base first-byte: ${BASE_FIRST_BYTE_TIMEOUT}ms, adaptive by model/prompt) | Max concurrent: ${MAX_CONCURRENT}`);
console.log(`Tools: ${SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS.join(", ")}`);
console.log(`Sessions: TTL=${SESSION_TTL / 1000}s`);
if (SYSTEM_PROMPT) console.log(`System prompt: "${SYSTEM_PROMPT.slice(0, 80)}..."`);
if (MCP_CONFIG) console.log(`MCP config: ${MCP_CONFIG}`);
console.log(`Auth: ${PROXY_API_KEY ? "enabled (PROXY_API_KEY set)" : "disabled (no PROXY_API_KEY)"}`);
console.log(`---`);
console.log(`Coexistence: This proxy does NOT conflict with Claude Code interactive mode.`);
console.log(` OCP uses: localhost:${PORT} (HTTP) → claude -p (per-request process)`);
console.log(` CC uses: MCP protocol (in-process) → persistent session`);
console.log(` Both can run simultaneously on the same machine.`);
});
-20
View File
@@ -1,20 +0,0 @@
{
"name": "openclaw-claude-proxy",
"version": "3.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openclaw-claude-proxy",
"version": "3.4.0",
"license": "MIT",
"bin": {
"ocp": "ocp",
"openclaw-claude-proxy": "server.mjs"
},
"engines": {
"node": ">=18"
}
}
}
}
+5 -4
View File
@@ -1,6 +1,6 @@
{
"name": "openclaw-claude-proxy",
"version": "3.12.0",
"name": "open-claude-proxy",
"version": "3.24.0",
"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.",
"type": "module",
"bin": {
@@ -9,7 +9,8 @@
},
"scripts": {
"start": "node server.mjs",
"setup": "node setup.mjs"
"setup": "node setup.mjs",
"test": "node test-features.mjs"
},
"keywords": [
"openclaw",
@@ -20,7 +21,7 @@
],
"license": "MIT",
"engines": {
"node": ">=18"
"node": ">=22.5"
},
"repository": {
"type": "git",
+311
View File
@@ -0,0 +1,311 @@
#!/usr/bin/env node
/**
* scripts/doctor.mjs OCP health & upgrade-readiness check.
*
* Usage:
* ocp doctor human-readable PASS/WARN/FAIL
* ocp doctor --json machine-readable JSON for AI agents + ocp update
* ocp doctor --check oauth fast path: only OAuth check
*
* Exit codes:
* 0 all PASS or WARN-only
* 1 any FAIL
*/
import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { execSync } from "node:child_process";
import { DEFAULT_PORT } from "../lib/constants.mjs";
const SCHEMA_VERSION = "1";
function semverParts(v) {
const m = String(v).replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)/);
if (!m) return null;
return { major: +m[1], minor: +m[2], patch: +m[3] };
}
function semverCompare(a, b) {
const A = semverParts(a), B = semverParts(b);
if (!A || !B) return 0;
if (A.major !== B.major) return A.major - B.major;
if (A.minor !== B.minor) return A.minor - B.minor;
return A.patch - B.patch;
}
export async function runDoctor(opts = {}) {
const checks = [];
const push = (id, level, message, extra = {}) =>
checks.push({ id, level, message, ...extra });
// --- fast path: --check oauth ---
if (opts.checkOnly === "oauth") {
return runOauthOnly(opts, checks, push);
}
// --- version detection ---
const ocpDir = opts.ocpDir || join(homedir(), "ocp");
let currentVersion = opts.mockVersion;
if (!currentVersion) {
try {
const pkg = JSON.parse(readFileSync(join(ocpDir, "package.json"), "utf8"));
currentVersion = `v${pkg.version}`;
} catch {
currentVersion = "unknown";
}
}
// Resolve latest from origin/main (cheap: `git show origin/main:package.json`).
// Falls back to current_version when network/git unavailable, so kind = noop instead
// of recommending a downgrade against a stale hardcoded value.
let latestVersion = opts.mockLatest;
if (!latestVersion) {
// Issue #173: `git show origin/main:...` reads the LOCALLY CACHED remote ref. Without a
// fetch first, a machine that hasn't pulled since the last release sees latest == current
// and reports noop — new releases were invisible everywhere except the machine that cut
// the tag (live repro: Oracle VM, 2026-07-17). Fetch before comparing; on failure
// (offline, auth, timeout) fall through to the cached ref — the pre-existing behavior.
if (!opts.skipNetwork) {
try {
execSync(`git -C ${ocpDir} fetch --tags --quiet`, { stdio: ["pipe", "pipe", "pipe"], timeout: 15000 });
} catch { /* offline → compare against cached origin/main, as before */ }
}
try {
const out = execSync(`git -C ${ocpDir} show origin/main:package.json 2>/dev/null`, { stdio: ["pipe", "pipe", "pipe"] }).toString();
const remotePkg = JSON.parse(out);
latestVersion = `v${remotePkg.version}`;
} catch {
latestVersion = currentVersion;
}
}
push("current_version", "PASS", `current=${currentVersion}`);
// --- from-version supported? ---
const fromSupported = !!semverParts(currentVersion) && semverCompare(currentVersion, "v3.4.0") >= 0;
push("from_version_supported", fromSupported ? "PASS" : "FAIL",
fromSupported ? "≥ v3.4.0" : `${currentVersion} < v3.4.0; in-place upgrade not supported`);
// --- service health check (mockable) ---
let healthOk = true, oauthOk = true;
if (!opts.skipNetwork) {
let health;
if (opts.mockHealth !== undefined) {
health = opts.mockHealth;
} else {
try {
const port = process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT);
const out = execSync(`curl -sf --max-time 3 http://127.0.0.1:${port}/health`, { stdio: ["pipe", "pipe", "pipe"] }).toString();
health = { status: 200, body: JSON.parse(out) };
} catch (e) {
health = { error: String(e.message || e) };
}
}
if (health.error || health.status !== 200) {
healthOk = false;
push("service_running", "FAIL", `service unreachable: ${health.error || `status ${health.status}`}`);
} else if (!health.body || typeof health.body !== "object") {
healthOk = false;
push("service_running", "FAIL", "service /health returned 200 but empty/non-JSON body");
} else {
push("service_running", "PASS", "service responding on /health");
const authOk = health.body?.auth?.ok;
if (!authOk) {
oauthOk = false;
push("oauth_ok", "FAIL", `auth.ok=false: ${health.body?.auth?.message || "unknown"}`);
} else {
push("oauth_ok", "PASS", "OAuth token valid");
}
}
}
// --- determine next_action.kind (priority: fresh_install > fix_service > fix_oauth > noop > update > upgrade) ---
let kind;
if (!fromSupported) {
kind = "fresh_install";
} else if (!opts.skipNetwork && !healthOk) {
kind = "fix_service";
} else if (!opts.skipNetwork && !oauthOk) {
kind = "fix_oauth";
} else {
const cur = semverParts(currentVersion), lat = semverParts(latestVersion);
if (!cur) {
kind = "fresh_install";
} else if (semverCompare(currentVersion, latestVersion) === 0) {
kind = "noop";
} else if (lat && cur.major === lat.major && cur.minor === lat.minor) {
kind = "update";
} else {
kind = "upgrade";
}
}
// --- next_action shape ---
let next_action;
if (kind === "fresh_install") {
next_action = {
kind,
human_required: ["claude auth login (only if OAuth becomes invalid after reinstall)"],
ai_executable: [
`launchctl bootout gui/$(id -u)/ai.openclaw.proxy 2>/dev/null || true`,
`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`,
`mv ${join(homedir(), ".ocp")} ${join(homedir(), ".ocp.backup-")}$(date +%s) 2>/dev/null || true`,
`rm -rf ${ocpDir}`,
`git clone https://github.com/dtzp555-max/ocp ${ocpDir}`,
`cd ${ocpDir} && npm install --no-audit --no-fund && node setup.mjs`,
`${ocpDir}/ocp doctor`
],
verify: "ocp doctor expects PASS on all checks"
};
} else if (kind === "noop") {
next_action = { kind, human_required: [], ai_executable: [], verify: "already at latest" };
} else if (kind === "fix_oauth") {
next_action = {
kind,
human_required: [],
ai_executable: [
`cd "$(npm root -g)/@anthropic-ai/claude-code" && node install.cjs`,
`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`,
`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`,
`${ocpDir}/ocp doctor`
],
verify: "ocp doctor expects oauth_ok=PASS",
reference: "~/.cc-rules/memory/learnings/ocp_claude_native_binary_postinstall.md"
};
} else if (kind === "fix_service") {
next_action = {
kind,
human_required: [],
ai_executable: [
`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`,
`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`,
`${ocpDir}/ocp doctor`
],
verify: "ocp doctor expects service_running=PASS"
};
} else {
next_action = {
kind,
human_required: [],
ai_executable: [`${ocpDir}/ocp update --yes`],
verify: "ocp doctor expects PASS on all checks"
};
}
const fail_count = checks.filter(c => c.level === "FAIL").length;
const warn_count = checks.filter(c => c.level === "WARN").length;
return {
schema_version: SCHEMA_VERSION,
timestamp: new Date().toISOString(),
ready_to_upgrade: fail_count === 0,
current_version: currentVersion,
latest_version: latestVersion,
from_version_supported: fromSupported,
fail_count,
warn_count,
checks,
next_action
};
}
function runOauthOnly(opts, checks, push) {
let healthOk = true, oauthOk = true;
let health;
if (opts.mockHealth !== undefined) {
health = opts.mockHealth;
} else {
try {
const port = process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT);
const out = execSync(`curl -sf --max-time 3 http://127.0.0.1:${port}/health`, { stdio: ["pipe", "pipe", "pipe"] }).toString();
health = { status: 200, body: JSON.parse(out) };
} catch (e) {
health = { error: String(e.message || e) };
}
}
if (health.error || health.status !== 200) {
healthOk = false;
push("oauth_ok", "FAIL", `service unreachable: ${health.error || `status ${health.status}`}`);
} else if (!health.body || typeof health.body !== "object") {
healthOk = false;
push("oauth_ok", "FAIL", "service /health returned 200 but empty/non-JSON body");
} else if (!health.body?.auth?.ok) {
oauthOk = false;
push("oauth_ok", "FAIL", `auth.ok=false: ${health.body?.auth?.message || "unknown"}`);
} else {
push("oauth_ok", "PASS", "OAuth token valid");
}
const kind = !healthOk ? "fix_service" : !oauthOk ? "fix_oauth" : "noop";
let next_action;
const ocpDir = opts.ocpDir || join(homedir(), "ocp");
if (kind === "noop") {
next_action = { kind, human_required: [], ai_executable: [], verify: "OAuth healthy" };
} else if (kind === "fix_oauth") {
next_action = {
kind,
human_required: [],
ai_executable: [
`cd "$(npm root -g)/@anthropic-ai/claude-code" && node install.cjs`,
`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`,
`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`,
`${ocpDir}/ocp doctor --check oauth`
],
verify: "ocp doctor --check oauth expects PASS",
reference: "~/.cc-rules/memory/learnings/ocp_claude_native_binary_postinstall.md"
};
} else {
next_action = {
kind,
human_required: [],
ai_executable: [
`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`,
`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`,
`${ocpDir}/ocp doctor --check oauth`
],
verify: "ocp doctor --check oauth expects service_running=PASS"
};
}
const fail_count = checks.filter(c => c.level === "FAIL").length;
// "skipped" = --check oauth fast path intentionally omits version detection.
// AI agents should NOT semver-compare against current_version/latest_version when
// either equals "skipped"; the full path provides those fields when needed.
return {
schema_version: SCHEMA_VERSION,
timestamp: new Date().toISOString(),
ready_to_upgrade: fail_count === 0,
current_version: opts.mockVersion || "skipped",
latest_version: opts.mockLatest || "skipped",
from_version_supported: true,
fail_count,
warn_count: 0,
checks,
next_action
};
}
// CLI entrypoint — use fileURLToPath + realpath to handle symlinked install paths
// (e.g. /tmp/ → /private/tmp/ on macOS would otherwise miss the guard).
import { fileURLToPath } from "node:url";
import { realpathSync } from "node:fs";
function _isMain() {
if (!process.argv[1]) return false;
try {
return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
} catch { return false; }
}
if (_isMain()) {
const wantJson = process.argv.includes("--json");
const checkIdx = process.argv.indexOf("--check");
const checkOnly = checkIdx !== -1 ? process.argv[checkIdx + 1] : undefined;
const result = await runDoctor({ checkOnly });
if (wantJson) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(`OCP doctor — ${result.current_version}${result.latest_version}`);
for (const c of result.checks) console.log(` [${c.level}] ${c.id}: ${c.message}`);
console.log(`\nSummary: ${result.fail_count} FAIL, ${result.warn_count} WARN`);
console.log(`Next action: ${result.next_action.kind}`);
}
process.exit(result.fail_count === 0 ? 0 : 1);
}
+132
View File
@@ -0,0 +1,132 @@
#!/bin/bash
# One-shot field-evidence gatherer for OCP v3.12.0 SSE heartbeat.
# Scheduled by ~/Library/LaunchAgents/dev.ocp.heartbeat-check.plist to fire
# once at 2026-05-02 09:00 Australia/Brisbane. Gathers evidence from local
# OCP logs + GitHub issue #47 + repo issue search, posts a summary comment
# on #47, and exits. Does NOT open PRs or change code — the maintainer
# decides after reading the summary.
#
# Dry-run: ./heartbeat-field-check.sh --dry-run (prints summary, skips post)
set -euo pipefail
REPO="dtzp555-max/ocp"
SHIP_DATE="2026-04-25"
# Baseline captured at script-install time so internal testing entries from
# Phase 3 verification (~5 entries from 2026-04-25T00:0000:48Z) don't get
# counted as field evidence. Any heartbeat_active log entry with ts >= this
# timestamp is treated as a real opt-in.
BASELINE_TS="2026-04-25T01:00:00Z"
PROXY_LOG="$HOME/ocp/logs/proxy.log"
OUT_DIR="$HOME/ocp/logs"
SELF_LOG="$OUT_DIR/heartbeat-field-check-$(date +%Y-%m-%d).log"
DRY_RUN=0
[ "${1:-}" = "--dry-run" ] && DRY_RUN=1
mkdir -p "$OUT_DIR"
exec > >(tee -a "$SELF_LOG") 2>&1
echo "=== heartbeat field-evidence check: $(date -u +%Y-%m-%dT%H:%M:%SZ) (dry_run=$DRY_RUN) ==="
# ── signal 1: local proxy log ─────────────────────────────────────────────
if [ -r "$PROXY_LOG" ]; then
# Only count entries with ts >= BASELINE_TS (string sort works on RFC3339)
HEARTBEAT_COUNT=$(grep '"event":"heartbeat_active"' "$PROXY_LOG" 2>/dev/null \
| awk -v base="$BASELINE_TS" '
match($0, /"ts":"[^"]+"/) {
ts = substr($0, RSTART+6, RLENGTH-7);
if (ts >= base) c++
}
END { print c+0 }')
else
HEARTBEAT_COUNT=0
fi
echo "signal 1 — heartbeat_active log entries since $BASELINE_TS: $HEARTBEAT_COUNT"
# ── signal 2: comments on #47 since ship ──────────────────────────────────
NEW_47_JSON="/tmp/ocp-47-new-comments-$$.json"
gh issue view 47 --repo "$REPO" --json comments \
--jq '[.comments[] | select(.createdAt >= "'"$SHIP_DATE"'T00:00:00Z")]' \
> "$NEW_47_JSON" 2>/dev/null || echo "[]" > "$NEW_47_JSON"
NEW_COMMENTS=$(jq 'length' "$NEW_47_JSON")
echo "signal 2 — new comments on #47 since $SHIP_DATE: $NEW_COMMENTS"
# Build a compact, human-readable excerpt for the summary body
NEW_47_EXCERPT=""
if [ "$NEW_COMMENTS" -gt 0 ]; then
NEW_47_EXCERPT=$(jq -r '.[] | "- **@\(.author.login)** (\(.createdAt)): " + (.body | gsub("\r"; "") | split("\n")[0])[:180]' "$NEW_47_JSON")
fi
# ── signal 3: other heartbeat-related issues since ship ──────────────────
OTHER_ISSUES_JSON="/tmp/ocp-heartbeat-issues-$$.json"
gh search issues "repo:$REPO heartbeat" --json number,title,state,createdAt --limit 30 \
--jq '[.[] | select(.createdAt >= "'"$SHIP_DATE"'T00:00:00Z" and .number != 47 and .number != 48)]' \
> "$OTHER_ISSUES_JSON" 2>/dev/null || echo "[]" > "$OTHER_ISSUES_JSON"
OTHER_ISSUES=$(jq 'length' "$OTHER_ISSUES_JSON")
echo "signal 3 — other heartbeat-related issues since ship: $OTHER_ISSUES"
OTHER_ISSUES_EXCERPT=""
if [ "$OTHER_ISSUES" -gt 0 ]; then
OTHER_ISSUES_EXCERPT=$(jq -r '.[] | "- #\(.number) [\(.state)] \(.title)"' "$OTHER_ISSUES_JSON")
fi
# ── compose summary ──────────────────────────────────────────────────────
BODY_FILE="/tmp/ocp-47-summary-$$.md"
{
echo "### Automated 7-day field-evidence check (v3.12.0)"
echo
echo "_Triggered by a local launchd scheduled task on the maintainer's rig at $(date -u +%Y-%m-%dT%H:%M:%SZ)._"
echo
echo "| Signal | Count |"
echo "|---|---|"
echo "| \`heartbeat_active\` log entries on prod rig (since baseline $BASELINE_TS) | $HEARTBEAT_COUNT |"
echo "| New comments on #47 since $SHIP_DATE | $NEW_COMMENTS |"
echo "| Other heartbeat-related issues filed since $SHIP_DATE | $OTHER_ISSUES |"
echo
if [ -n "$NEW_47_EXCERPT" ]; then
echo "**New #47 comments (first line each):**"
echo
echo "$NEW_47_EXCERPT"
echo
fi
if [ -n "$OTHER_ISSUES_EXCERPT" ]; then
echo "**Other heartbeat-related issues:**"
echo
echo "$OTHER_ISSUES_EXCERPT"
echo
fi
echo "**Decision guidance for maintainer (manual):**"
echo
echo "- If any of the above indicate a **crash report** on \`: keepalive\` comment frames → leave default at \`0\` and file a \`CLAUDE_HEARTBEAT_FORMAT=empty-delta\` follow-up issue (spec \`§D2\` fallback plan)."
echo "- If there is at least one **opt-in confirmation** (a user reports \`CLAUDE_HEARTBEAT_INTERVAL\` fixed their timeout issue) and no crash reports → consider opening a PR for v3.13.0 flipping the default to \`30000\`, following the same ALIGNMENT + independent-reviewer + release-kit discipline as PR #49."
echo "- If all three signals are zero → extend the soak window or close this follow-up as \"no field evidence.\""
echo
echo "This bot does not open PRs or change code. The maintainer reviews and acts."
} > "$BODY_FILE"
echo "--- summary preview ---"
cat "$BODY_FILE"
echo "--- end preview ---"
# ── post (unless dry-run) ────────────────────────────────────────────────
if [ "$DRY_RUN" -eq 1 ]; then
echo "DRY RUN — skipping gh issue comment"
else
gh issue comment 47 --repo "$REPO" --body-file "$BODY_FILE" && echo "comment posted on #47"
fi
# ── cleanup + self-disable so the plist doesn't linger loaded forever ────
rm -f "$NEW_47_JSON" "$OTHER_ISSUES_JSON" "$BODY_FILE"
if [ "$DRY_RUN" -eq 0 ]; then
# Unload + remove the plist so this never fires again
PLIST="$HOME/Library/LaunchAgents/dev.ocp.heartbeat-check.plist"
if [ -f "$PLIST" ]; then
launchctl bootout "gui/$(id -u)" "$PLIST" 2>/dev/null || launchctl unload "$PLIST" 2>/dev/null || true
rm -f "$PLIST"
echo "self-disabled: removed $PLIST"
fi
fi
echo "=== done ==="
+101
View File
@@ -0,0 +1,101 @@
// scripts/lib/plist-merge.mjs
//
// Preserves user-customised env vars when setup.mjs rewrites the unit file.
//
// Rule:
// - keys present in NEW template → template value wins (template is source of truth)
// - keys ONLY in EXISTING (not in template) → preserved verbatim
//
// No new dependencies — regex-based, plist <key>X</key><string>Y</string> shape
// is stable enough for our hand-written templates in setup.mjs.
//
// SECURITY DENYLIST (A4): keys that must NEVER be carried into a service unit, even when a
// prior unit already contained them. OCP's key store honors OCP_DIR_OVERRIDE only when
// NODE_ENV === "test" (keys.mjs). If BOTH somehow reached a daemon's environment, the server
// would open a scratch/empty key store instead of ~/.ocp/ocp.db — in AUTH_MODE=multi a silent
// total auth outage. The preservation rule below ("keys only in EXISTING are kept verbatim")
// is exactly a vector for that: a unit that once carried these test-only vars would otherwise
// survive every setup re-run. So we strip them from the preserved set unconditionally. This is
// defense-in-depth: setup.mjs's own template never injects them, so the only way they enter is
// preservation, and this closes it. (The residual path — a hand-rolled `node server.mjs` with
// both vars exported — is out of any launcher's reach; keys.mjs's loud "NOT the default" log is
// the backstop there.)
export const NEVER_PRESERVE = new Set(["NODE_ENV", "OCP_DIR_OVERRIDE"]);
// Note: setup.mjs XML-escapes all injected values before writing (via xmlEscape()),
// so raw `<` / `>` / `&` never appear in plist <string> bodies — the [^<]* regex below is safe.
const PLIST_KV_RE = /<key>([^<]+)<\/key>\s*<string>([^<]*)<\/string>/g;
export function parsePlistEnv(plistContent) {
if (!plistContent) return {};
if (Buffer.isBuffer(plistContent)) plistContent = plistContent.toString("utf8");
// Restrict to the EnvironmentVariables dict to avoid catching Label, etc.
const envBlock = plistContent.match(/<key>EnvironmentVariables<\/key>\s*<dict>([\s\S]*?)<\/dict>/);
if (!envBlock) return {};
const out = {};
let m;
PLIST_KV_RE.lastIndex = 0;
while ((m = PLIST_KV_RE.exec(envBlock[1])) !== null) {
out[m[1]] = m[2];
}
return out;
}
export function mergePlistEnv(existing, template) {
if (!existing) return template;
const existingEnv = parsePlistEnv(existing);
const templateEnv = parsePlistEnv(template);
const KNOWN = new Set(Object.keys(templateEnv));
const preserved = {};
for (const [k, v] of Object.entries(existingEnv)) {
if (!KNOWN.has(k) && !NEVER_PRESERVE.has(k)) preserved[k] = v;
}
if (Object.keys(preserved).length === 0) return template;
const lines = Object.entries(preserved)
.map(([k, v]) => ` <key>${k}</key>\n <string>${v}</string>`)
.join("\n");
// Inject before the closing </dict> of EnvironmentVariables
return template.replace(
/(<key>EnvironmentVariables<\/key>\s*<dict>[\s\S]*?)(\n\s*<\/dict>)/,
`$1\n${lines}$2`
);
}
const SYSTEMD_KV_RE = /^Environment=([^=]+)=(.*)$/gm;
export function parseSystemdEnv(serviceContent) {
if (!serviceContent) return {};
if (Buffer.isBuffer(serviceContent)) serviceContent = serviceContent.toString("utf8");
const out = {};
let m;
SYSTEMD_KV_RE.lastIndex = 0;
while ((m = SYSTEMD_KV_RE.exec(serviceContent)) !== null) {
out[m[1]] = m[2];
}
return out;
}
export function mergeSystemdEnv(existing, template) {
if (!existing) return template;
const existingEnv = parseSystemdEnv(existing);
const templateEnv = parseSystemdEnv(template);
const KNOWN = new Set(Object.keys(templateEnv));
const preservedLines = Object.entries(existingEnv)
.filter(([k]) => !KNOWN.has(k) && !NEVER_PRESERVE.has(k))
.map(([k, v]) => `Environment=${k}=${v}`);
if (preservedLines.length === 0) return template;
// Guard: if template has no Environment= anchor, cannot inject — return template as-is.
// (In practice the OCP systemd template always has Environment= lines.)
if (!/^Environment=/m.test(template)) return template;
// Inject after the last existing Environment= line in the template
return template.replace(
/(^Environment=[^\n]+\n)((?!Environment=).*$)/ms,
`$1${preservedLines.join("\n")}\n$2`
);
}
+130
View File
@@ -0,0 +1,130 @@
import { mkdirSync, writeFileSync, readFileSync, copyFileSync, existsSync, readdirSync, statSync, rmSync } from "node:fs";
import { join } from "node:path";
export function writeSnapshot({ homeDir, fromCommit, fromVersion, toVersion, extraFiles = [] }) {
const ts = formatSnapshotTimestamp(new Date());
const root = join(homeDir, ".ocp", `upgrade-snapshot-${ts}`);
mkdirSync(root, { recursive: true });
// Standard manifest files
writeFileSync(join(root, "from-commit.txt"), fromCommit + "\n");
writeFileSync(join(root, "from-version.txt"), fromVersion + "\n");
writeFileSync(join(root, "to-version.txt"), toVersion + "\n");
// Optional captures (best-effort, never fatal)
const tryCopy = (src, dst) => {
try {
if (existsSync(src)) copyFileSync(src, dst);
} catch (err) {
console.error(`[snapshot] warn: could not copy ${src} (${err.code || err.message})`);
}
};
tryCopy(join(homeDir, "Library", "LaunchAgents", "dev.ocp.proxy.plist"), join(root, "plist"));
tryCopy(join(homeDir, ".config", "systemd", "user", "ocp-proxy.service"), join(root, "service"));
tryCopy(join(homeDir, ".ocp", "ocp.db"), join(root, "db.bak"));
tryCopy(join(homeDir, ".ocp", "admin-key"), join(root, "admin-key"));
tryCopy(join(homeDir, ".openclaw", "openclaw.json"), join(root, "openclaw.json"));
for (const { src, name } of extraFiles) tryCopy(src, join(root, name));
return root;
}
export function readSnapshot(snapshotPath) {
const read = (n) => {
try { return readFileSync(join(snapshotPath, n), "utf8").trim(); } catch { return null; }
};
return {
path: snapshotPath,
fromCommit: read("from-commit.txt"),
fromVersion: read("from-version.txt"),
toVersion: read("to-version.txt")
};
}
export function listSnapshots(homeDir) {
const root = join(homeDir, ".ocp");
if (!existsSync(root)) return [];
return readdirSync(root)
.filter(name => name.startsWith("upgrade-snapshot-"))
.map(name => ({ name, path: join(root, name), mtime: statSync(join(root, name)).mtimeMs }))
.sort((a, b) => {
const chronological = parseSnapshotTimestamp(a.name) - parseSnapshotTimestamp(b.name);
return chronological || a.name.localeCompare(b.name);
});
}
/**
* Garbage-collect old upgrade snapshots.
*
* Retention rule (a snapshot is KEPT if any of these is true):
* - It is among the last `keepCount` snapshots (sorted oldestnewest)
* - Its timestamp is within `keepDays` of `now`
* - It is the single most-recent snapshot (always-keep safety net)
*
* @param {string} homeDir - Root containing ~/.ocp/
* @param {object} opts
* @param {number} [opts.keepCount=5] - Minimum count to keep
* @param {number} [opts.keepDays=30] - Keep snapshots newer than N days
* @param {boolean} [opts.dryRun=false] - If true, report plan but don't delete
* @param {Date} [opts.now=new Date()] - Override clock for testing
* @returns {{kept: Array, removed: Array, dryRun: boolean}}
*/
export function gcSnapshots(homeDir, opts = {}) {
const keepCount = opts.keepCount ?? 5;
const keepDays = opts.keepDays ?? 30;
const dryRun = !!opts.dryRun;
const now = opts.now || new Date();
const all = listSnapshots(homeDir); // sorted oldest→newest
if (all.length === 0) return { kept: [], removed: [], dryRun };
if (all.length === 1) return { kept: all, removed: [], dryRun }; // always keep most recent
const cutoffMs = now.getTime() - keepDays * 24 * 60 * 60 * 1000;
const lastN = new Set(all.slice(-keepCount).map(s => s.path));
const kept = [], removed = [];
for (let i = 0; i < all.length; i++) {
const s = all[i];
const isMostRecent = i === all.length - 1;
const isInLastN = lastN.has(s.path);
const isWithinDays = parseSnapshotTimestamp(s.name) >= cutoffMs;
if (isMostRecent || isInLastN || isWithinDays) {
kept.push(s);
} else {
removed.push(s);
}
}
if (!dryRun) {
for (const s of removed) {
try {
rmSync(s.path, { recursive: true, force: true });
} catch (err) {
console.error(`[snapshot] warn: could not remove ${s.path} (${err.code || err.message})`);
}
}
}
return { kept, removed, dryRun };
}
function parseSnapshotTimestamp(name) {
// Both legacy ISO names and Windows-safe names are supported.
// upgrade-snapshot-2026-05-11T08:30:00Z → epoch ms
// upgrade-snapshot-2026-05-11T08-30-00Z → epoch ms
const m = name.match(/upgrade-snapshot-(.+)$/);
if (!m) return 0;
const raw = m[1];
const t = Date.parse(raw);
if (Number.isFinite(t)) return t;
const iso = raw.replace(/(T\d{2})-(\d{2})-(\d{2})Z$/, "$1:$2:$3Z");
const portable = Date.parse(iso);
return Number.isFinite(portable) ? portable : 0;
}
function formatSnapshotTimestamp(date) {
// Windows forbids ':' in directory names. Replacing only the time separators
// preserves chronological lexical order and keeps the timestamp readable.
return date.toISOString().replace(/\.\d+Z$/, "Z").replace(/:/g, "-");
}
+2 -1
View File
@@ -9,6 +9,7 @@ import { readFileSync, writeFileSync, existsSync, copyFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { homedir } from "node:os";
import { DEFAULT_PORT, LOCAL_HOST, OPENAI_API_BASE } from "../lib/constants.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = join(__dirname, "..");
@@ -70,7 +71,7 @@ if (!config.models.providers) config.models.providers = {};
if (!config.models.providers[PROVIDER_NAME]) {
// First-time registration
config.models.providers[PROVIDER_NAME] = {
baseUrl: "http://127.0.0.1:3456/v1",
baseUrl: `http://${LOCAL_HOST}:${DEFAULT_PORT}${OPENAI_API_BASE}`,
api: "openai-completions",
authHeader: false,
models: desiredModels,
+365
View File
@@ -0,0 +1,365 @@
#!/usr/bin/env node
/**
* scripts/upgrade.mjs OCP unified upgrade dispatcher.
*
* Paths:
* noop current == latest, exit 0
* light same major.minor, patch bump only (existing fast path; delegated to bash)
* full cross-minor (snapshot + setup.mjs + post-flight)
* fresh_install from-version < v3.4.0 (--yes required for non-interactive)
* rollback restore from snapshot
*/
import { runDoctor } from "./doctor.mjs";
import { execSync } from "node:child_process";
import { homedir } from "node:os";
import { join } from "node:path";
import { existsSync, copyFileSync } from "node:fs";
import { writeSnapshot, listSnapshots, readSnapshot, gcSnapshots } from "./lib/snapshot.mjs";
import { DEFAULT_PORT } from "../lib/constants.mjs";
// Post-flight acceptance predicate (issue #173). A health probe passes ONLY when the server
// is authed AND actually serving the TARGET version. auth.ok alone is not enough: a stale
// process holding the port answers auth.ok=true while still running the OLD code — exactly
// what a nohup-fallback orphan did on 2026-07-17 (upgrade "succeeded", /health kept serving
// 3.21.1). Comparing /health.version to the checkout target catches orphan-holds-port,
// restart-didn't-take, and wrong-unit-restarted alike. `target` tolerates a leading "v"
// (doctor reports "v3.22.1"; /health reports "3.22.1"); an empty/unknown target degrades to
// the old auth-only check rather than blocking an otherwise-good upgrade.
export function postFlightOk(body, target) {
if (body?.auth?.ok !== true) return false;
const want = String(target || "").replace(/^v/, "");
return !want || body?.version === want;
}
export async function runUpgrade(opts = {}) {
const dryRun = !!opts.dryRun;
const yes = !!opts.yes;
// yes is reserved for Bundle 3 (fresh-install / rollback interactive gate); not used in upgrade-path here.
const plan = [];
// --- rollback path (no doctor needed; snapshot is authoritative) ---
if (opts.rollback) {
return await runRollback(opts);
}
// --- doctor pre-flight ---
const doctor = opts.mockDoctor || await runDoctor();
if (!doctor.ready_to_upgrade && doctor.next_action.kind !== "fresh_install") {
throw new Error(`doctor FAIL: ${doctor.next_action.kind} (run "ocp doctor" for details)`);
}
const kind = doctor.next_action.kind;
plan.push(`[doctor] from=${doctor.current_version} to=${doctor.latest_version} kind=${kind}`);
// --- noop ---
if (kind === "noop") {
plan.push(`[noop] already at latest (${doctor.latest_version})`);
return { path: "noop", executed: true, changed: false, plan };
}
// --- dry-run early exit ---
if (dryRun) {
plan.push(`[plan] would proceed with ${kind} path`);
if (kind === "upgrade") {
plan.push(`[plan] phase 1: snapshot to ~/.ocp/upgrade-snapshot-<ts>/`);
plan.push(`[plan] phase 2: git checkout ${doctor.latest_version} && npm install`);
plan.push(`[plan] phase 3: node setup.mjs`);
plan.push(`[plan] phase 4: launchctl bootout/bootstrap`);
plan.push(`[plan] phase 5: post-flight /health + /v1/models`);
} else if (kind === "update") {
plan.push(`[plan] light path: git pull + npm install + restart`);
} else if (kind === "fresh_install") {
plan.push(`[plan] fresh-install ai_executable[]:`);
for (const cmd of doctor.next_action.ai_executable) plan.push(` - ${cmd}`);
}
return { path: kind, executed: false, plan };
}
// --- non-dry-run paths ---
if (kind === "update") {
return { path: "update", executed: true, changed: true, plan: [...plan, "[light] delegated to bash cmd_update existing logic"] };
}
if (kind === "upgrade") {
return await runFullUpgrade({ doctor, opts });
}
if (kind === "fresh_install") {
return await runFreshInstall({ doctor, opts });
}
throw new Error(`path ${kind} not yet implemented`);
}
async function runFullUpgrade({ doctor, opts }) {
const phases = [];
let snapshotPath = null;
const exec = (cmd, label) => {
if (opts.mockExec) {
phases.push({ name: label, cmd, status: "skipped-mock" });
return "";
}
try {
const out = execSync(cmd, { stdio: ["pipe", "pipe", "pipe"] }).toString();
phases.push({ name: label, cmd, status: "ok" });
return out;
} catch (err) {
const detail = err.stderr?.toString().trim();
phases.push({ name: label, cmd, status: "fail", stderr: detail });
throw Object.assign(
new Error(`phase ${label} failed: ${detail || err.message}`),
{ phases, cmd }
);
}
};
const ocpDir = opts.ocpDir || join(homedir(), "ocp");
try {
// phase 1: pre-flight (doctor already passed; just record)
phases.push({ name: "pre-flight", status: "ok", note: `kind=upgrade from=${doctor.current_version} to=${doctor.latest_version}` });
// phase 2: snapshot
const fromCommit = opts.mockExec
? "mock-commit"
: execSync(`git -C ${ocpDir} rev-parse HEAD`).toString().trim();
snapshotPath = opts.mockExec
? "/tmp/mock-snapshot"
: writeSnapshot({ homeDir: homedir(), fromCommit, fromVersion: doctor.current_version, toVersion: doctor.latest_version });
phases.push({ name: "snapshot", path: snapshotPath, status: "ok" });
// phase 3: fetch + install
exec(`git -C ${ocpDir} fetch --tags --quiet`, "fetch+install");
exec(`git -C ${ocpDir} checkout ${doctor.latest_version}`, "fetch+install");
exec(`npm --prefix ${ocpDir} install --no-audit --no-fund`, "fetch+install");
// phase 4: reconfigure
exec(`node ${ocpDir}/setup.mjs`, "reconfigure");
// phase 5: restart (heads-up note printed before invoking)
if (!opts.mockExec) {
console.error(`[heads-up] restarting OCP service in 3s — expect ~510s blip on requests in flight.`);
await new Promise(r => setTimeout(r, 3000));
}
if (process.platform === "darwin") {
exec(`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`, "restart");
exec(`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`, "restart");
} else {
exec(`systemctl --user restart ocp-proxy.service`, "restart");
}
// phase 6: post-flight (10s budget; skipped under mockExec)
if (!opts.mockExec) {
const port = process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT);
let ok = false;
let lastSeen = null;
for (let i = 0; i < 10; i++) {
try {
const out = execSync(`curl -sf --max-time 2 http://127.0.0.1:${port}/health`).toString();
const body = JSON.parse(out);
lastSeen = body.version;
if (postFlightOk(body, doctor.latest_version)) { ok = true; break; }
} catch { /* retry */ }
await new Promise(r => setTimeout(r, 1000));
}
if (!ok) {
phases.push({
name: "post-flight", status: "fail",
message: `health did not return auth.ok=true AND version=${doctor.latest_version} within 10s`
+ (lastSeen ? ` (last saw version=${lastSeen} — a stale process may still hold the port; check \`ss -ltnp\` / \`lsof -i\`)` : ""),
});
throw new Error("post-flight failed");
}
execSync(`curl -sf --max-time 3 http://127.0.0.1:${port}/v1/models > /dev/null`);
phases.push({ name: "post-flight", status: "ok" });
} else {
phases.push({ name: "post-flight", status: "skipped-mock" });
}
// Auto-GC old snapshots after successful upgrade (best-effort, never throws).
try {
const gc = gcSnapshots(homedir(), { keepCount: 5, keepDays: 30 });
if (gc.removed.length > 0) {
console.error(`[gc] removed ${gc.removed.length} old snapshots; kept ${gc.kept.length}`);
}
} catch (e) {
console.error(`[gc] warn: snapshot GC failed: ${e.message}`);
}
return { path: "upgrade", executed: true, changed: true, snapshotPath, phases };
} catch (err) {
if (snapshotPath && !err.snapshotPath) {
Object.assign(err, {
snapshotPath,
phases,
hint: "Working tree may be at new version. Run `ocp update --rollback` to restore from snapshot."
});
}
throw err;
}
}
async function runFreshInstall({ doctor, opts }) {
if (!opts.yes) {
throw new Error("fresh_install requires --yes for non-interactive execution (or run interactively and answer y)");
}
const steps = [];
for (const cmd of doctor.next_action.ai_executable) {
if (opts.mockExec) {
steps.push({ cmd, status: "skipped-mock" });
} else {
try {
execSync(cmd, { stdio: "inherit" });
steps.push({ cmd, status: "ok" });
} catch (e) {
const detail = e.stderr?.toString().trim() || e.message;
steps.push({ cmd, status: "fail", error: String(detail) });
throw Object.assign(new Error(`fresh_install step failed: ${cmd}${detail}`), { steps });
}
}
}
return { path: "fresh_install", executed: true, changed: true, steps };
}
async function runRollback(opts) {
const homeDir = opts.homeDir || homedir();
const snapshots = opts.mockSnapshots ?? listSnapshots(homeDir);
if (opts.gc) {
const result = gcSnapshots(homeDir, { dryRun: opts.dryRun });
return { path: opts.dryRun ? "rollback-gc-dry-run" : "rollback-gc", ...result };
}
if (opts.list) {
return { path: "rollback-list", snapshots };
}
if (snapshots.length === 0) {
throw new Error("no upgrade snapshots found in ~/.ocp/upgrade-snapshot-*");
}
const target = opts.snapshotPath
? snapshots.find(s => s.path === opts.snapshotPath)
: snapshots[snapshots.length - 1];
if (!target) throw new Error(`snapshot not found: ${opts.snapshotPath} (must be inside ~/.ocp/upgrade-snapshot-*)`);
const meta = opts.mockSnapshotMeta ?? readSnapshot(target.path);
if (!meta.fromCommit) throw new Error(`snapshot ${target.path} has no from-commit.txt`);
const phases = [];
if (opts.dryRun) {
return {
path: "rollback-dry-run",
executed: false,
target: target.path,
plan: [
`git checkout ${meta.fromCommit}`,
`cp ${target.path}/plist ~/Library/LaunchAgents/dev.ocp.proxy.plist`,
`cp ${target.path}/db.bak ~/.ocp/ocp.db`,
`launchctl bootout/bootstrap`,
`ocp doctor`
]
};
}
if (!opts.yes) throw new Error("rollback requires --yes for non-interactive execution");
const exec = (cmd, label) => {
if (opts.mockExec) {
phases.push({ name: label, cmd, status: "skipped-mock" });
return "";
}
try {
execSync(cmd, { stdio: ["pipe", "pipe", "pipe"] });
phases.push({ name: label, cmd, status: "ok" });
} catch (err) {
const detail = err.stderr?.toString().trim();
phases.push({ name: label, cmd, status: "fail", stderr: detail });
throw Object.assign(
new Error(`rollback phase ${label} failed: ${detail || err.message}`),
{ phases, target: target.path }
);
}
};
const ocpDir = opts.ocpDir || join(homedir(), "ocp");
exec(`git -C ${ocpDir} checkout ${meta.fromCommit}`, "git-checkout");
if (!opts.mockExec) {
const tryCopy = (src, dst) => {
try {
if (existsSync(src)) copyFileSync(src, dst);
} catch (err) {
console.error(`[rollback] warn: could not restore ${src}${dst} (${err.code || err.message})`);
}
};
tryCopy(join(target.path, "plist"), join(homeDir, "Library", "LaunchAgents", "dev.ocp.proxy.plist"));
tryCopy(join(target.path, "service"), join(homeDir, ".config", "systemd", "user", "ocp-proxy.service"));
tryCopy(join(target.path, "db.bak"), join(homeDir, ".ocp", "ocp.db"));
tryCopy(join(target.path, "admin-key"), join(homeDir, ".ocp", "admin-key"));
phases.push({ name: "restore-files", status: "ok" });
} else {
phases.push({ name: "restore-files", status: "skipped-mock" });
}
exec(`npm --prefix ${ocpDir} install --no-audit --no-fund`, "npm-install");
if (!opts.mockExec) {
console.error(`[heads-up] restarting OCP service in 3s — expect ~510s blip on requests in flight.`);
await new Promise(r => setTimeout(r, 3000));
}
if (process.platform === "darwin") {
exec(`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`, "restart");
exec(`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`, "restart");
} else {
exec(`systemctl --user restart ocp-proxy.service`, "restart");
}
return { path: "rollback", executed: true, changed: true, target: target.path, phases };
}
// CLI entrypoint — use fileURLToPath + realpath to handle symlinked install paths.
import { fileURLToPath } from "node:url";
import { realpathSync } from "node:fs";
function _isMain() {
if (!process.argv[1]) return false;
try {
return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
} catch { return false; }
}
if (_isMain()) {
const args = process.argv.slice(2);
const dryRun = args.includes("--dry-run");
const yes = args.includes("--yes");
const rollback = args.includes("--rollback");
const list = args.includes("--list");
const gc = args.includes("--gc");
const targetIdx = args.indexOf("--target");
const target = targetIdx !== -1 ? args[targetIdx + 1] : undefined;
// First non-flag positional after --rollback is the snapshot path
let snapshotPath;
if (rollback) {
const rb = args.indexOf("--rollback");
const cand = args[rb + 1];
if (cand && !cand.startsWith("--")) snapshotPath = cand;
}
try {
const result = await runUpgrade({ dryRun, yes, rollback, list, gc, snapshotPath, target });
if (result.plan) for (const line of result.plan) console.log(line);
if (result.phases) for (const p of result.phases) console.log(`[${p.name}] ${p.status}${p.cmd ? `: ${p.cmd}` : ""}`);
if (result.steps) for (const s of result.steps) console.log(` ${s.status === "ok" ? "✓" : s.status === "skipped-mock" ? "·" : "✗"} ${s.cmd}`);
if (result.snapshots) {
console.log(`Found ${result.snapshots.length} snapshots:`);
for (const s of result.snapshots) console.log(` ${s.name}`);
}
if (result.removed && result.kept) {
console.log(`Snapshots: kept ${result.kept.length}, ${result.dryRun ? "would remove" : "removed"} ${result.removed.length}`);
for (const s of result.removed) console.log(` - ${s.name}`);
}
process.exit(0);
} catch (e) {
console.error(`${e.message}`);
if (e.snapshotPath) console.error(` snapshot: ${e.snapshotPath}`);
if (e.target) console.error(` target: ${e.target}`);
if (e.hint) console.error(` hint: ${e.hint}`);
process.exit(1);
}
}
+2235 -226
View File
File diff suppressed because it is too large Load Diff
+310 -147
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env node
/**
* openclaw-claude-proxy setup
* OCP (Open Claude Proxy) setup
*
* Automatically configures OpenClaw to use Claude CLI as a model provider.
* Run: node setup.mjs [--port 3456] [--default-model opus|sonnet|haiku] [--dry-run]
* Run: node setup.mjs [--port N] [--default-model opus|sonnet|haiku] [--dry-run]
* (default port = DEFAULT_PORT from lib/constants.mjs)
*
* What it does:
* 1. Verifies claude CLI is installed and authenticated
@@ -12,11 +13,13 @@
* 4. Creates start.sh for easy launch
* 5. Optionally starts the proxy
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync, chmodSync } from "node:fs";
import { mergePlistEnv, mergeSystemdEnv } from "./scripts/lib/plist-merge.mjs";
import { execSync } from "node:child_process";
import { join, dirname } from "node:path";
import { homedir } from "node:os";
import { fileURLToPath } from "node:url";
import { DEFAULT_PORT } from "./lib/constants.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const HOME = homedir();
@@ -31,7 +34,7 @@ const opt = (name, fallback) => {
return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
};
const PORT = parseInt(opt("port", "3456"), 10);
const PORT = parseInt(opt("port", String(DEFAULT_PORT)), 10);
const DEFAULT_MODEL = opt("default-model", "opus"); // opus | sonnet | haiku
const DRY_RUN = flag("dry-run");
const SKIP_START = flag("no-start");
@@ -39,6 +42,51 @@ const PROVIDER_NAME = opt("provider-name", "claude-local");
const BIND_ADDRESS = opt("bind", "127.0.0.1");
const AUTH_MODE_CONFIG = opt("auth-mode", "none");
// ── Service-env injection: CLAUDE_BIN, OCP_ADMIN_KEY, PROXY_ANONYMOUS_KEY ──
// These are read from the user's shell env at install time and written into
// the service unit (plist / systemd) so the daemon picks them up on boot.
// CLAUDE_BIN — detect at install time; omit if not found (server.mjs fallback)
let CLAUDE_BIN_INJECT = null;
if (process.env.CLAUDE_BIN) {
CLAUDE_BIN_INJECT = process.env.CLAUDE_BIN;
} else {
try {
const detected = execSync("which claude 2>/dev/null", { encoding: "utf-8" }).trim();
if (detected && existsSync(detected)) {
CLAUDE_BIN_INJECT = detected;
}
} catch { /* which not available or claude not on PATH — omit */ }
}
// OCP_ADMIN_KEY — omit entirely when empty/unset; don't write empty string
const OCP_ADMIN_KEY_INJECT = process.env.OCP_ADMIN_KEY || null;
// PROXY_ANONYMOUS_KEY — same pattern
const PROXY_ANON_KEY_INJECT = process.env.PROXY_ANONYMOUS_KEY || null;
// ── Inject-value helpers ─────────────────────────────────────────────────
// Escape a value for safe inclusion in a plist <string>…</string> body.
function xmlEscape(v) {
return String(v).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
}
// Validate an injected service value: no control chars (a newline would inject a
// rogue systemd Environment= directive; other control chars corrupt the unit/plist).
// Spaces are allowed — filesystem paths (CLAUDE_BIN) may legitimately contain them.
function assertSafeInjectValue(name, v) {
if (v == null) return v;
if (/[\x00-\x1f]/.test(String(v))) {
console.error(`FATAL: ${name} contains a newline or control character — refusing to write it into the service unit.`);
process.exit(1);
}
return v;
}
// Validate all three INJECT values before they are written into any service unit.
assertSafeInjectValue("CLAUDE_BIN", CLAUDE_BIN_INJECT);
assertSafeInjectValue("OCP_ADMIN_KEY", OCP_ADMIN_KEY_INJECT);
assertSafeInjectValue("PROXY_ANONYMOUS_KEY", PROXY_ANON_KEY_INJECT);
// ── Models: derived from models.json (single source of truth) ──────────
const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf-8"));
@@ -93,120 +141,134 @@ try {
}
// Check claude auth (quick test)
try {
const out = execSync('claude -p --output-format text --no-session-persistence -- "ping"', {
encoding: "utf-8",
timeout: 30000,
env: { ...process.env, CLAUDECODE: undefined, ANTHROPIC_API_KEY: undefined, ANTHROPIC_BASE_URL: undefined, ANTHROPIC_AUTH_TOKEN: undefined },
}).trim();
if (out.length > 0) {
log(`Claude CLI authenticated (test response: "${out.slice(0, 40)}...")`);
// NOTE: This probe uses `claude -p` (sdk-cli spawn). After the 2026-06-15 Anthropic billing
// split, every `claude -p` call draws from the Agent SDK credit pool rather than the
// Pro/Max subscription. Re-running setup after 6/15 will consume one metered credit.
// Set OCP_SKIP_AUTH_TEST=1 to skip this probe (auth is still validated at first real request).
if (process.env.OCP_SKIP_AUTH_TEST === "1") {
warn("OCP_SKIP_AUTH_TEST=1 — skipping claude auth probe (will be validated at first request).");
} else {
try {
const out = execSync('claude -p --output-format text --no-session-persistence -- "ping"', {
encoding: "utf-8",
timeout: 30000,
env: { ...process.env, CLAUDECODE: undefined, ANTHROPIC_API_KEY: undefined, ANTHROPIC_BASE_URL: undefined, ANTHROPIC_AUTH_TOKEN: undefined },
}).trim();
if (out.length > 0) {
log(`Claude CLI authenticated (test response: "${out.slice(0, 40)}...")`);
}
} catch (e) {
warn(`Claude CLI auth test failed: ${e.message.slice(0, 100)}`);
warn("Make sure you're logged in: claude login");
}
} catch (e) {
warn(`Claude CLI auth test failed: ${e.message.slice(0, 100)}`);
warn("Make sure you're logged in: claude login");
}
// Check openclaw config
if (!existsSync(CONFIG_PATH)) fail(`OpenClaw config not found at ${CONFIG_PATH}`);
log(`OpenClaw config: ${CONFIG_PATH}`);
// Check openclaw config (optional — OCP runs standalone without OpenClaw)
const OPENCLAW_PRESENT = existsSync(CONFIG_PATH);
if (OPENCLAW_PRESENT) {
log(`OpenClaw config: ${CONFIG_PATH}`);
} else {
warn(`OpenClaw not detected at ${CONFIG_PATH} — skipping OpenClaw integration.`);
warn(`To register OCP with OpenClaw later, install OpenClaw and re-run \`node setup.mjs\`,`);
warn(`or run \`ocp update\` if OpenClaw is installed afterward.`);
}
// ── Step 2: Patch openclaw.json ─────────────────────────────────────────
console.log("\n📝 Configuring OpenClaw...\n");
if (OPENCLAW_PRESENT) {
console.log("\n📝 Configuring OpenClaw...\n");
const config = readJSON(CONFIG_PATH);
const config = readJSON(CONFIG_PATH);
// Ensure models.providers exists
if (!config.models) config.models = {};
if (!config.models.providers) config.models.providers = {};
// Ensure models.providers exists
if (!config.models) config.models = {};
if (!config.models.providers) config.models.providers = {};
// Add/update claude-local provider
config.models.providers[PROVIDER_NAME] = {
baseUrl: `http://127.0.0.1:${PORT}/v1`,
api: "openai-completions",
authHeader: false,
models: MODELS,
};
log(`Provider "${PROVIDER_NAME}" → http://127.0.0.1:${PORT}/v1`);
// Add/update claude-local provider
config.models.providers[PROVIDER_NAME] = {
baseUrl: `http://127.0.0.1:${PORT}/v1`,
api: "openai-completions",
authHeader: false,
models: MODELS,
};
log(`Provider "${PROVIDER_NAME}" → http://127.0.0.1:${PORT}/v1`);
// Ensure auth profile in config
if (!config.auth) config.auth = {};
if (!config.auth.profiles) config.auth.profiles = {};
config.auth.profiles[`${PROVIDER_NAME}:default`] = {
provider: PROVIDER_NAME,
mode: "api_key",
};
log(`Auth profile "${PROVIDER_NAME}:default" registered`);
// Ensure auth profile in config
if (!config.auth) config.auth = {};
if (!config.auth.profiles) config.auth.profiles = {};
config.auth.profiles[`${PROVIDER_NAME}:default`] = {
provider: PROVIDER_NAME,
mode: "api_key",
};
log(`Auth profile "${PROVIDER_NAME}:default" registered`);
// Add models to agents.defaults.models
if (!config.agents) config.agents = {};
if (!config.agents.defaults) config.agents.defaults = {};
if (!config.agents.defaults.models) config.agents.defaults.models = {};
for (const [key, val] of Object.entries(MODEL_ALIASES)) {
config.agents.defaults.models[key] = val;
}
log(`Model aliases added to agents.defaults.models`);
// Set idleTimeoutSeconds to 0 — critical for Claude tool-use.
// When Claude calls tools (Bash, Read, etc.), the token stream pauses for 30-120s.
// OpenClaw's default idleTimeoutSeconds (60s) kills the connection mid-tool-call,
// causing exit 143 (SIGTERM) and stuck sessions. Setting to 0 disables the idle timer.
if (!config.agents.defaults.llm) config.agents.defaults.llm = {};
if (config.agents.defaults.llm.idleTimeoutSeconds === undefined ||
config.agents.defaults.llm.idleTimeoutSeconds > 0) {
config.agents.defaults.llm.idleTimeoutSeconds = 0;
log(`Set agents.defaults.llm.idleTimeoutSeconds = 0 (prevents tool-call timeouts)`);
} else {
log(`idleTimeoutSeconds already configured: ${config.agents.defaults.llm.idleTimeoutSeconds}`);
}
writeJSON(CONFIG_PATH, config);
log(`Config saved`);
// ── Step 3: Patch auth-profiles.json ────────────────────────────────────
console.log("\n🔑 Configuring auth profiles...\n");
// Find all agent auth-profiles.json files
const agentsDir = join(OPENCLAW_DIR, "agents");
const agentDirs = existsSync(agentsDir)
? readdirSync(agentsDir).filter((d) => {
const ap = join(agentsDir, d, "agent", "auth-profiles.json");
return existsSync(ap);
})
: [];
import { readdirSync } from "node:fs";
for (const agentId of agentDirs) {
const apPath = join(agentsDir, agentId, "agent", "auth-profiles.json");
try {
const ap = readJSON(apPath);
if (!ap.profiles) ap.profiles = {};
// Add claude-local profile if missing
if (!ap.profiles[`${PROVIDER_NAME}:default`]) {
ap.profiles[`${PROVIDER_NAME}:default`] = {
type: "api_key",
provider: PROVIDER_NAME,
key: "local-proxy-no-auth",
};
}
// Add to lastGood if missing
if (!ap.lastGood) ap.lastGood = {};
if (!ap.lastGood[PROVIDER_NAME]) {
ap.lastGood[PROVIDER_NAME] = `${PROVIDER_NAME}:default`;
}
writeJSON(apPath, ap);
log(`Agent "${agentId}" auth profile updated`);
} catch (e) {
warn(`Skipped agent "${agentId}": ${e.message}`);
// Add models to agents.defaults.models
if (!config.agents) config.agents = {};
if (!config.agents.defaults) config.agents.defaults = {};
if (!config.agents.defaults.models) config.agents.defaults.models = {};
for (const [key, val] of Object.entries(MODEL_ALIASES)) {
config.agents.defaults.models[key] = val;
}
}
log(`Model aliases added to agents.defaults.models`);
if (agentDirs.length === 0) {
warn("No agent auth-profiles.json found — you may need to restart the gateway first");
// Set idleTimeoutSeconds to 0 — critical for Claude tool-use.
// When Claude calls tools (Bash, Read, etc.), the token stream pauses for 30-120s.
// OpenClaw's default idleTimeoutSeconds (60s) kills the connection mid-tool-call,
// causing exit 143 (SIGTERM) and stuck sessions. Setting to 0 disables the idle timer.
if (!config.agents.defaults.llm) config.agents.defaults.llm = {};
if (config.agents.defaults.llm.idleTimeoutSeconds === undefined ||
config.agents.defaults.llm.idleTimeoutSeconds > 0) {
config.agents.defaults.llm.idleTimeoutSeconds = 0;
log(`Set agents.defaults.llm.idleTimeoutSeconds = 0 (prevents tool-call timeouts)`);
} else {
log(`idleTimeoutSeconds already configured: ${config.agents.defaults.llm.idleTimeoutSeconds}`);
}
writeJSON(CONFIG_PATH, config);
log(`Config saved`);
// ── Step 3: Patch auth-profiles.json ────────────────────────────────────
console.log("\n🔑 Configuring auth profiles...\n");
// Find all agent auth-profiles.json files
const agentsDir = join(OPENCLAW_DIR, "agents");
const agentDirs = existsSync(agentsDir)
? readdirSync(agentsDir).filter((d) => {
const ap = join(agentsDir, d, "agent", "auth-profiles.json");
return existsSync(ap);
})
: [];
for (const agentId of agentDirs) {
const apPath = join(agentsDir, agentId, "agent", "auth-profiles.json");
try {
const ap = readJSON(apPath);
if (!ap.profiles) ap.profiles = {};
// Add claude-local profile if missing
if (!ap.profiles[`${PROVIDER_NAME}:default`]) {
ap.profiles[`${PROVIDER_NAME}:default`] = {
type: "api_key",
provider: PROVIDER_NAME,
key: "local-proxy-no-auth",
};
}
// Add to lastGood if missing
if (!ap.lastGood) ap.lastGood = {};
if (!ap.lastGood[PROVIDER_NAME]) {
ap.lastGood[PROVIDER_NAME] = `${PROVIDER_NAME}:default`;
}
writeJSON(apPath, ap);
log(`Agent "${agentId}" auth profile updated`);
} catch (e) {
warn(`Skipped agent "${agentId}": ${e.message}`);
}
}
if (agentDirs.length === 0) {
warn("No agent auth-profiles.json found — you may need to restart the gateway first");
}
}
// ── Step 4: Create start.sh ─────────────────────────────────────────────
@@ -217,7 +279,7 @@ const logDir = join(OPENCLAW_DIR, "logs");
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
const startSh = `#!/bin/bash
# Start openclaw-claude-proxy if not already running
# Start OCP (Open Claude Proxy) if not already running
PORT=\${CLAUDE_PROXY_PORT:-${PORT}}
if ! lsof -i :\$PORT -sTCP:LISTEN &>/dev/null; then
unset CLAUDECODE
@@ -238,40 +300,71 @@ if (!DRY_RUN) {
log(`Launcher: ${startPath}`);
// ── Step 5: Summary ─────────────────────────────────────────────────────
console.log(`
Setup complete!
Provider: ${PROVIDER_NAME.padEnd(44)}
Port: ${String(PORT).padEnd(44)}
Models: ${`see models.json (${MODELS.length} available)`.padEnd(44)}
Default: ${DEFAULT_MODEL_ID.padEnd(44)}
Start proxy:
bash ${startPath.replace(HOME, "~").padEnd(50)}
Or directly:
node ${serverPath.replace(HOME, "~").padEnd(49)}
Set as default model in openclaw.json:
agents.defaults.model.primary =
"${PROVIDER_NAME}/${DEFAULT_MODEL_ID}"${" ".repeat(Math.max(0, 30 - PROVIDER_NAME.length - DEFAULT_MODEL_ID.length))}
Then restart gateway:
openclaw gateway restart
`);
// ── Step 6: Optionally start ────────────────────────────────────────────
if (!SKIP_START && !DRY_RUN) {
try {
execSync(`bash "${startPath}"`, { stdio: "inherit" });
} catch { /* ignore */ }
const banner = [
`╔══════════════════════════════════════════════════════════════╗`,
`║ Setup complete! ║`,
`╠══════════════════════════════════════════════════════════════╣`,
`║ ║`,
`║ Provider: ${PROVIDER_NAME.padEnd(44)}`,
`║ Port: ${String(PORT).padEnd(44)}`,
`║ Models: ${`see models.json (${MODELS.length} available)`.padEnd(44)}`,
`║ Default: ${DEFAULT_MODEL_ID.padEnd(44)}`,
`║ ║`,
`║ Start proxy: ║`,
`║ bash ${startPath.replace(HOME, "~").padEnd(50)}`,
`║ ║`,
`║ Or directly: ║`,
`║ node ${serverPath.replace(HOME, "~").padEnd(49)}`,
`║ ║`,
];
if (OPENCLAW_PRESENT) {
banner.push(
`║ Set as default model in openclaw.json: ║`,
`║ agents.defaults.model.primary = ║`,
`║ "${PROVIDER_NAME}/${DEFAULT_MODEL_ID}"${" ".repeat(Math.max(0, 30 - PROVIDER_NAME.length - DEFAULT_MODEL_ID.length))}`,
`║ ║`,
`║ Then restart gateway: ║`,
`║ openclaw gateway restart ║`,
`║ ║`,
);
} else {
banner.push(
`║ OpenClaw not detected — running in standalone mode. ║`,
`║ Point your IDE (Cline / Cursor / Continue / OpenCode / ║`,
`║ Aider / OpenClaw) at: ║`,
`║ http://${BIND_ADDRESS}:${String(PORT)}/v1${" ".repeat(Math.max(0, 47 - BIND_ADDRESS.length - String(PORT).length))}`,
`║ ║`,
`║ See docs/lan-mode.md for per-IDE client setup. ║`,
`║ ║`,
);
}
banner.push(`╚══════════════════════════════════════════════════════════════╝`);
console.log("\n" + banner.join("\n") + "\n");
// ── Step 7: Install auto-start on boot ──────────────────────────────────
// Log service-env injection plan (shown in both dry-run and live mode)
console.log("\n🔧 Service unit env vars to inject:\n");
if (CLAUDE_BIN_INJECT) {
log(`CLAUDE_BIN: ${CLAUDE_BIN_INJECT}`);
} else {
log(`CLAUDE_BIN: (not found — server.mjs will auto-detect at runtime)`);
}
if (OCP_ADMIN_KEY_INJECT) {
log(`OCP_ADMIN_KEY: injected (length: ${OCP_ADMIN_KEY_INJECT.length})`);
} else {
log(`OCP_ADMIN_KEY: (unset — admin endpoints disabled)`);
}
if (PROXY_ANON_KEY_INJECT) {
log(`PROXY_ANONYMOUS_KEY: injected (set)`);
} else {
log(`PROXY_ANONYMOUS_KEY: (unset — anonymous access disabled)`);
}
if (DRY_RUN) {
console.log("\n [dry-run] would write service unit with above env vars\n");
}
if (!DRY_RUN) {
console.log("\n🔄 Installing auto-start on login...\n");
@@ -297,7 +390,9 @@ if (!DRY_RUN) {
// and "ocp-proxy" keeps the proxy invisible to that heuristic.
const OCP_HOME = join(HOME, ".ocp");
const ocpLogsDir = join(OCP_HOME, "logs");
if (!existsSync(ocpLogsDir)) mkdirSync(ocpLogsDir, { recursive: true });
// mode 0700: with `recursive`, this call can create ~/.ocp ITSELF on a fresh install, and
// without an explicit mode that parent lands at the umask default (world-listable 0755).
if (!existsSync(ocpLogsDir)) mkdirSync(ocpLogsDir, { recursive: true, mode: 0o700 });
// Uninstall legacy service names if present (upgrade path)
if (platform === "darwin") {
@@ -340,11 +435,17 @@ if (!DRY_RUN) {
<key>EnvironmentVariables</key>
<dict>
<key>CLAUDE_PROXY_PORT</key>
<string>${PORT}</string>
<string>${xmlEscape(PORT)}</string>
<key>CLAUDE_BIND</key>
<string>${BIND_ADDRESS}</string>
<string>${xmlEscape(BIND_ADDRESS)}</string>
<key>CLAUDE_AUTH_MODE</key>
<string>${AUTH_MODE_CONFIG}</string>
<string>${xmlEscape(AUTH_MODE_CONFIG)}</string>${CLAUDE_BIN_INJECT ? `
<key>CLAUDE_BIN</key>
<string>${xmlEscape(CLAUDE_BIN_INJECT)}</string>` : ""}${OCP_ADMIN_KEY_INJECT ? `
<key>OCP_ADMIN_KEY</key>
<string>${xmlEscape(OCP_ADMIN_KEY_INJECT)}</string>` : ""}${PROXY_ANON_KEY_INJECT ? `
<key>PROXY_ANONYMOUS_KEY</key>
<string>${xmlEscape(PROXY_ANON_KEY_INJECT)}</string>` : ""}
</dict>
<key>RunAtLoad</key>
<true/>
@@ -358,8 +459,15 @@ if (!DRY_RUN) {
</plist>
`;
writeFileSync(plistPath, plistXml);
log(`Plist written: ${plistPath}`);
const existingPlist = existsSync(plistPath) ? readFileSync(plistPath, "utf8") : null;
const finalPlistXml = mergePlistEnv(existingPlist, plistXml);
writeFileSync(plistPath, finalPlistXml);
chmodSync(plistPath, 0o600);
if (existingPlist && finalPlistXml !== plistXml) {
log(`Plist written: ${plistPath} (mode 600, preserved user env vars)`);
} else {
log(`Plist written: ${plistPath} (mode 600)`);
}
// Bootout first (in case it was already loaded) then bootstrap
try { execSync(`launchctl bootout gui/$(id -u) "${plistPath}" 2>/dev/null`); } catch { /* ignore */ }
@@ -382,7 +490,7 @@ After=network.target
ExecStart=${nodeBin} ${serverPath}
Environment=CLAUDE_PROXY_PORT=${PORT}
Environment=CLAUDE_BIND=${BIND_ADDRESS}
Environment=CLAUDE_AUTH_MODE=${AUTH_MODE_CONFIG}
Environment=CLAUDE_AUTH_MODE=${AUTH_MODE_CONFIG}${CLAUDE_BIN_INJECT ? `\nEnvironment=CLAUDE_BIN=${CLAUDE_BIN_INJECT}` : ""}${OCP_ADMIN_KEY_INJECT ? `\nEnvironment=OCP_ADMIN_KEY=${OCP_ADMIN_KEY_INJECT}` : ""}${PROXY_ANON_KEY_INJECT ? `\nEnvironment=PROXY_ANONYMOUS_KEY=${PROXY_ANON_KEY_INJECT}` : ""}
Restart=always
RestartSec=5
StandardOutput=append:${logPath}
@@ -392,8 +500,15 @@ StandardError=append:${logPath}
WantedBy=default.target
`;
writeFileSync(servicePath, serviceUnit);
log(`Service file written: ${servicePath}`);
const existingService = existsSync(servicePath) ? readFileSync(servicePath, "utf8") : null;
const finalServiceUnit = mergeSystemdEnv(existingService, serviceUnit);
writeFileSync(servicePath, finalServiceUnit);
chmodSync(servicePath, 0o600);
if (existingService && finalServiceUnit !== serviceUnit) {
log(`Service file written: ${servicePath} (mode 600, preserved user env vars)`);
} else {
log(`Service file written: ${servicePath} (mode 600)`);
}
execSync(`systemctl --user daemon-reload`);
execSync(`systemctl --user enable ocp-proxy`);
@@ -405,4 +520,52 @@ WantedBy=default.target
}
console.log("\n✅ Auto-start installed — proxy will start automatically on login\n");
// ── Step 8: Post-install health verification ───────────────────────────
if (!SKIP_START) {
console.log("⏳ Waiting for server to bind...\n");
await new Promise(r => setTimeout(r, 3000));
const healthUrl = `http://127.0.0.1:${PORT}/health`;
let verified = false;
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
const res = await fetch(healthUrl, { signal: controller.signal });
clearTimeout(timer);
if (res.ok) {
const body = await res.json().catch(() => ({}));
console.log(` ✓ Health check passed (${healthUrl})`);
console.log(` version: ${body.version ?? "unknown"}`);
console.log(` authMode: ${body.authMode ?? "unknown"}`);
// Verify bind socket
try {
const bindCheck = process.platform === "linux"
? execSync(`ss -tlnp 2>/dev/null | grep ':${PORT}'`, { encoding: "utf-8" }).trim()
: execSync(`lsof -nP -iTCP:${PORT} -sTCP:LISTEN 2>/dev/null`, { encoding: "utf-8" }).trim();
if (bindCheck) {
console.log(` bind: ${bindCheck.split("\n")[0]}`);
}
} catch { /* bind check is best-effort */ }
verified = true;
} else {
warn(`Health check returned HTTP ${res.status} — service may not have started cleanly`);
}
} catch (e) {
const isTimeout = e.name === "AbortError" || (e.cause && e.cause.code === "UND_ERR_CONNECT_TIMEOUT");
warn(`Health check failed: ${isTimeout ? "timeout (5s)" : e.message}`);
}
if (!verified) {
const logHint = process.platform === "linux"
? "journalctl --user -u ocp-proxy -n 50"
: `tail -n 100 ~/.ocp/logs/proxy.log`;
console.error(`\n ✗ Server did not respond on port ${PORT} within 5 seconds.`);
console.error(` Check service logs:\n ${logHint}\n`);
process.exit(1);
}
}
}
View File
-12
View File
@@ -1,12 +0,0 @@
#!/bin/bash
# Start openclaw-claude-proxy if not already running
PORT=${CLAUDE_PROXY_PORT:-3456}
if ! lsof -i :$PORT -sTCP:LISTEN &>/dev/null; then
unset CLAUDECODE
nohup node "/Users/taodeng/.openclaw/projects/claude-proxy/server.mjs" \
>> "/Users/taodeng/.openclaw/logs/claude-proxy.log" \
2>> "/Users/taodeng/.openclaw/logs/claude-proxy.err.log" &
echo "claude-proxy started on port $PORT (pid $!)"
else
echo "claude-proxy already running on port $PORT"
fi
+26
View File
@@ -0,0 +1,26 @@
// Imported FIRST by test-features.mjs, before keys.mjs, so this runs before anything can open
// the key store. ESM hoists imports and evaluates them in order, so a `process.env.X = ...`
// statement in the test's own body would run too late — hence a separate module.
//
// Why this exists: `npm test` used to write real, UNREVOKED api_keys rows into the operator's
// live ~/.ocp/ocp.db (the same database the running server reads) — two per run, unbounded.
// It also made the suite racy: two concurrent runs (e.g. review worktrees) shared one file, so
// `listKeys()` could miss "test-user-1" and the `in` check would throw on undefined.
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
export const TEST_OCP_DIR = mkdtempSync(join(tmpdir(), "ocp-test-"));
// BOTH are required. keys.mjs honors OCP_DIR_OVERRIDE only when NODE_ENV === "test", so neither
// var alone redirects anything — a stray OCP_DIR_OVERRIDE in a production env is inert without
// NODE_ENV=test alongside it. (A daemon OCP launches never carries either: the service units and
// the `ocp` restart fallback strip both — see plist-merge NEVER_PRESERVE / keys.mjs's comment.)
process.env.NODE_ENV = "test";
process.env.OCP_DIR_OVERRIDE = TEST_OCP_DIR;
// Remove the scratch store on exit. Without this the fix would trade unbounded growth in
// ~/.ocp/ocp.db for unbounded growth in $TMPDIR — better, but still litter.
process.on("exit", () => {
try { rmSync(TEST_OCP_DIR, { recursive: true, force: true }); } catch { /* best effort */ }
});
+4731 -13
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* openclaw-claude-proxy uninstaller
* OCP (Open Claude Proxy) uninstaller
*
* Stops and removes the launchd (macOS) or systemd (Linux) auto-start entry.
* Handles both legacy (ai.openclaw.proxy / openclaw-proxy) and current