mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +00:00
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>
738 lines
36 KiB
Markdown
738 lines
36 KiB
Markdown
# 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.
|