mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-19 09:44:07 +00:00
feat(tui): opt-in CLAUDE_TUI_MODE — interactive subscription-pool bridge (PR-1..PR-4 squashed) (#104)
Reader + driver + home modes + server wiring + entrypoint hardening. 5 independent reviews folded; e2e green; default stream-json path byte-identical when flag off. See PR description + ADR 0007 for the full layer/authority/security detail. Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user