feat: W1 batch — config, protocol, ring-buffer, origin, mock-pty (TDD)

Parallel module-builder subagents, disjoint file ownership, shared tree:
- T4 src/config.ts        — loadConfig+M1 origins (32 tests)
- T5 src/protocol.ts      — parse/serialize+SESSION_ID_RE+M7 (60 tests)
- T6 src/session/ring-buffer.ts — byte-accurate, no UTF-8/ANSI split+M2 (15 tests)
- T7 src/http/origin.ts   — host+port match, undefined rejected (12 tests)
- T3 test/helpers/mock-pty.ts — IPty double w/ emitData/emitExit (9 tests)

Verified by orchestrator: tsc --noEmit clean; full suite 128 tests pass.
This commit is contained in:
Yaojia Wang
2026-06-16 08:09:40 +02:00
parent 0e10dc21c3
commit 0fa0b69ce9
15 changed files with 1579 additions and 0 deletions

View File

View File

@@ -0,0 +1,65 @@
/**
* src/session/ring-buffer.ts (T6) — output scrollback for reconnect replay.
*
* M2 (the easy-to-get-wrong part). PTY output is an opaque ANSI byte stream; we
* must NEVER cut a multi-byte UTF-8 codepoint or an ANSI escape sequence in half,
* or the first screen on reconnect renders as garbage — exactly the vibe-coding
* "refresh and the Claude TUI is still there" path. So:
*
* - capacity is measured in REAL UTF-8 bytes (Buffer.byteLength), because
* `string.length` counts UTF-16 code units, not bytes;
* - eviction happens on whole append-chunk boundaries (drop the oldest chunk),
* so no chunk is ever sliced (the trade-off is the live size floats a little
* above/below maxBytes);
* - snapshot() prepends a soft-reset `\x1b[0m` so that even if the oldest
* surviving chunk started mid-attribute, the terminal is reset first.
*
* Implements the frozen `RingBuffer` contract in src/types.ts (read-only import).
*/
import type { RingBuffer } from '../types.js';
const SOFT_RESET = '\x1b[0m';
interface Chunk {
readonly text: string;
readonly bytes: number;
}
/**
* Create a ring buffer holding at most ~`maxBytes` UTF-8 bytes of recent output.
*
* @param maxBytes capacity in real UTF-8 bytes (e.g. `cfg.scrollbackBytes`).
*/
export function createRingBuffer(maxBytes: number): RingBuffer {
// Immutable-update style: each append produces a new chunk list / total rather
// than mutating shared state in place (per coding-style immutability rule).
let chunks: readonly Chunk[] = [];
let totalBytes = 0;
function append(chunk: string): void {
if (chunk.length === 0) return; // nothing to store; keeps accounting clean
const bytes = Buffer.byteLength(chunk, 'utf8');
let nextChunks: Chunk[] = [...chunks, { text: chunk, bytes }];
let nextTotal = totalBytes + bytes;
// Evict whole oldest chunks until we fit — but never drop the only remaining
// chunk, so a lone chunk larger than capacity is kept intact (cannot split).
while (nextTotal > maxBytes && nextChunks.length > 1) {
const oldest = nextChunks[0]!;
nextChunks = nextChunks.slice(1);
nextTotal -= oldest.bytes;
}
chunks = nextChunks;
totalBytes = nextTotal;
}
function snapshot(): string {
// Soft-reset first (M2 safety net), then the current buffer verbatim.
return SOFT_RESET + chunks.map((c) => c.text).join('');
}
return { append, snapshot };
}