mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7d57f489d |
@@ -2,6 +2,20 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -874,6 +874,11 @@ Future `ocp update` invocations sync automatically.
|
||||
| `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). |
|
||||
| `CLAUDE_TUI_MODE` | `false` | **Opt-in.** Set to `"true"` to serve requests via interactive `claude` (no `-p` / `--output-format` → `cc_entrypoint=cli`, subscription pool). **Single-user only** — see [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) for the security constraint. |
|
||||
| `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` | `$HOME` (real home) | (TUI-mode) `HOME` claude runs under. Default is the operator's real home (shared credentials, existing onboarding). Set to a separate path for scratch-home isolation — see ADR 0007 for the credential-fork caveat. |
|
||||
| `OCP_TUI_ENTRYPOINT` | `cli` | (TUI-mode) 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 ADR 0007. |
|
||||
|
||||
### Streaming heartbeat
|
||||
|
||||
@@ -885,6 +890,73 @@ Heartbeats are inert SSE comment lines — conforming SSE clients ignore them. I
|
||||
|
||||
OCP also sends `X-Accel-Buffering: no` on SSE responses so nginx-default proxy buffering does not hold heartbeats in an upstream buffer.
|
||||
|
||||
## 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
|
||||
|
||||
From 2026-06-15 Anthropic routes `claude` invocations by `cc_entrypoint`:
|
||||
|
||||
| Launch method | `cc_entrypoint` | Billing pool |
|
||||
|---------------|-----------------|-------------|
|
||||
| `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. 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.
|
||||
|
||||
### 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
|
||||
# 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
|
||||
```
|
||||
|
||||
Then restart OCP. At boot you will see:
|
||||
|
||||
```
|
||||
⚠️ TUI-mode ON — single-user only; do NOT enable on a multi-user OCP ...
|
||||
TUI-mode: ON home=/home/user cwd=/home/user/.ocp-tui/work wallclock=120000ms
|
||||
```
|
||||
|
||||
### What changes / what doesn't
|
||||
|
||||
- **Callers see no API change.** The response is a normal OpenAI completion object or chunked SSE — identical wire format.
|
||||
- **No real token streaming.** TUI-mode buffers the full response then replays it as chunked SSE. You will see a delay then the complete response rather than real-time tokens.
|
||||
- **Cache and singleflight work normally.** TUI-mode writes the buffered response to the cache on success; cache-hits skip the interactive turn entirely.
|
||||
- **Default path unchanged.** Unset `CLAUDE_TUI_MODE` and restart → `callClaude` / `callClaudeStreaming` are used again, byte-for-byte identical to today.
|
||||
|
||||
### Kill-switch
|
||||
|
||||
```bash
|
||||
unset CLAUDE_TUI_MODE
|
||||
# restart OCP
|
||||
```
|
||||
|
||||
The stream-json path is restored immediately. No other change is needed.
|
||||
|
||||
### Architecture and design decisions
|
||||
|
||||
See [`docs/adr/0007-tui-interactive-mode.md`](docs/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.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
Top-level files a contributor or operator may need to know:
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
# ADR 0007 — TUI Interactive Mode (subscription-pool bridge)
|
||||
|
||||
**Date:** 2026-05-31
|
||||
**Status:** Accepted — amended by PR-4 (entrypoint hardening)
|
||||
**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").
|
||||
|
||||
### 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 (real-home default)
|
||||
|
||||
`TUI_HOME = OCP_TUI_HOME || HOME` (defaults to the operator's real home).
|
||||
|
||||
- **Real-home (default, `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>`):** 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 scratch-home only with a dedicated OAuth or for ephemeral testing.
|
||||
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
## Provenance
|
||||
|
||||
TUI-mode originated in a prototype contributed via PR #101 (see the PR for author attribution). The productionization design is in `docs/superpowers/specs/2026-05-30-tui-mode-production-design.md`. Spikes S1–S6 / T1–T6 were validated live on the test host against `claude v2.1.158`.
|
||||
@@ -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 (S1–S6, T1–T6) 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 6–7), 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 4–5 match imports in Task 6–8; `streamStringAsSSE(res, id, model, content)` defined Task 6, used Tasks 6–7; `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.
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,241 @@
|
||||
// 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";
|
||||
|
||||
export const SESSION_PREFIX = "ocp-tui-"; // per-proxy namespace (coexistence rule)
|
||||
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 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;
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
const PASTE_SETTLE_MS = parseInt(process.env.OCP_TUI_PASTE_MS || "1800", 10);
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// 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 */ }
|
||||
}
|
||||
|
||||
// Prepare the HOME claude runs under. Two modes:
|
||||
// - real-home (tuiHome === realHome OR falsy): no isolation; just trust the cwd
|
||||
// in the real ~/.claude.json. Opt in by setting OCP_TUI_HOME=$HOME.
|
||||
// - scratch-home: a dedicated HOME that reuses the real OAuth via a SYMLINKED
|
||||
// .credentials.json, with a seeded .claude.json (onboarded real config minus
|
||||
// the user's project history; trusts only the scratch cwd) and its own
|
||||
// projects/ dir — so the real ~/.claude is never mutated or polluted.
|
||||
//
|
||||
// ⚠️ CREDENTIAL CAVEAT (verified live): claude rewrites .credentials.json on token
|
||||
// refresh, REPLACING the symlink with a regular-file copy → the scratch home then
|
||||
// FORKS the OAuth credentials. Because OAuth refresh tokens rotate (single-use), a
|
||||
// refresh in the scratch home can invalidate the token the user's real-home claude
|
||||
// relies on. Therefore scratch-home is safe only with a DEDICATED OAuth or for
|
||||
// ephemeral use; for a shared subscription prefer real-home (tuiHome===realHome),
|
||||
// which shares one .credentials.json — identical to how OCP already spawns claude.
|
||||
// Idempotent + best-effort: any failure degrades toward the dialog/cap, never
|
||||
// corrupts. Run BEFORE the session boots.
|
||||
export function prepareTuiHome(realHome, tuiHome, cwd) {
|
||||
if (!tuiHome || tuiHome === realHome) { ensureTuiCwdTrusted(realHome, cwd); return; }
|
||||
try {
|
||||
const claudeDir = `${tuiHome}/.claude`;
|
||||
mkdirSync(`${claudeDir}/projects`, { recursive: true });
|
||||
// Symlink the real credentials (never copy the OAuth token); refresh if missing.
|
||||
const link = `${claudeDir}/.credentials.json`;
|
||||
if (!existsSync(link)) {
|
||||
try { symlinkSync(`${realHome}/.claude/.credentials.json`, link); } catch { /* best effort */ }
|
||||
}
|
||||
// Seed .claude.json ONCE (if absent): start from the onboarded real config,
|
||||
// drop the user's project history, trust only the scratch cwd. mode 0600.
|
||||
const seedPath = `${tuiHome}/.claude.json`;
|
||||
if (!existsSync(seedPath)) {
|
||||
let base = {};
|
||||
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);
|
||||
}
|
||||
|
||||
// ── Billing-classifier labeling ─────────────────────────────────────────
|
||||
// Resolve CLAUDE_CODE_ENTRYPOINT on the spawn env per mode. ALWAYS deletes any
|
||||
// inherited value first (so a stray entrypoint from OCP's own parent env can never
|
||||
// leak into / mislabel the billing header). Then:
|
||||
// "cli" (default) → set "cli": deterministic subscription-pool classification.
|
||||
// HONEST ONLY because OCP's spawn is a genuine interactive PTY (tmux pane,
|
||||
// no -p, stdout not redirected). Never set "cli" on a non-interactive spawn.
|
||||
// "auto" → leave unset → claude self-classifies via its t$A (TTY → cli). Use to
|
||||
// observe/diagnose the real TTY-derived value.
|
||||
// "off" → leave the env exactly as inherited (diagnostics / honesty audit).
|
||||
export function resolveTuiEntrypointEnv(env, mode = "cli") {
|
||||
if (mode === "off") return env;
|
||||
delete env.CLAUDE_CODE_ENTRYPOINT;
|
||||
if (mode === "cli") env.CLAUDE_CODE_ENTRYPOINT = "cli";
|
||||
return env;
|
||||
}
|
||||
|
||||
// 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.
|
||||
function buildTuiCmd(claudeBin, model, sessionId) {
|
||||
return [
|
||||
shq(claudeBin),
|
||||
"--model", shq(model),
|
||||
"--session-id", sessionId,
|
||||
"--strict-mcp-config",
|
||||
"--disallowedTools", shq("mcp__*"),
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
// Full per-request TUI lifecycle:
|
||||
// 1. Pre-trust the scratch cwd (no trust dialog will appear).
|
||||
// 2. Write prompt to a 0600 temp file (no shell injection from prompt content).
|
||||
// 3. Boot an interactive `claude` in a fresh tmux session in the scratch cwd.
|
||||
// 4. Submit the prompt via `send-keys -- "$(cat file)"` + a SEPARATE Enter key
|
||||
// event (spec §5 / T3: literal "\n" in paste does NOT submit; Enter token does).
|
||||
// 5. Block on the native JSONL transcript (located by session-id) until terminal
|
||||
// marker or wall-clock cap.
|
||||
// 6. Always teardown: kill session + rm temp dir (even on throw).
|
||||
export async function runTuiTurn({
|
||||
prompt,
|
||||
model,
|
||||
claudeBin,
|
||||
home,
|
||||
realHome,
|
||||
cwd,
|
||||
wallclockMs = 120000,
|
||||
entrypointMode = "cli",
|
||||
tmux = defaultTmux,
|
||||
}) {
|
||||
const sessionId = randomUUID();
|
||||
const tmuxName = SESSION_PREFIX + sessionId.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)
|
||||
|
||||
// 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);
|
||||
|
||||
// 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 });
|
||||
|
||||
// Build the env: disable marketplace auto-install, strip any Anthropic / CC
|
||||
// env vars that might interfere with interactive-mode classification.
|
||||
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;
|
||||
env.HOME = ehome; // claude reads credentials + writes the transcript under this HOME
|
||||
resolveTuiEntrypointEnv(env, entrypointMode);
|
||||
|
||||
try {
|
||||
// 1. 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 sleep rather than paste
|
||||
// into a non-existent session or issue a billing request without a verified
|
||||
// interactive context. The finally teardown is still harmless (kill-session
|
||||
// is a no-op when the session never existed).
|
||||
const spawnResult = tmux(
|
||||
["new-session", "-d", "-s", tmuxName, "-x", "220", "-y", "50", "-c", cwd,
|
||||
buildTuiCmd(claudeBin, model, sessionId)],
|
||||
{ env },
|
||||
);
|
||||
if (!spawnResult || spawnResult.status !== 0) {
|
||||
throw new Error("tui_spawn_failed: tmux session not created");
|
||||
}
|
||||
await sleep(BOOT_MS);
|
||||
|
||||
// 2. Submit prompt body via `"$(cat file)"` — byte-safe for any content —
|
||||
// then settle, then send a SEPARATE Enter key event to submit the line.
|
||||
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"]);
|
||||
|
||||
// 3. Block on the native transcript (resolved by session-id) until terminal.
|
||||
return await readTuiTranscript({ home: ehome, sessionId, wallclockMs });
|
||||
} finally {
|
||||
// 4. Teardown — always, even on throw.
|
||||
try { tmux(["kill-session", "-t", tmuxName]); } catch { /* already gone */ }
|
||||
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// 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));
|
||||
|
||||
// Project-dir encoding: claude replaces every "/" AND every "." with "-".
|
||||
// Verified live (claude v2.1.158): cwd /home/u/.ocp-tui/work is stored under
|
||||
// projects/-home-u--ocp-tui-work/ (the "." in ".ocp-tui" becomes "-", yielding
|
||||
// the double dash). The earlier "/"-only rule was wrong for dotted paths; the
|
||||
// fixture cwd /tmp/tui-test happened to have no dots so it never surfaced.
|
||||
// NOTE: prefer findTranscriptPath() (glob by session-id) for resolution — it is
|
||||
// immune to the exact encoding rule. This helper is kept for the known-path case.
|
||||
export function encodeCwd(cwd) {
|
||||
return cwd.replace(/[/.]/g, "-");
|
||||
}
|
||||
|
||||
export function transcriptPath(home, cwd, sessionId) {
|
||||
return `${home}/.claude/projects/${encodeCwd(cwd)}/${sessionId}.jsonl`;
|
||||
}
|
||||
|
||||
// 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).
|
||||
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 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.
|
||||
// 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.
|
||||
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 from the turn_duration line (e.g. "cli"),
|
||||
// or null if absent. Lets callers assert the subscription-classified path.
|
||||
// Fixture-confirmed: entrypoint field lives directly on the turn_duration line.
|
||||
export function verifyEntrypoint(events) {
|
||||
for (const ev of events) {
|
||||
if (ev && ev.type === "system" && ev.subtype === "turn_duration") {
|
||||
return ev.entrypoint != null ? ev.entrypoint : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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).
|
||||
// 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.
|
||||
export async function readTuiTranscript({ transcriptPath: p, home, sessionId, wallclockMs = 120000, pollMs = 250 }) {
|
||||
const deadline = Date.now() + wallclockMs;
|
||||
let lastText = "";
|
||||
while (Date.now() < deadline) {
|
||||
const resolved = p || findTranscriptPath(home, sessionId);
|
||||
if (resolved && existsSync(resolved)) {
|
||||
const events = parseTranscriptLines(readFileSync(resolved, "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");
|
||||
}
|
||||
+117
-16
@@ -36,6 +36,7 @@ import { dirname, join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs";
|
||||
import { DEFAULT_PORT } from "./lib/constants.mjs";
|
||||
import { runTuiTurn, reapStaleTuiSessions } from "./lib/tui/session.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
|
||||
@@ -278,6 +279,34 @@ const AUTH_MODE = process.env.CLAUDE_AUTH_MODE || (PROXY_API_KEY ? "shared" : "n
|
||||
const ADMIN_KEY = process.env.OCP_ADMIN_KEY || "";
|
||||
const PROXY_ANONYMOUS_KEY = process.env.PROXY_ANONYMOUS_KEY || "";
|
||||
let CACHE_TTL = parseInt(process.env.CLAUDE_CACHE_TTL || "0", 10); // 0 = disabled, value in ms
|
||||
|
||||
// ── TUI-mode (subscription-pool bridge) — opt-in; default OFF ───────────
|
||||
// When ON: requests are served by spawning interactive `claude` (no -p / no
|
||||
// --output-format) so cc_entrypoint=cli (subscription pool). Responses are
|
||||
// buffered then replayed as chunked SSE. Streaming is always buffered here.
|
||||
// Authority: docs/adr/0007-tui-interactive-mode.md
|
||||
// SECURITY: TUI-mode is SINGLE-USER ONLY. Never enable on a multi-user OCP
|
||||
// (guest prompts would run claude with operator filesystem access).
|
||||
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`;
|
||||
const TUI_HOME = process.env.OCP_TUI_HOME || process.env.HOME;
|
||||
const TUI_ENTRYPOINT = process.env.OCP_TUI_ENTRYPOINT || "cli"; // cli|auto|off — see ADR 0007
|
||||
|
||||
// SECURITY fail-loud: TUI-mode is incompatible with multi-user auth. Under TUI a
|
||||
// guest/anonymous prompt would run interactive claude with the OPERATOR's full
|
||||
// filesystem access (home is NOT isolation). Refuse to boot until B-path isolation
|
||||
// (tools-off + per-key ephemeral home + sandbox) lands. See ADR 0007.
|
||||
if (TUI_MODE && AUTH_MODE === "multi") {
|
||||
console.error(
|
||||
"FATAL: CLAUDE_TUI_MODE=true is incompatible with CLAUDE_AUTH_MODE=multi.\n" +
|
||||
" TUI runs interactive claude with the operator's filesystem access, so a guest/anonymous\n" +
|
||||
" prompt could read operator data. TUI-mode is single-user only until B-path isolation lands.\n" +
|
||||
" See docs/adr/0007-tui-interactive-mode.md. Refusing to start."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (PROXY_ANONYMOUS_KEY && AUTH_MODE !== "multi") {
|
||||
console.warn("WARNING: PROXY_ANONYMOUS_KEY is set but AUTH_MODE is not 'multi' — anonymous key will be ignored");
|
||||
}
|
||||
@@ -801,6 +830,37 @@ function callClaude(model, messages, conversationId, keyName) {
|
||||
});
|
||||
}
|
||||
|
||||
// ── TUI-mode upstream (interactive claude, cc_entrypoint=cli) ───────────
|
||||
// Drop-in replacement for callClaude when TUI_MODE is ON.
|
||||
// Same signature and Promise<string> contract so all downstream
|
||||
// (singleflight → setCachedResponse → 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.
|
||||
// Authority: claude CLI v2.1.158 interactive mode (cc_entrypoint=cli).
|
||||
// SECURITY: A-path single-user ONLY — home is NOT isolation (see ADR 0007).
|
||||
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: TUI_HOME,
|
||||
realHome: process.env.HOME,
|
||||
cwd: TUI_CWD,
|
||||
wallclockMs: TUI_WALLCLOCK_MS,
|
||||
entrypointMode: TUI_ENTRYPOINT,
|
||||
}).then((text) => {
|
||||
recordModelSuccess(cliModel, 0); // elapsed not measurable here; wallclock at reader level
|
||||
return text;
|
||||
}).catch((err) => {
|
||||
recordModelError(cliModel, false);
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
// ── SSE heartbeat (opt-in idle watchdog) ────────────────────────────────
|
||||
// Emits `: keepalive\n\n` SSE comment frames during silent windows on the
|
||||
// streaming response. Design: docs/superpowers/specs/2026-04-25-47-sse-heartbeat-design.md
|
||||
@@ -1032,6 +1092,24 @@ function completionResponse(res, id, model, content) {
|
||||
});
|
||||
}
|
||||
|
||||
// Replay a complete string as a chunked SSE stream (80 codepoints/chunk).
|
||||
// Used by: (a) cache-hit replay on the streaming path; (b) TUI-mode streaming
|
||||
// (buffered response replayed as SSE so clients get the same wire format).
|
||||
// Behaviour is byte-for-byte identical to the original inline cache-replay block.
|
||||
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();
|
||||
}
|
||||
|
||||
// ── Plan usage probe ────────────────────────────────────────────────────
|
||||
// ── Plan usage probe ────────────────────────────────────────────────────
|
||||
// ALIGNMENT: mirrors Claude Code cli.js vE4 rate-limit header extraction.
|
||||
@@ -1524,20 +1602,9 @@ async function handleChatCompletions(req, res) {
|
||||
if (cached) {
|
||||
logEvent("info", "cache_hit", { model, hash: hash.slice(0, 12), hits: cached.hits });
|
||||
if (stream) {
|
||||
// D3: replay cached content as chunked SSE stream (80 codepoints/chunk)
|
||||
const CACHE_REPLAY_CHUNK_SIZE = 80;
|
||||
// D3: replay cached content as chunked SSE stream — delegated to streamStringAsSSE (DRY).
|
||||
const id = `chatcmpl-${randomUUID()}`;
|
||||
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 codepoints = Array.from(cached.response);
|
||||
for (let i = 0; i < codepoints.length; i += CACHE_REPLAY_CHUNK_SIZE) {
|
||||
const chunk = codepoints.slice(i, i + CACHE_REPLAY_CHUNK_SIZE).join("");
|
||||
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { content: chunk }, 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();
|
||||
streamStringAsSSE(res, id, model, cached.response);
|
||||
return;
|
||||
} else {
|
||||
const id = `chatcmpl-${randomUUID()}`;
|
||||
@@ -1551,13 +1618,39 @@ async function handleChatCompletions(req, res) {
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
// Real streaming: pipe stdout from claude process directly as SSE chunks
|
||||
if (TUI_MODE) {
|
||||
// TUI-mode: no real token stream — buffer the full turn via callClaudeTui,
|
||||
// optionally write-back to cache, then replay as chunked SSE.
|
||||
// Default path (TUI_MODE===false) falls through to callClaudeStreaming below,
|
||||
// which is byte-for-byte unchanged from before this gate was added.
|
||||
const t0TuiStream = Date.now();
|
||||
const promptCharsTuiStream = messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0);
|
||||
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: promptCharsTuiStream, responseChars: content.length, elapsedMs: Date.now() - t0TuiStream, 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 });
|
||||
}
|
||||
|
||||
const t0Usage = Date.now();
|
||||
const promptChars = messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0);
|
||||
|
||||
// Select upstream based on TUI_MODE flag. With TUI_MODE===false (default),
|
||||
// upstreamCall===callClaude — identical to the pre-TUI code path.
|
||||
const upstreamCall = TUI_MODE ? callClaudeTui : callClaude;
|
||||
|
||||
// Non-streaming path with stampede protection: wrap the upstream call in singleflight
|
||||
// when cache is enabled and a hash is present. Concurrent identical requests share
|
||||
// one upstream spawn; followers receive the same promise. Streaming-path dedup is
|
||||
@@ -1571,7 +1664,7 @@ async function handleChatCompletions(req, res) {
|
||||
// will re-read the freshly-populated cache entry here rather than spawning.
|
||||
const recheck = getCachedResponse(req._cacheHash, CACHE_TTL);
|
||||
if (recheck) return recheck.response;
|
||||
const c = await callClaude(model, messages, conversationId, req._authKeyName);
|
||||
const c = await upstreamCall(model, messages, conversationId, req._authKeyName);
|
||||
try { setCachedResponse(req._cacheHash, model, c); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
|
||||
return c;
|
||||
});
|
||||
@@ -1593,7 +1686,7 @@ async function handleChatCompletions(req, res) {
|
||||
|
||||
// Fallback: cache disabled (CACHE_TTL=0) or no _cacheHash — original path untouched.
|
||||
try {
|
||||
const content = await callClaude(model, messages, conversationId, req._authKeyName);
|
||||
const content = await upstreamCall(model, messages, conversationId, req._authKeyName);
|
||||
const id = `chatcmpl-${randomUUID()}`;
|
||||
completionResponse(res, id, model, content);
|
||||
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: content.length, elapsedMs: Date.now() - t0Usage, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||
@@ -2012,6 +2105,14 @@ server.listen(PORT, BIND_ADDRESS, () => {
|
||||
if (NO_CONTEXT) console.log(`Context: suppressed (CLAUDE_NO_CONTEXT=true — no CLAUDE.md, no auto-memory)`);
|
||||
if (CACHE_TTL > 0) console.log(`Cache: enabled (TTL=${CACHE_TTL / 1000}s)`);
|
||||
else console.log(`Cache: disabled (set CLAUDE_CACHE_TTL to enable)`);
|
||||
if (TUI_MODE) {
|
||||
console.warn(`⚠️ TUI-mode ON — single-user only; do NOT enable on a multi-user OCP (guest prompts would run claude with operator filesystem access). See ADR 0007.`);
|
||||
console.log(` TUI-mode: ON home=${TUI_HOME} cwd=${TUI_CWD} wallclock=${TUI_WALLCLOCK_MS}ms`);
|
||||
try {
|
||||
const n = reapStaleTuiSessions();
|
||||
if (n) logEvent("info", "tui_reaped_stale_sessions", { count: n });
|
||||
} catch {}
|
||||
}
|
||||
console.log(`---`);
|
||||
console.log(`Coexistence: This proxy does NOT conflict with Claude Code interactive mode.`);
|
||||
console.log(` OCP uses: localhost:${PORT} (HTTP) → claude --output-format stream-json (per-request process)`);
|
||||
|
||||
@@ -1192,6 +1192,353 @@ test("parseStreamJsonEvent: unknown event type returns null", () => {
|
||||
const result = parseStreamJsonEvent({ type: "future_event_type" }, false);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
// ── Suite: streamStringAsSSE wire-format ────────────────────────────────
|
||||
// streamStringAsSSE is not exported from server.mjs (internal helper), so we
|
||||
// test the wire format contract using a local implementation with the same
|
||||
// logic. This validates the protocol shape (role chunk → content chunks →
|
||||
// stop → [DONE]) that both the cache-hit replay and TUI streaming paths rely on.
|
||||
console.log("\nstreamStringAsSSE wire-format:");
|
||||
|
||||
function _testSendSSE(res, data) { res.write(`data: ${JSON.stringify(data)}\n\n`); }
|
||||
|
||||
function _testStreamStringAsSSE(res, id, model, content) {
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
res.writeHead(200, { "Content-Type": "text/event-stream" });
|
||||
_testSendSSE(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) {
|
||||
_testSendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { content: codepoints.slice(i, i + CHUNK).join("") }, finish_reason: null }] });
|
||||
}
|
||||
_testSendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] });
|
||||
res.write("data: [DONE]\n\n");
|
||||
res.end();
|
||||
}
|
||||
|
||||
function _makeFakeRes() {
|
||||
const writes = [];
|
||||
let headsSent = false;
|
||||
return {
|
||||
writes,
|
||||
writeHead(status, headers) { headsSent = true; this._status = status; this._headers = headers; },
|
||||
write(s) { writes.push(s); },
|
||||
end() { this._ended = true; },
|
||||
};
|
||||
}
|
||||
|
||||
test("streamStringAsSSE emits role chunk + content chunks + stop + [DONE]", () => {
|
||||
const res = _makeFakeRes();
|
||||
const content = "hello world";
|
||||
_testStreamStringAsSSE(res, "test-id", "claude-haiku", content);
|
||||
assert.ok(res._status === 200, "writeHead(200) called");
|
||||
assert.ok(res._ended, "res.end() called");
|
||||
// First write: role delta
|
||||
const firstEvent = JSON.parse(res.writes[0].replace(/^data: /, "").trim());
|
||||
assert.equal(firstEvent.choices[0].delta.role, "assistant");
|
||||
assert.equal(firstEvent.choices[0].finish_reason, null);
|
||||
// Since content < 80 chars it fits in one chunk
|
||||
const secondEvent = JSON.parse(res.writes[1].replace(/^data: /, "").trim());
|
||||
assert.equal(secondEvent.choices[0].delta.content, content);
|
||||
// Second-to-last: stop chunk
|
||||
const stopEvent = JSON.parse(res.writes[res.writes.length - 2].replace(/^data: /, "").trim());
|
||||
assert.equal(stopEvent.choices[0].finish_reason, "stop");
|
||||
// Last: [DONE]
|
||||
assert.equal(res.writes[res.writes.length - 1], "data: [DONE]\n\n");
|
||||
});
|
||||
|
||||
test("streamStringAsSSE splits content at 80 codepoints per chunk", () => {
|
||||
const res = _makeFakeRes();
|
||||
const content = "x".repeat(200); // 3 chunks: 80+80+40
|
||||
_testStreamStringAsSSE(res, "test-id-2", "claude-haiku", content);
|
||||
// writes: [role_chunk, content_chunk_1, content_chunk_2, content_chunk_3, stop_chunk, [DONE]]
|
||||
assert.equal(res.writes.length, 6);
|
||||
const c1 = JSON.parse(res.writes[1].replace(/^data: /, "").trim());
|
||||
assert.equal(c1.choices[0].delta.content.length, 80);
|
||||
const c2 = JSON.parse(res.writes[2].replace(/^data: /, "").trim());
|
||||
assert.equal(c2.choices[0].delta.content.length, 80);
|
||||
const c3 = JSON.parse(res.writes[3].replace(/^data: /, "").trim());
|
||||
assert.equal(c3.choices[0].delta.content.length, 40);
|
||||
});
|
||||
|
||||
test("streamStringAsSSE empty content: role + stop + [DONE] only", () => {
|
||||
const res = _makeFakeRes();
|
||||
_testStreamStringAsSSE(res, "test-id-3", "claude-haiku", "");
|
||||
// writes: [role_chunk, stop_chunk, [DONE]]
|
||||
assert.equal(res.writes.length, 3);
|
||||
const stop = JSON.parse(res.writes[1].replace(/^data: /, "").trim());
|
||||
assert.equal(stop.choices[0].finish_reason, "stop");
|
||||
assert.equal(res.writes[2], "data: [DONE]\n\n");
|
||||
});
|
||||
|
||||
// ── Suite: TUI transcript reader ────────────────────────────────────────
|
||||
import { encodeCwd, transcriptPath, findTranscriptPath, parseTranscriptLines, isTerminalLine, extractLatestAssistantText, verifyEntrypoint } from "./lib/tui/transcript.mjs";
|
||||
import { readFileSync as tuiReadFileSync, mkdtempSync as tuiMkdtemp0, mkdirSync as tuiMkdir0, writeFileSync as tuiWrite0 } from "node:fs";
|
||||
import { tmpdir as tuiTmp0 } from "node:os";
|
||||
|
||||
console.log("\nTUI transcript — path formula:");
|
||||
|
||||
test("encodeCwd replaces every slash AND every dot with dash", () => {
|
||||
// Verified live (claude v2.1.158): /home/u/.ocp-tui/work -> -home-u--ocp-tui-work
|
||||
assert.equal(encodeCwd("/home/u/.ocp-tui/work"), "-home-u--ocp-tui-work");
|
||||
assert.equal(encodeCwd("/tmp/tui-test"), "-tmp-tui-test"); // dot-free path still correct
|
||||
});
|
||||
test("transcriptPath composes HOME/.claude/projects/<enc>/<sid>.jsonl", () => {
|
||||
assert.equal(
|
||||
transcriptPath("/home/u", "/home/u/.ocp-tui/work", "abc-123"),
|
||||
"/home/u/.claude/projects/-home-u--ocp-tui-work/abc-123.jsonl"
|
||||
);
|
||||
});
|
||||
test("findTranscriptPath locates <sid>.jsonl across projects subdirs by UUID", () => {
|
||||
const home = tuiMkdtemp0(`${tuiTmp0()}/tui-home-`);
|
||||
const sid = "11111111-2222-3333-4444-555555555555";
|
||||
const proj = `${home}/.claude/projects/-some--weird-encoding`;
|
||||
tuiMkdir0(proj, { recursive: true });
|
||||
tuiWrite0(`${proj}/${sid}.jsonl`, "{}\n");
|
||||
assert.equal(findTranscriptPath(home, sid), `${proj}/${sid}.jsonl`);
|
||||
assert.equal(findTranscriptPath(home, "no-such-uuid"), null);
|
||||
assert.equal(findTranscriptPath(null, sid), null);
|
||||
});
|
||||
|
||||
console.log("\nTUI transcript — parsing + terminal detection:");
|
||||
|
||||
test("parseTranscriptLines skips blank + malformed/partial lines", () => {
|
||||
const evs = parseTranscriptLines('{"a":1}\n\n{bad json\n{"b":2}\n');
|
||||
assert.equal(evs.length, 2);
|
||||
assert.equal(evs[1].b, 2);
|
||||
});
|
||||
test("isTerminalLine true on turn_duration", () => {
|
||||
assert.equal(isTerminalLine({ type: "system", subtype: "turn_duration" }), true);
|
||||
});
|
||||
test("isTerminalLine true on stop_reason tool_use (message-wrapped)", () => {
|
||||
assert.equal(isTerminalLine({ type: "assistant", message: { stop_reason: "tool_use" } }), true);
|
||||
});
|
||||
test("isTerminalLine true on stop_reason tool_use (flat)", () => {
|
||||
assert.equal(isTerminalLine({ stop_reason: "tool_use" }), true);
|
||||
});
|
||||
test("isTerminalLine false on ordinary assistant text line", () => {
|
||||
assert.equal(isTerminalLine({ type: "assistant", message: { content: [{ type: "text", text: "hi" }] } }), false);
|
||||
});
|
||||
test("extractLatestAssistantText concatenates text blocks of LAST assistant entry", () => {
|
||||
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" }] } },
|
||||
];
|
||||
assert.equal(extractLatestAssistantText(evs), "AB");
|
||||
});
|
||||
test("extractLatestAssistantText ignores thinking-only assistant entries", () => {
|
||||
// Fixture shape: thinking block and text block are SEPARATE top-level entries sharing same msg id
|
||||
const evs = [
|
||||
{ type: "assistant", message: { content: [{ type: "thinking", thinking: "hmm" }] } },
|
||||
{ type: "assistant", message: { content: [{ type: "text", text: "PONG" }] } },
|
||||
];
|
||||
assert.equal(extractLatestAssistantText(evs), "PONG");
|
||||
});
|
||||
test("real complete fixture: parseTranscriptLines yields >0 events", () => {
|
||||
const evs = parseTranscriptLines(tuiReadFileSync("./lib/tui/fixtures/complete-haiku.jsonl", "utf8"));
|
||||
assert.ok(evs.length > 0, "fixture must parse to events");
|
||||
});
|
||||
test("real complete fixture: at least one isTerminalLine", () => {
|
||||
const evs = parseTranscriptLines(tuiReadFileSync("./lib/tui/fixtures/complete-haiku.jsonl", "utf8"));
|
||||
assert.ok(evs.some(isTerminalLine), "fixture must contain a terminal line");
|
||||
});
|
||||
test("real complete fixture: extractLatestAssistantText returns non-empty text", () => {
|
||||
const evs = parseTranscriptLines(tuiReadFileSync("./lib/tui/fixtures/complete-haiku.jsonl", "utf8"));
|
||||
assert.ok(extractLatestAssistantText(evs).length > 0, "fixture must yield assistant text");
|
||||
});
|
||||
test("real complete fixture: extractLatestAssistantText returns the FINAL text, not the first", () => {
|
||||
// The fixture's first assistant text is "PONG"; it is followed by 8 later refusal
|
||||
// turns. Pinning the exact FINAL string guards the overwrite-to-last semantic —
|
||||
// a regression that returned the first text block would still pass a length check.
|
||||
const evs = parseTranscriptLines(tuiReadFileSync("./lib/tui/fixtures/complete-haiku.jsonl", "utf8"));
|
||||
assert.equal(extractLatestAssistantText(evs), "I'm moving on. If you have a genuine task, let me know.");
|
||||
});
|
||||
test("real complete fixture: verifyEntrypoint returns 'cli'", () => {
|
||||
const evs = parseTranscriptLines(tuiReadFileSync("./lib/tui/fixtures/complete-haiku.jsonl", "utf8"));
|
||||
assert.equal(verifyEntrypoint(evs), "cli");
|
||||
});
|
||||
|
||||
// ── TUI transcript — polling reader (async) ──────────────────────────────
|
||||
import { readTuiTranscript } from "./lib/tui/transcript.mjs";
|
||||
import { mkdtempSync as tuiMkdtemp, writeFileSync as tuiWriteFile } from "node:fs";
|
||||
import { tmpdir as tuiTmpdir } from "node:os";
|
||||
|
||||
console.log("\nTUI transcript — polling reader:");
|
||||
|
||||
await asyncTest("readTuiTranscript returns assistant text when terminal marker present", async () => {
|
||||
const dir = tuiMkdtemp(`${tuiTmpdir()}/tui-`);
|
||||
const p = `${dir}/s.jsonl`;
|
||||
tuiWriteFile(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 });
|
||||
assert.equal(out, "hello world");
|
||||
});
|
||||
|
||||
await asyncTest("readTuiTranscript honours wall-clock cap and returns partial text", async () => {
|
||||
const dir = tuiMkdtemp(`${tuiTmpdir()}/tui-`);
|
||||
const p = `${dir}/s.jsonl`;
|
||||
tuiWriteFile(p, JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "partial" }] } }) + "\n");
|
||||
const out = await readTuiTranscript({ transcriptPath: p, wallclockMs: 300, pollMs: 50 });
|
||||
assert.equal(out, "partial");
|
||||
});
|
||||
|
||||
await asyncTest("readTuiTranscript throws when no text and cap elapses", async () => {
|
||||
const dir = tuiMkdtemp(`${tuiTmpdir()}/tui-`);
|
||||
const p = `${dir}/missing.jsonl`;
|
||||
let threw = false;
|
||||
try { await readTuiTranscript({ transcriptPath: p, wallclockMs: 200, pollMs: 50 }); }
|
||||
catch { threw = true; }
|
||||
assert.ok(threw, "must throw on empty timeout");
|
||||
});
|
||||
|
||||
// ── TUI session reaper ───────────────────────────────────────────────────
|
||||
import { reapStaleTuiSessions, SESSION_PREFIX } from "./lib/tui/session.mjs";
|
||||
|
||||
console.log("\nTUI session reaper:");
|
||||
|
||||
test("SESSION_PREFIX is ocp-tui-", () => {
|
||||
assert.equal(SESSION_PREFIX, "ocp-tui-");
|
||||
});
|
||||
|
||||
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 });
|
||||
assert.equal(n, 2);
|
||||
assert.equal(killed.join(","), "ocp-tui-aaaa,ocp-tui-cccc");
|
||||
assert.ok(!killed.includes("olp-tui-bbbb"), "olp-tui-bbbb must never be killed");
|
||||
});
|
||||
|
||||
test("reaper returns 0 when tmux status !== 0 (no server)", () => {
|
||||
const fakeTmux = (_args) => ({ status: 1, stdout: "" });
|
||||
const n = reapStaleTuiSessions({ tmux: fakeTmux });
|
||||
assert.equal(n, 0);
|
||||
});
|
||||
|
||||
test("reaper returns 0 for empty session list", () => {
|
||||
const killed = [];
|
||||
const fakeTmux = (args) => {
|
||||
if (args[0] === "list-sessions") return { status: 0, stdout: "" };
|
||||
if (args[0] === "kill-session") { killed.push(args[args.indexOf("-t") + 1]); return { status: 0 }; }
|
||||
return { status: 0, stdout: "" };
|
||||
};
|
||||
const n = reapStaleTuiSessions({ tmux: fakeTmux });
|
||||
assert.equal(n, 0);
|
||||
assert.equal(killed.length, 0);
|
||||
});
|
||||
|
||||
// ── TUI home preparation (scratch vs real) ───────────────────────────────
|
||||
import { prepareTuiHome, ensureTuiCwdTrusted } from "./lib/tui/session.mjs";
|
||||
import { mkdtempSync as hMkdtemp, mkdirSync as hMkdir, writeFileSync as hWrite, readFileSync as hRead, existsSync as hExists, readlinkSync as hReadlink } from "node:fs";
|
||||
import { tmpdir as hTmp } from "node:os";
|
||||
|
||||
console.log("\nTUI home preparation:");
|
||||
|
||||
test("prepareTuiHome scratch mode: symlinks creds, seeds onboarded config, trusts cwd, strips history", () => {
|
||||
const realHome = hMkdtemp(`${hTmp()}/real-`);
|
||||
hMkdir(`${realHome}/.claude`, { recursive: true });
|
||||
hWrite(`${realHome}/.claude/.credentials.json`, '{"token":"x"}');
|
||||
hWrite(`${realHome}/.claude.json`, JSON.stringify({ theme: "dark", projects: { "/old/secret/project": { hasTrustDialogAccepted: true } } }));
|
||||
const tuiHome = hMkdtemp(`${hTmp()}/tui-`);
|
||||
const cwd = `${tuiHome}/work`;
|
||||
prepareTuiHome(realHome, tuiHome, cwd);
|
||||
// credentials symlinked (token never copied)
|
||||
assert.equal(hReadlink(`${tuiHome}/.claude/.credentials.json`), `${realHome}/.claude/.credentials.json`);
|
||||
const seed = JSON.parse(hRead(`${tuiHome}/.claude.json`, "utf8"));
|
||||
assert.equal(seed.hasCompletedOnboarding, true);
|
||||
assert.equal(seed.theme, "dark"); // onboarded config carried over
|
||||
assert.equal(seed.projects[cwd].hasTrustDialogAccepted, true); // scratch cwd trusted
|
||||
assert.equal(seed.projects["/old/secret/project"], undefined); // user project history stripped
|
||||
assert.ok(hExists(`${tuiHome}/.claude/projects`)); // own projects dir
|
||||
});
|
||||
|
||||
test("prepareTuiHome real mode (tuiHome===realHome): no symlink, just trusts cwd in real config", () => {
|
||||
const realHome = hMkdtemp(`${hTmp()}/real2-`);
|
||||
hWrite(`${realHome}/.claude.json`, JSON.stringify({ projects: {} }));
|
||||
const cwd = `${realHome}/work`;
|
||||
prepareTuiHome(realHome, realHome, cwd);
|
||||
assert.ok(!hExists(`${realHome}/.claude/.credentials.json`)); // no scratch symlink created
|
||||
const j = JSON.parse(hRead(`${realHome}/.claude.json`, "utf8"));
|
||||
assert.equal(j.projects[cwd].hasTrustDialogAccepted, true); // cwd trusted in real config
|
||||
});
|
||||
|
||||
// ── resolveTuiEntrypointEnv ───────────────────────────────────────────────
|
||||
import { resolveTuiEntrypointEnv } from "./lib/tui/session.mjs";
|
||||
|
||||
console.log("\nresolveTuiEntrypointEnv:");
|
||||
|
||||
test("mode 'cli' sets CLAUDE_CODE_ENTRYPOINT=cli", () => {
|
||||
const env = {};
|
||||
resolveTuiEntrypointEnv(env, "cli");
|
||||
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, "cli");
|
||||
});
|
||||
|
||||
test("mode 'cli' overwrites an inherited CLAUDE_CODE_ENTRYPOINT value", () => {
|
||||
const env = { CLAUDE_CODE_ENTRYPOINT: "sdk-cli" };
|
||||
resolveTuiEntrypointEnv(env, "cli");
|
||||
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, "cli");
|
||||
});
|
||||
|
||||
test("mode 'auto' deletes CLAUDE_CODE_ENTRYPOINT (leaves unset)", () => {
|
||||
const env = {};
|
||||
resolveTuiEntrypointEnv(env, "auto");
|
||||
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, undefined);
|
||||
assert.ok(!Object.prototype.hasOwnProperty.call(env, "CLAUDE_CODE_ENTRYPOINT"));
|
||||
});
|
||||
|
||||
test("mode 'auto' deletes an inherited CLAUDE_CODE_ENTRYPOINT value", () => {
|
||||
const env = { CLAUDE_CODE_ENTRYPOINT: "sdk-cli" };
|
||||
resolveTuiEntrypointEnv(env, "auto");
|
||||
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, undefined);
|
||||
assert.ok(!Object.prototype.hasOwnProperty.call(env, "CLAUDE_CODE_ENTRYPOINT"));
|
||||
});
|
||||
|
||||
test("mode 'off' leaves an inherited CLAUDE_CODE_ENTRYPOINT value untouched", () => {
|
||||
const env = { CLAUDE_CODE_ENTRYPOINT: "sdk-cli" };
|
||||
resolveTuiEntrypointEnv(env, "off");
|
||||
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, "sdk-cli");
|
||||
});
|
||||
|
||||
test("mode 'off' with no inherited value leaves env unchanged", () => {
|
||||
const env = { OTHER: "x" };
|
||||
resolveTuiEntrypointEnv(env, "off");
|
||||
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, undefined);
|
||||
assert.equal(env.OTHER, "x");
|
||||
});
|
||||
|
||||
test("default mode (no second arg) behaves like 'cli'", () => {
|
||||
const env = { CLAUDE_CODE_ENTRYPOINT: "sdk-cli" };
|
||||
resolveTuiEntrypointEnv(env);
|
||||
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, "cli");
|
||||
});
|
||||
|
||||
// ── TUI session driver: runTuiTurn (live-only, guarded) ──────────────────
|
||||
console.log("\nTUI session driver:");
|
||||
|
||||
if (process.env.OCP_TUI_LIVE === "1") {
|
||||
await asyncTest("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.ok(/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.ok(true);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Cleanup ──
|
||||
closeDb();
|
||||
|
||||
Reference in New Issue
Block a user