feat: add the TUI's API, SSE and degraded-mode client

Everything the dashboard needs from outside the process, behind one typed
surface, so the app loop stays a loop. It is a client of the running server and
nothing else: rows come from the unified list, blocked states from the
approvals inbox, and answering goes through the endpoint that re-captures the
pane and refuses with a 409 when the dialog has already been answered in tmux.
That refusal is a typed result rather than an exception, because a human
beating you to a prompt is normal operation.

Discovery mirrors the daemon probe (`CODEMAN_API_URL`, else loopback on
`CODEMAN_PORT`, self-signed TLS accepted) and credentials come from where
`codeman attach` already reads them. An explicit port outranks the ambient
`CODEMAN_API_URL`, which every managed session exports: a caller that named a
port must not be redirected at whatever server owns its shell.

Input is single-line and `\r`-terminated at this layer, so no caller can strand
text on an unsubmitted composer, and each send is tagged for the server's
exactly-once path. The event stream defaults to a `?sessions=` filter that
matches nothing, which drops the terminal firehose while lifecycle, hook and
approval events still arrive. A silent-but-open stream is caught by a watchdog
rather than a socket error, since that failure mode reports nothing at all.

With no server answering, sessions are listed from tmux on the instance socket
(argv, never a shell string) and decorated from a read-only peek at state.json,
which keeps the "the server died, get me to my sessions" path alive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-22 14:13:58 +02:00
parent 6475c010a6
commit e140132e45
3 changed files with 1668 additions and 0 deletions
+997
View File
@@ -0,0 +1,997 @@
/**
* @fileoverview Everything `codeman tui` needs from the outside world.
*
* The TUI is a CLIENT of the running server, never a second brain (see
* docs/tui-plan.md §4): states, approvals and history all come from the same
* API the web UI uses, so the two surfaces can never disagree. This module is
* the only place in `src/tui/` that does IO. It covers four jobs:
*
* 1. **Discovery + auth** — find the instance's server (`CODEMAN_API_URL`, else
* loopback on `CODEMAN_PORT`), accepting the self-signed cert `--https`
* generates, and read credentials the way `codeman attach` already does
* (env, then the data dir's `.env`).
* 2. **Typed API calls** that unwrap the `{success,data}` envelope and throw a
* `TuiApiError` carrying the status and `errorCode` on failure.
* 3. **Live updates** over SSE, decoded by `tui-sse.ts`, with a staleness
* watchdog and capped backoff. The TUI does not patch rows from payloads: an
* interesting event means "resync", and the app layer debounces the refetch.
* 4. **Degraded mode** — when nothing answers, sessions are enumerated straight
* from tmux plus a read-only peek at `state.json`, which keeps the "the
* server died, get me to my sessions" path that `sc` has today.
*
* IMPORT-SAFE: no probing, no timers and no tmux at import time. Everything a
* `TuiClient` starts is owned by it and released by `close()`; a leaked SSE
* socket or watchdog interval would keep the process alive after the TUI exits.
*
* LIMITATIONS (server surfaces that do not exist, worked around here rather
* than by touching `src/web/`):
* - There is no endpoint that reports the server's hostname, so the header's
* hostname is this machine's (`os.hostname()`) unless `CODEMAN_API_URL`
* points somewhere non-loopback, in which case that host is used.
* - Plan usage has no route of its own: the last-known snapshot rides
* `GET /api/status` as `planUsage` (`web/plan-usage-latest.ts`) and updates
* arrive as `session:statusTelemetry` SSE frames.
* - The unified list carries no token counters or turn-start stamp
* (`TuiSessionRow.lastSubmitAt`), so those stay unset here; the app layer
* merges them from live session state when it wants them.
*
* @module tui/tui-client
*/
import { execFile as execFileCb } from 'node:child_process';
import { readFileSync } from 'node:fs';
import http from 'node:http';
import https from 'node:https';
import { hostname as osHostname } from 'node:os';
import { promisify } from 'node:util';
import { CODEMAN_INSTANCE, dataPath, resolveTmuxSocketName } from '../config/instance.js';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import { probeServer } from '../daemon-control.js';
import { getErrorMessage } from '../types/api.js';
import {
SseFrameParser,
SSE_BASE_BACKOFF_MS,
SSE_MAX_BACKOFF_MS,
SSE_STALE_TIMEOUT_MS,
approvalEventKind,
classifySseEvent,
sseBackoffDelay,
} from './tui-sse.js';
import type { UnifiedSessionItem } from '../services/unified-session-service.js';
import type { CaseInfo } from '../types/api.js';
import type { SearchResponseData } from '../types/search.js';
import type { StatusTelemetry } from '../usage-telemetry.js';
import type { ApprovalItem, ApprovalResolvedInfo } from '../web/approval-inbox.js';
import type { AwayDigestResponse } from '../web/away-digest.js';
// ─────────────────────────────────────────────────────────────────────────────
// Types
// ─────────────────────────────────────────────────────────────────────────────
/** A non-2xx answer, or a `success:false` envelope. */
export class TuiApiError extends Error {
constructor(
message: string,
readonly status: number,
readonly errorCode?: string
) {
super(message);
this.name = 'TuiApiError';
}
}
export interface TuiServerInfo {
/** Origin only, no trailing slash: `https://127.0.0.1:3000`. */
baseUrl: string;
version?: string;
hostname?: string;
/** `CODEMAN_INSTANCE`, empty string for the production layout. */
instance: string;
/** A server answered but rejected our credentials. */
authRequired?: boolean;
}
export interface TuiClientOptions {
/** Skip discovery and talk to this origin. */
baseUrl?: string;
/** Loopback port to probe. Outranks `CODEMAN_API_URL`, so a caller that names a port cannot be redirected by the ambient environment. */
port?: number | string;
username?: string;
password?: string;
/** Where credentials come from when none are passed. Defaults to the instance's `.env`. */
envFilePath?: string;
/** Per-request timeout. Discovery probes use `probeTimeoutMs`. */
timeoutMs?: number;
probeTimeoutMs?: number;
/** Injected for tests; the default shells out to `tmux` via execFile. */
exec?: TuiExecFile;
/** Read-only source of names/dirs in degraded mode. Defaults to the instance's. */
statePath?: string;
}
/** Plan-usage snapshot as the server broadcasts it (telemetry plus its source). */
export type TuiPlanUsage = StatusTelemetry & { sessionId?: string };
export type TuiApprovalAnswer =
| { action: 'approve' }
| { action: 'deny' }
| { action: 'option'; option: number }
| { action: 'text'; text: string };
/**
* Answering is a conversation with a live terminal, so refusal is a normal
* outcome, not an exception: the server re-captures the pane first and 409s
* when the dialog is gone (someone answered it in tmux a second ago).
*/
export type TuiAnswerResult =
| { ok: true; id: string; sessionId: string; action: string }
| { ok: false; reason: 'gone' | 'not-found' | 'rejected' | 'failed'; message: string };
export interface TuiQuickStartOptions {
caseName: string;
mode?: 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi';
sessionName?: string;
/** The tab this spawn came from, for the lineage lines (cosmetic, dropped if unresolvable). */
parentSessionId?: string;
}
export interface TuiQuickStartResult {
sessionId: string;
casePath?: string;
caseName?: string;
}
/** Init snapshot, narrowed to the two facts the dashboard header shows. */
export interface TuiInitState {
version?: string;
planUsage?: TuiPlanUsage | null;
}
export type TuiApprovalEvent =
| { kind: 'pending'; item: ApprovalItem }
| { kind: 'updated'; item: ApprovalItem }
| { kind: 'resolved'; info: ApprovalResolvedInfo };
export type TuiSseStatus = 'connected' | 'reconnecting';
export interface TuiSseStatusDetail {
/** Consecutive failed connects; 0 while connected. */
attempt: number;
/**
* SSE has failed often enough that the app should poll
* `fetchUnifiedSessions()` instead of waiting for events.
*/
recommendPolling: boolean;
message?: string;
}
export interface TuiEventHandlers {
onInit?(state: TuiInitState): void;
/** Something session-shaped changed; the argument is the event name. */
onResync?(event: string): void;
onApproval?(event: TuiApprovalEvent): void;
onPlanUsage?(usage: TuiPlanUsage): void;
onStatus?(status: TuiSseStatus, detail: TuiSseStatusDetail): void;
}
export interface TuiSubscribeOptions {
/**
* Sessions whose `session:terminal` frames this stream wants. The default is
* a sentinel that matches no session id, which suppresses the terminal
* stream (by far the bulk of the wire) without suppressing anything else:
* the `?sessions=` filter applies to terminal frames ONLY, lifecycle and
* hook events still reach every client. The preview pane pulls its own tail
* over HTTP, so the TUI never needs those bytes.
*/
sessionIds?: readonly string[];
staleTimeoutMs?: number;
/** How often the staleness watchdog fires. Defaults to a third of the timeout. */
checkIntervalMs?: number;
baseBackoffMs?: number;
maxBackoffMs?: number;
/** Consecutive failed connects before `recommendPolling` flips on. */
pollingAfterFailures?: number;
}
export interface TuiEventStream {
close(): void;
readonly status: TuiSseStatus;
readonly recommendPolling: boolean;
}
export type TuiExecFile = (file: string, args: readonly string[]) => Promise<{ stdout: string; stderr: string }>;
/** One tmux session as degraded mode sees it. */
export interface TuiTmuxSession {
muxName: string;
/** The `codeman-<prefix>` fragment; mux names carry only the first 8 chars of the id. */
sessionIdPrefix: string;
/** Full id, when `state.json` had exactly one session starting with the prefix. */
sessionId?: string;
name?: string;
workingDir?: string;
mode?: string;
attached: boolean;
/** Epoch ms from tmux's `session_created` (which reports seconds). */
createdAt?: number;
windows?: number;
}
// ─────────────────────────────────────────────────────────────────────────────
// Discovery + credentials (pure halves, exported for tests)
// ─────────────────────────────────────────────────────────────────────────────
const DEFAULT_PORT = 3000;
const DEFAULT_TIMEOUT_MS = 10_000;
const DEFAULT_PROBE_TIMEOUT_MS = 1_500;
/** Ceiling on a single response body. A tail request asks for far less. */
const MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
/** Trim a trailing slash so `new URL(path, base)` never doubles it. */
function normalizeOrigin(url: string): string {
return url.trim().replace(/\/+$/, '');
}
/**
* Origins to probe, in preference order. `CODEMAN_API_URL` (the variable hooks
* already get) wins outright; otherwise both schemes on loopback, https first
* because a production install is HTTPS-only and a plain-http server rejects a
* TLS handshake immediately rather than hanging.
*/
export function tuiServerCandidates(env: { apiUrl?: string; port?: string | number } = {}): string[] {
if (env.apiUrl && env.apiUrl.trim()) return [normalizeOrigin(env.apiUrl)];
const parsed = typeof env.port === 'number' ? env.port : Number.parseInt(String(env.port ?? ''), 10);
const port = Number.isSafeInteger(parsed) && parsed > 0 && parsed < 65536 ? parsed : DEFAULT_PORT;
return [`https://127.0.0.1:${port}`, `http://127.0.0.1:${port}`];
}
/**
* Parse a `KEY=value` env file. Mirrors `readCodemanEnv()` in `cli.ts`: blank
* lines and `#` comments skipped, one layer of matching quotes stripped.
*/
export function parseEnvFile(text: string): Record<string, string> {
const result: Record<string, string> = {};
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match) continue;
let value = match[2].trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
result[match[1]] = value;
}
return result;
}
export interface TuiCredentials {
username: string;
password?: string;
}
/**
* Credentials for the API, env first and the data dir's `.env` as the fallback,
* exactly like the `codeman attach` path. No password means no auth is
* configured (or the user has it only in the server's environment, in which
* case the API answers 401 and `connect()` reports `authRequired`).
*/
export function readCodemanCredentials(envFilePath = dataPath('.env')): TuiCredentials {
let fileEnv: Record<string, string> = {};
try {
fileEnv = parseEnvFile(readFileSync(envFilePath, 'utf-8'));
} catch {
/* absent or unreadable: env-only */
}
const username = process.env.CODEMAN_USERNAME || fileEnv.CODEMAN_USERNAME || 'admin';
const password = process.env.CODEMAN_PASSWORD || fileEnv.CODEMAN_PASSWORD;
return password ? { username, password } : { username };
}
export function basicAuthHeader(credentials: TuiCredentials): string | undefined {
if (!credentials.password) return undefined;
return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
}
// ─────────────────────────────────────────────────────────────────────────────
// Degraded mode
// ─────────────────────────────────────────────────────────────────────────────
const execFileAsync = promisify(execFileCb);
const defaultExecFile: TuiExecFile = async (file, args) => {
const { stdout, stderr } = await execFileAsync(file, [...args], {
timeout: EXEC_TIMEOUT_MS,
encoding: 'utf-8',
maxBuffer: 1024 * 1024,
});
return { stdout, stderr };
};
/**
* Session names Codeman owns on this socket. Remote (`codeman-ssh-…`) and
* docker (`codeman-dkr-…`) names deliberately fail this pattern (they live on
* their own sockets and must never be adopted), and the letters they use are
* outside `[a-f0-9-]`, so this is the same fence `tmux-manager.ts` draws.
*/
const MUX_NAME_PATTERN = /^(?:codeman|claudeman)-([a-f0-9-]+)$/;
/** Field separator for `list-sessions -F`. Session names cannot contain a tab. */
const TMUX_FIELD_SEPARATOR = '\t';
const TMUX_LIST_FORMAT = ['#{session_name}', '#{session_attached}', '#{session_created}', '#{session_windows}'].join(
TMUX_FIELD_SEPARATOR
);
/** Names/dirs from `state.json`, keyed by full session id. Read-only, tolerant. */
function readStateSessions(statePath: string): Map<string, { name?: string; workingDir?: string; mode?: string }> {
const sessions = new Map<string, { name?: string; workingDir?: string; mode?: string }>();
try {
const parsed = JSON.parse(readFileSync(statePath, 'utf-8')) as {
sessions?: Record<string, { name?: string; workingDir?: string; mode?: string }>;
};
for (const [id, value] of Object.entries(parsed.sessions ?? {})) {
if (value && typeof value === 'object') sessions.set(id, value);
}
} catch {
/* no state file, or mid-write garbage: degraded mode is best-effort */
}
return sessions;
}
/** Parse `list-sessions -F` output. Pure, so the format string is unit-testable. */
export function parseTmuxSessionList(stdout: string): TuiTmuxSession[] {
const rows: TuiTmuxSession[] = [];
for (const line of stdout.split('\n')) {
if (!line.trim()) continue;
const [muxName = '', attached = '', created = '', windows = ''] = line.split(TMUX_FIELD_SEPARATOR);
const match = MUX_NAME_PATTERN.exec(muxName);
if (!match) continue;
const createdSeconds = Number.parseInt(created, 10);
const windowCount = Number.parseInt(windows, 10);
rows.push({
muxName,
sessionIdPrefix: match[1],
attached: attached.trim() !== '' && attached.trim() !== '0',
...(Number.isSafeInteger(createdSeconds) && createdSeconds > 0 ? { createdAt: createdSeconds * 1000 } : {}),
...(Number.isSafeInteger(windowCount) && windowCount > 0 ? { windows: windowCount } : {}),
});
}
return rows;
}
/**
* List Codeman's tmux sessions without a server, decorating them with whatever
* `state.json` remembers. Attach is all this supports: there are no states, no
* approvals and no previews when nothing is running the classification.
*
* The tmux call is `execFile` with an argv array (never a shell string), and
* the socket comes from the instance config, so a beta TUI sees only the beta
* instance's sessions.
*/
export async function enumerateTmuxSessions(
options: { exec?: TuiExecFile; socket?: string; statePath?: string } = {}
): Promise<TuiTmuxSession[]> {
const exec = options.exec ?? defaultExecFile;
const socket = options.socket ?? resolveTmuxSocketName();
let stdout = '';
try {
({ stdout } = await exec('tmux', ['-L', socket, 'list-sessions', '-F', TMUX_LIST_FORMAT]));
} catch {
// "no server running on ..." exits non-zero, which is simply an empty list.
return [];
}
const rows = parseTmuxSessionList(stdout);
if (rows.length === 0) return rows;
const state = readStateSessions(options.statePath ?? dataPath('state.json'));
for (const row of rows) {
const matches = [...state.entries()].filter(([id]) => id.startsWith(row.sessionIdPrefix));
// Ambiguity is meaningless here: two ids sharing an 8-char prefix cannot both
// own one mux name, and guessing would put the wrong name on a row.
if (matches.length !== 1) continue;
const [id, entry] = matches[0];
row.sessionId = id;
if (entry.name) row.name = entry.name;
if (entry.workingDir) row.workingDir = entry.workingDir;
if (entry.mode) row.mode = entry.mode;
}
return rows;
}
// ─────────────────────────────────────────────────────────────────────────────
// The client
// ─────────────────────────────────────────────────────────────────────────────
interface RawResponse {
status: number;
body: string;
}
export class TuiClient {
private base: string | null;
private readonly credentials: TuiCredentials;
private readonly timeoutMs: number;
private readonly probeTimeoutMs: number;
private readonly exec: TuiExecFile;
private readonly statePath: string;
private readonly streams = new Set<SseStream>();
private readonly clientId = `codeman-tui-${process.pid}`;
private seq = 0;
private server: TuiServerInfo | null = null;
constructor(private readonly options: TuiClientOptions = {}) {
this.base = options.baseUrl ? normalizeOrigin(options.baseUrl) : null;
const credentials = readCodemanCredentials(options.envFilePath ?? dataPath('.env'));
this.credentials = {
username: options.username ?? credentials.username,
...((options.password ?? credentials.password) ? { password: options.password ?? credentials.password } : {}),
};
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
this.probeTimeoutMs = options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
this.exec = options.exec ?? defaultExecFile;
this.statePath = options.statePath ?? dataPath('state.json');
}
/** The origin in use, or null before a successful `connect()`. */
get baseUrl(): string | null {
return this.base;
}
get serverInfo(): TuiServerInfo | null {
return this.server;
}
/**
* Find the server and read its identity. Returns null when nothing answers,
* which is the app's cue to fall back to `enumerateTmuxSessions()`.
*/
async connect(): Promise<TuiServerInfo | null> {
// An explicit port outranks the ambient `CODEMAN_API_URL` (which every
// Codeman-managed session exports): a caller that named a port must not be
// silently redirected at whatever server happens to own this shell.
const candidates = this.base
? [this.base]
: this.options.port !== undefined
? tuiServerCandidates({ port: this.options.port })
: tuiServerCandidates({ apiUrl: process.env.CODEMAN_API_URL, port: process.env.CODEMAN_PORT });
const probes = await Promise.all(
candidates.map((origin) => probeServer(`${origin}/api/status`, this.probeTimeoutMs))
);
const index = probes.findIndex((probe) => probe.up);
if (index === -1) {
this.server = null;
return null;
}
this.base = candidates[index];
const info: TuiServerInfo = {
baseUrl: this.base,
instance: CODEMAN_INSTANCE,
hostname: this.resolveHostname(this.base),
};
if (probes[index].version) info.version = probes[index].version;
// The probe is unauthenticated, so behind a password it learns nothing but
// "something is there". Ask again with credentials for the version.
try {
const status = await this.requestData<{ version?: string; planUsage?: TuiPlanUsage | null }>(
'GET',
'/api/status'
);
if (status?.version) info.version = status.version;
} catch (err) {
if (err instanceof TuiApiError && (err.status === 401 || err.status === 403)) {
info.authRequired = true;
}
}
this.server = info;
return info;
}
// ── API ────────────────────────────────────────────────────────────────────
async fetchUnifiedSessions(limit?: number): Promise<UnifiedSessionItem[]> {
const query = limit !== undefined ? `?limit=${encodeURIComponent(String(Math.max(1, Math.trunc(limit))))}` : '';
const data = await this.requestData<{ sessions?: UnifiedSessionItem[] }>('GET', `/api/sessions/unified${query}`);
return data?.sessions ?? [];
}
async fetchApprovals(): Promise<ApprovalItem[]> {
const data = await this.requestData<{ approvals?: ApprovalItem[] }>('GET', '/api/approvals');
return data?.approvals ?? [];
}
/**
* Answer a pending prompt. Every refusal the server can reasonably give
* (dialog gone, item already resolved, digit not among the parsed options)
* comes back as a typed result: a human answering a dialog that just closed
* is normal operation, not an error condition.
*/
async answerApproval(id: string, answer: TuiApprovalAnswer): Promise<TuiAnswerResult> {
try {
const data = await this.requestData<{ id: string; sessionId: string; action: string }>(
'POST',
`/api/approvals/${encodeURIComponent(id)}/answer`,
answer
);
return { ok: true, id: data?.id ?? id, sessionId: data?.sessionId ?? '', action: data?.action ?? answer.action };
} catch (err) {
if (!(err instanceof TuiApiError)) throw err;
return { ok: false, reason: answerFailureReason(err), message: err.message };
}
}
/** Raw terminal bytes (ANSI intact) for the preview pane. */
async fetchTerminalTail(sessionId: string, bytes: number): Promise<string> {
const tail = Math.max(1, Math.trunc(bytes));
const data = await this.requestData<{ terminalBuffer?: string }>(
'GET',
`/api/sessions/${encodeURIComponent(sessionId)}/terminal?tail=${tail}`
);
return data?.terminalBuffer ?? '';
}
/**
* Type one line at a session's composer.
*
* Two hard rules from CLAUDE.md, both enforced here so no caller can get them
* wrong: the payload must END with `\r` or the server never issues Enter and
* the text sits unsubmitted, and embedded newlines are stripped rather than
* sent (multi-line input breaks Ink, and the server would join the lines).
* The `clientId`/`seq` pair makes delivery exactly-once, so a retry after a
* dropped connection cannot type the prompt twice.
*/
async sendInput(sessionId: string, text: string): Promise<void> {
const line = text.replace(/[\r\n]+/g, ' ').trim();
this.seq += 1;
await this.requestData('POST', `/api/sessions/${encodeURIComponent(sessionId)}/input`, {
input: `${line}\r`,
useMux: true,
clientId: this.clientId,
seq: this.seq,
});
}
/** Last `seq` sent. Monotonic per process; exposed for tests and diagnostics. */
get lastInputSeq(): number {
return this.seq;
}
/**
* Start a session. Always `quick-start`, never `POST /api/sessions`: only
* this route resolves a case NAME, and it is what routes remote/docker cases
* to the right host instead of stat-ing the path locally.
*/
async quickStart(options: TuiQuickStartOptions): Promise<TuiQuickStartResult> {
const data = await this.requestData<TuiQuickStartResult>('POST', '/api/quick-start', {
caseName: options.caseName,
...(options.mode ? { mode: options.mode } : {}),
...(options.sessionName ? { sessionName: options.sessionName } : {}),
...(options.parentSessionId ? { parentSessionId: options.parentSessionId } : {}),
});
if (!data?.sessionId) throw new TuiApiError('quick-start returned no session id', 502);
return data;
}
async fetchCases(): Promise<CaseInfo[]> {
const data = await this.requestData<CaseInfo[]>('GET', '/api/cases');
return Array.isArray(data) ? data : [];
}
async deleteSession(sessionId: string): Promise<void> {
await this.requestData('DELETE', `/api/sessions/${encodeURIComponent(sessionId)}`);
}
async search(query: string, limit?: number): Promise<SearchResponseData> {
const params = new URLSearchParams({ q: query });
if (limit !== undefined) params.set('limit', String(Math.max(1, Math.trunc(limit))));
const data = await this.requestData<SearchResponseData>('GET', `/api/search?${params.toString()}`);
return data ?? { query, groups: [], totalResults: 0, truncated: false };
}
/**
* The away digest. This route predates the envelope and answers
* `{success:true, digest}` with the payload at the TOP level, so it reads the
* raw body rather than `data` (see CLAUDE.md, away-digest).
*/
async fetchAwayDigest(range?: string): Promise<AwayDigestResponse> {
const query = range ? `?range=${encodeURIComponent(range)}` : '';
const body = await this.requestJson<{ digest?: AwayDigestResponse }>('GET', `/api/away-digest${query}`);
const digest = body?.digest;
if (!digest) throw new TuiApiError('away-digest returned no digest', 502);
return digest;
}
/** Last-known plan-usage snapshot, or null when the account reports none. */
async fetchPlanUsage(): Promise<TuiPlanUsage | null> {
const data = await this.requestData<{ planUsage?: TuiPlanUsage | null }>('GET', '/api/status');
return data?.planUsage ?? null;
}
// ── Degraded mode ──────────────────────────────────────────────────────────
/** Sessions straight from tmux, for when no server answered. */
enumerateTmuxSessions(): Promise<TuiTmuxSession[]> {
return enumerateTmuxSessions({ exec: this.exec, statePath: this.statePath });
}
// ── Live updates ───────────────────────────────────────────────────────────
/**
* Subscribe to `/api/events`. The returned stream owns its socket, its
* watchdog and its backoff timer; `close()` (or the client's) releases all
* three. Handlers are called synchronously as frames decode.
*/
subscribeEvents(handlers: TuiEventHandlers, options: TuiSubscribeOptions = {}): TuiEventStream {
if (!this.base) throw new Error('subscribeEvents() needs a connected client: call connect() first');
const stream = new SseStream(this.base, this.authHeader(), handlers, options, () => this.streams.delete(stream));
this.streams.add(stream);
stream.start();
return stream;
}
/** Tear down every stream this client opened. Safe to call twice. */
close(): void {
for (const stream of [...this.streams]) stream.close();
this.streams.clear();
}
// ── Internals ──────────────────────────────────────────────────────────────
private authHeader(): string | undefined {
return basicAuthHeader(this.credentials);
}
/**
* The header's hostname. No endpoint reports the server's own, so a loopback
* origin means "this machine" and anything else is named by its URL.
*/
private resolveHostname(origin: string): string {
try {
const host = new URL(origin).hostname;
if (host === '127.0.0.1' || host === 'localhost' || host === '::1' || host === '[::1]') return osHostname();
return host;
} catch {
return osHostname();
}
}
/** Unwrap `{success:true,data}`; throw `TuiApiError` on `success:false`. */
private async requestData<T>(method: string, path: string, body?: unknown): Promise<T | undefined> {
const payload = await this.requestJson<{ success?: boolean; data?: T }>(method, path, body);
if (payload && typeof payload === 'object' && payload.success === true) return payload.data;
return payload as unknown as T;
}
/** The parsed response body, envelope and all. */
private async requestJson<T>(method: string, path: string, body?: unknown): Promise<T | undefined> {
const response = await this.raw(method, path, body);
let parsed: unknown;
if (response.body.trim()) {
try {
parsed = JSON.parse(response.body);
} catch {
if (response.status >= 400) {
throw new TuiApiError(httpErrorMessage(response), response.status);
}
throw new TuiApiError(`${method} ${path} returned a non-JSON body`, response.status);
}
}
const envelope = parsed as { success?: boolean; error?: string; errorCode?: string } | undefined;
if (envelope && typeof envelope === 'object' && envelope.success === false) {
throw new TuiApiError(envelope.error || 'request failed', response.status, envelope.errorCode);
}
if (response.status >= 400) {
throw new TuiApiError(httpErrorMessage(response), response.status);
}
return parsed as T | undefined;
}
private raw(method: string, path: string, body?: unknown): Promise<RawResponse> {
if (!this.base) throw new Error('the TUI client is not connected to a server');
const url = new URL(path, `${this.base}/`);
const payload = body === undefined ? undefined : JSON.stringify(body);
const transport = url.protocol === 'https:' ? https : http;
const auth = this.authHeader();
return new Promise<RawResponse>((resolve, reject) => {
const headers: Record<string, string | number> = { Accept: 'application/json' };
if (payload !== undefined) {
headers['Content-Type'] = 'application/json';
headers['Content-Length'] = Buffer.byteLength(payload);
}
if (auth) headers.Authorization = auth;
const req = transport.request(
{
protocol: url.protocol,
hostname: url.hostname,
port: url.port,
method,
path: `${url.pathname}${url.search}`,
// Loopback with the self-signed cert `--https` generates: verifying it
// would fail every local request. Same call the CLI already makes.
rejectUnauthorized: false,
timeout: this.timeoutMs,
headers,
},
(res) => {
let text = '';
let overflowed = false;
res.setEncoding('utf-8');
res.on('data', (chunk: string) => {
if (overflowed) return;
if (text.length + chunk.length > MAX_RESPONSE_BYTES) {
overflowed = true;
res.destroy();
reject(new TuiApiError(`${method} ${path} response exceeded ${MAX_RESPONSE_BYTES} bytes`, 507));
return;
}
text += chunk;
});
res.on('end', () => {
if (!overflowed) resolve({ status: res.statusCode ?? 0, body: text });
});
res.on('error', (err) => reject(new TuiApiError(getErrorMessage(err), 0)));
}
);
req.on('timeout', () => {
req.destroy();
reject(new TuiApiError(`${method} ${path} timed out after ${this.timeoutMs}ms`, 0));
});
req.on('error', (err) => reject(new TuiApiError(getErrorMessage(err), 0)));
if (payload !== undefined) req.write(payload);
req.end();
});
}
}
function httpErrorMessage(response: RawResponse): string {
const detail = response.body.trim().slice(0, 200);
return detail ? `HTTP ${response.status}: ${detail}` : `HTTP ${response.status}`;
}
/** Map an answer failure onto the outcomes the dashboard renders differently. */
function answerFailureReason(err: TuiApiError): 'gone' | 'not-found' | 'rejected' | 'failed' {
if (err.errorCode === 'CONFLICT' || err.status === 409) return 'gone';
if (err.errorCode === 'NOT_FOUND' || err.status === 404) return 'not-found';
if (err.errorCode === 'INVALID_INPUT' || err.status === 400) return 'rejected';
return 'failed';
}
// ─────────────────────────────────────────────────────────────────────────────
// SSE connection
// ─────────────────────────────────────────────────────────────────────────────
/**
* A session id can never look like this, so passing it as the `?sessions=`
* filter drops every `session:terminal` frame while leaving lifecycle, hook and
* approval events untouched.
*/
const NO_TERMINAL_FILTER = 'tui-no-terminal';
const DEFAULT_POLLING_AFTER_FAILURES = 2;
class SseStream implements TuiEventStream {
private req: http.ClientRequest | null = null;
private res: http.IncomingMessage | null = null;
private parser = new SseFrameParser();
private watchdog: NodeJS.Timeout | null = null;
private retryTimer: NodeJS.Timeout | null = null;
private lastTraffic = 0;
private attempt = 0;
private closed = false;
/** Bumped per connection so a late `error`/`close` cannot fail a newer socket. */
private generation = 0;
private _status: TuiSseStatus = 'reconnecting';
private _recommendPolling = false;
private readonly staleTimeoutMs: number;
private readonly checkIntervalMs: number;
private readonly baseBackoffMs: number;
private readonly maxBackoffMs: number;
private readonly pollingAfterFailures: number;
private readonly sessionsParam: string;
constructor(
private readonly baseUrl: string,
private readonly auth: string | undefined,
private readonly handlers: TuiEventHandlers,
options: TuiSubscribeOptions,
private readonly onClosed: () => void
) {
this.staleTimeoutMs = options.staleTimeoutMs ?? SSE_STALE_TIMEOUT_MS;
this.checkIntervalMs = options.checkIntervalMs ?? Math.max(250, Math.floor(this.staleTimeoutMs / 3));
this.baseBackoffMs = options.baseBackoffMs ?? SSE_BASE_BACKOFF_MS;
this.maxBackoffMs = options.maxBackoffMs ?? SSE_MAX_BACKOFF_MS;
this.pollingAfterFailures = options.pollingAfterFailures ?? DEFAULT_POLLING_AFTER_FAILURES;
this.sessionsParam = options.sessionIds?.length ? options.sessionIds.join(',') : NO_TERMINAL_FILTER;
}
get status(): TuiSseStatus {
return this._status;
}
get recommendPolling(): boolean {
return this._recommendPolling;
}
start(): void {
this.open();
}
close(): void {
if (this.closed) return;
this.closed = true;
this.teardown();
this.onClosed();
}
private open(): void {
if (this.closed) return;
const generation = ++this.generation;
const url = new URL('/api/events', `${this.baseUrl}/`);
url.searchParams.set('sessions', this.sessionsParam);
const transport = url.protocol === 'https:' ? https : http;
const headers: Record<string, string> = { Accept: 'text/event-stream', 'Cache-Control': 'no-cache' };
if (this.auth) headers.Authorization = this.auth;
const req = transport.request(
{
protocol: url.protocol,
hostname: url.hostname,
port: url.port,
method: 'GET',
path: `${url.pathname}${url.search}`,
rejectUnauthorized: false,
headers,
},
(res) => {
if (generation !== this.generation || this.closed) {
res.destroy();
return;
}
if (res.statusCode !== 200) {
res.resume();
this.fail(generation, `event stream refused with HTTP ${res.statusCode ?? 0}`);
return;
}
this.res = res;
this.attempt = 0;
this._recommendPolling = false;
this._status = 'connected';
this.touch();
this.armWatchdog();
this.handlers.onStatus?.('connected', { attempt: 0, recommendPolling: false });
res.setEncoding('utf-8');
res.on('data', (chunk: string) => {
if (generation !== this.generation) return;
// Any inbound bytes are liveness, comments and padding included.
this.touch();
for (const frame of this.parser.feed(chunk)) this.dispatch(frame.event, frame.data);
});
res.on('end', () => this.fail(generation, 'event stream ended'));
res.on('close', () => this.fail(generation, 'event stream closed'));
res.on('error', (err) => this.fail(generation, getErrorMessage(err)));
}
);
req.on('error', (err) => this.fail(generation, getErrorMessage(err)));
this.req = req;
req.end();
}
private dispatch(event: string, data: string): void {
switch (classifySseEvent(event)) {
case 'heartbeat':
return;
case 'init': {
const state = parseJson<{ version?: string; planUsage?: TuiPlanUsage | null }>(data);
if (state) this.handlers.onInit?.({ version: state.version, planUsage: state.planUsage ?? null });
return;
}
case 'approval': {
const payload = parseJson<ApprovalItem & ApprovalResolvedInfo>(data);
const kind = approvalEventKind(event);
if (!payload || !kind) return;
if (kind === 'resolved') {
this.handlers.onApproval?.({ kind, info: payload as ApprovalResolvedInfo });
} else {
this.handlers.onApproval?.({ kind, item: payload as ApprovalItem });
}
// An approval landing or clearing also changes how its row is grouped.
this.handlers.onResync?.(event);
return;
}
case 'plan-usage': {
const usage = parseJson<TuiPlanUsage>(data);
if (usage) this.handlers.onPlanUsage?.(usage);
return;
}
case 'resync':
this.handlers.onResync?.(event);
return;
case 'ignore':
return;
}
}
private touch(): void {
this.lastTraffic = Date.now();
}
private armWatchdog(): void {
if (this.watchdog) return;
this.watchdog = setInterval(() => {
if (this.closed) return;
if (Date.now() - this.lastTraffic <= this.staleTimeoutMs) return;
// The socket never errored, it just went quiet: the server heartbeats
// every 15s, so this is a dead stream that would otherwise freeze every
// SSE-driven surface until the user restarted the TUI.
this.fail(this.generation, `no traffic for ${this.staleTimeoutMs}ms`);
}, this.checkIntervalMs);
}
private fail(generation: number, message: string): void {
if (this.closed || generation !== this.generation) return;
this.generation++;
this.teardown();
this.attempt++;
this._status = 'reconnecting';
this._recommendPolling = this.attempt >= this.pollingAfterFailures;
this.handlers.onStatus?.('reconnecting', {
attempt: this.attempt,
recommendPolling: this._recommendPolling,
message,
});
const delay = sseBackoffDelay(this.attempt, this.baseBackoffMs, this.maxBackoffMs);
this.retryTimer = setTimeout(() => {
this.retryTimer = null;
this.open();
}, delay);
}
/** Drop the socket and both timers. Never touches `closed`, so `fail()` can reuse it. */
private teardown(): void {
if (this.watchdog) {
clearInterval(this.watchdog);
this.watchdog = null;
}
if (this.retryTimer) {
clearTimeout(this.retryTimer);
this.retryTimer = null;
}
// The no-op error listeners are not decoration: destroying a socket can
// emit ECONNRESET, and an `error` event with no listener is an uncaught
// exception that would take the TUI down on a routine reconnect.
if (this.res) {
this.res.removeAllListeners();
this.res.on('error', () => {});
this.res.destroy();
this.res = null;
}
if (this.req) {
this.req.removeAllListeners();
this.req.on('error', () => {});
this.req.destroy();
this.req = null;
}
this.parser.reset();
}
}
function parseJson<T>(text: string): T | null {
try {
return JSON.parse(text) as T;
} catch {
return null;
}
}
+206
View File
@@ -0,0 +1,206 @@
/**
* @fileoverview Integration tests for the TUI's live-update stream.
*
* These run against a real loopback `text/event-stream` endpoint rather than a
* mocked socket, because the behaviours that matter here are all socket-level:
* a stream that ENDS, a stream that goes SILENT without erroring (the failure
* mode `EventSource` cannot see, which is why the server heartbeats), and a
* teardown that must leave no timer behind.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import http from 'node:http';
import { TuiClient, type TuiApprovalEvent, type TuiSseStatusDetail } from '../../src/tui/tui-client.js';
const PORT = 3242;
const BASE_URL = `http://127.0.0.1:${PORT}`;
interface Connection {
url: string;
headers: http.IncomingHttpHeaders;
res: http.ServerResponse;
}
const connections: Connection[] = [];
/** Flipped by a test that wants every connect attempt to fail. */
let refuse = false;
let server: http.Server;
let client: TuiClient | null = null;
function frame(event: string, data: unknown): string {
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
}
async function until(predicate: () => boolean, timeoutMs = 3000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (predicate()) return;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error('condition not met before the deadline');
}
beforeAll(async () => {
server = http.createServer((req, res) => {
if (!req.url?.startsWith('/api/events')) {
res.writeHead(404).end();
return;
}
if (refuse) {
res.writeHead(503).end('busy');
return;
}
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' });
// Node holds headers back until the first body write; the real server sends
// an `init` frame immediately, so flush to match it. Without this the
// client never sees a response and every test here waits forever.
res.flushHeaders();
connections.push({ url: req.url, headers: req.headers, res });
});
await new Promise<void>((resolve) => server.listen(PORT, '127.0.0.1', resolve));
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
beforeEach(() => {
connections.length = 0;
refuse = false;
});
afterEach(() => {
client?.close();
client = null;
for (const connection of connections) connection.res.end();
});
describe('subscribeEvents', () => {
it('routes each frame to the handler that owns it', async () => {
const resyncs: string[] = [];
const approvals: TuiApprovalEvent[] = [];
let planUsage: unknown = null;
let init: unknown = null;
client = new TuiClient({ baseUrl: BASE_URL, password: 's3cret' });
client.subscribeEvents({
onInit: (state) => {
init = state;
},
onResync: (event) => resyncs.push(event),
onApproval: (event) => approvals.push(event),
onPlanUsage: (usage) => {
planUsage = usage;
},
});
await until(() => connections.length === 1);
const { res } = connections[0];
res.write(frame('init', { version: '9.9.9', planUsage: { fiveHour: { usedPercentage: 5, resetAt: 1 } } }));
res.write(frame('session:created', { id: 'a' }));
// Split across writes on purpose: the parser must not need frame-aligned reads.
res.write('event: approval:pending\ndata: {"id":"a:1","sessionId":"a",');
res.write('"kind":"permission","createdAt":7}\n\n');
res.write(frame('session:terminal', { id: 'a', data: 'noise' }));
res.write(frame('sse:heartbeat', { t: 1 }));
res.write(frame('session:statusTelemetry', { sessionId: 'a', fiveHour: { usedPercentage: 41, resetAt: 2 } }));
await until(() => planUsage !== null);
expect(init).toEqual({ version: '9.9.9', planUsage: { fiveHour: { usedPercentage: 5, resetAt: 1 } } });
expect(approvals).toEqual([
{ kind: 'pending', item: { id: 'a:1', sessionId: 'a', kind: 'permission', createdAt: 7 } },
]);
expect(planUsage).toEqual({ sessionId: 'a', fiveHour: { usedPercentage: 41, resetAt: 2 } });
// The approval also regrouped a row, so it resyncs too. Terminal and
// heartbeat frames never do.
expect(resyncs).toEqual(['session:created', 'approval:pending']);
});
it('suppresses the terminal firehose by default and carries the auth header', async () => {
client = new TuiClient({ baseUrl: BASE_URL, password: 's3cret' });
client.subscribeEvents({});
await until(() => connections.length === 1);
expect(connections[0].url).toBe('/api/events?sessions=tui-no-terminal');
expect(connections[0].headers.authorization).toBe(`Basic ${Buffer.from('admin:s3cret').toString('base64')}`);
expect(connections[0].headers.accept).toBe('text/event-stream');
});
it('subscribes to the terminal stream of named sessions when asked', async () => {
client = new TuiClient({ baseUrl: BASE_URL });
client.subscribeEvents({}, { sessionIds: ['a', 'b'] });
await until(() => connections.length === 1);
expect(connections[0].url).toBe('/api/events?sessions=a%2Cb');
});
it('reconnects when the stream ends', async () => {
const statuses: Array<[string, TuiSseStatusDetail]> = [];
client = new TuiClient({ baseUrl: BASE_URL });
const stream = client.subscribeEvents(
{ onStatus: (status, detail) => statuses.push([status, detail]) },
{ baseBackoffMs: 10, maxBackoffMs: 20 }
);
await until(() => stream.status === 'connected');
connections[0].res.end();
await until(() => connections.length === 2 && stream.status === 'connected');
expect(statuses.map(([status]) => status)).toEqual(['connected', 'reconnecting', 'connected']);
expect(statuses[1][1].message).toBeTruthy();
});
it('reconnects when a live stream goes silent, which no socket error reports', async () => {
client = new TuiClient({ baseUrl: BASE_URL });
client.subscribeEvents({}, { staleTimeoutMs: 150, checkIntervalMs: 25, baseBackoffMs: 10, maxBackoffMs: 20 });
await until(() => connections.length === 1);
// The server holds the connection open and says nothing: exactly the case
// the watchdog exists for.
await until(() => connections.length === 2);
expect(connections).toHaveLength(2);
});
it('recommends polling once connecting keeps failing', async () => {
refuse = true;
const details: TuiSseStatusDetail[] = [];
client = new TuiClient({ baseUrl: BASE_URL });
const stream = client.subscribeEvents(
{ onStatus: (_status, detail) => details.push(detail) },
{ baseBackoffMs: 10, maxBackoffMs: 20, pollingAfterFailures: 2 }
);
await until(() => details.length >= 2);
expect(details[0]).toMatchObject({ attempt: 1, recommendPolling: false });
expect(details[1]).toMatchObject({ attempt: 2, recommendPolling: true });
expect(stream.recommendPolling).toBe(true);
expect(stream.status).toBe('reconnecting');
});
it('stops reconnecting after close, so the process can exit', async () => {
client = new TuiClient({ baseUrl: BASE_URL });
const stream = client.subscribeEvents({}, { baseBackoffMs: 10, maxBackoffMs: 20 });
await until(() => connections.length === 1);
connections[0].res.end();
stream.close();
const seen = connections.length;
await new Promise((resolve) => setTimeout(resolve, 120));
expect(connections.length).toBe(seen);
});
it('closes every stream the client opened', async () => {
client = new TuiClient({ baseUrl: BASE_URL });
client.subscribeEvents({});
client.subscribeEvents({});
await until(() => connections.length === 2);
client.close();
await until(() => connections.every((connection) => connection.res.socket === null || connection.res.destroyed));
const seen = connections.length;
await new Promise((resolve) => setTimeout(resolve, 120));
expect(connections.length).toBe(seen);
});
it('refuses to subscribe before the client knows where the server is', () => {
const disconnected = new TuiClient({ port: 3999 });
expect(() => disconnected.subscribeEvents({})).toThrow(/connect\(\)/);
});
});
+465
View File
@@ -0,0 +1,465 @@
/**
* @fileoverview Unit tests for the TUI's IO layer: discovery, credentials, the
* typed API surface and degraded-mode tmux enumeration.
*
* The API calls run against a real loopback HTTP server that answers in the
* shapes the routes really produce (the `{success,data}` envelope, plus
* away-digest's legacy top-level `digest`), so an envelope change breaks these
* tests rather than the dashboard. tmux is never executed: the exec function is
* injected, and `test/setup.ts` gives this file its own HOME, so the state file
* it reads is a fixture of its own making.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import http from 'node:http';
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { dataPath } from '../../src/config/instance.js';
import {
TuiApiError,
TuiClient,
basicAuthHeader,
enumerateTmuxSessions,
parseEnvFile,
parseTmuxSessionList,
readCodemanCredentials,
tuiServerCandidates,
type TuiExecFile,
} from '../../src/tui/tui-client.js';
const PORT = 3241;
/** Nothing ever listens here: the "no server" path. */
const DEAD_PORT = 3243;
const BASE_URL = `http://127.0.0.1:${PORT}`;
interface Recorded {
method: string;
url: string;
headers: http.IncomingHttpHeaders;
body: string;
}
const recorded: Recorded[] = [];
type Responder = (req: http.IncomingMessage, res: http.ServerResponse, body: string) => void;
/** Per-test override; falls back to `defaultResponder`. */
let responder: Responder | null = null;
function sendJson(res: http.ServerResponse, status: number, payload: unknown): void {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(payload));
}
const defaultResponder: Responder = (req, res) => {
const url = req.url ?? '';
if (url.startsWith('/api/status')) {
return sendJson(res, 200, {
success: true,
data: { version: '9.9.9', planUsage: { fiveHour: { usedPercentage: 32, resetAt: 1000 } } },
});
}
if (url.startsWith('/api/sessions/unified')) {
return sendJson(res, 200, {
success: true,
data: { sessions: [{ sessionId: 'abc', name: 'w1-codeman', sources: ['live'] }], total: 1 },
});
}
if (url.startsWith('/api/approvals')) {
return sendJson(res, 200, {
success: true,
data: { approvals: [{ id: 'abc:1', sessionId: 'abc', sessionName: 'w1', kind: 'permission', createdAt: 5 }] },
});
}
if (url.includes('/terminal')) {
return sendJson(res, 200, { success: true, data: { terminalBuffer: 'tail bytes', status: 'idle' } });
}
if (url.endsWith('/input')) {
return sendJson(res, 200, { success: true, data: {} });
}
if (url.startsWith('/api/quick-start')) {
return sendJson(res, 200, { success: true, data: { sessionId: 'new-1', casePath: '/cases/x', caseName: 'x' } });
}
if (url.startsWith('/api/cases')) {
return sendJson(res, 200, { success: true, data: [{ name: 'x', path: '/cases/x', location: 'local' }] });
}
if (url.startsWith('/api/search')) {
return sendJson(res, 200, {
success: true,
data: { query: 'foo', groups: [], totalResults: 0, truncated: false },
});
}
if (url.startsWith('/api/away-digest')) {
// Legacy shape: the payload sits at the TOP level, not under `data`.
return sendJson(res, 200, { success: true, digest: { totals: { activeSessions: 2 } } });
}
if (req.method === 'DELETE') {
return sendJson(res, 200, { success: true, data: {} });
}
return sendJson(res, 404, { success: false, error: 'no route', errorCode: 'NOT_FOUND' });
};
function client(overrides: Record<string, unknown> = {}): TuiClient {
return new TuiClient({ baseUrl: BASE_URL, timeoutMs: 4000, ...overrides });
}
let server: http.Server;
const originalApiUrl = process.env.CODEMAN_API_URL;
const originalPort = process.env.CODEMAN_PORT;
beforeAll(async () => {
// This suite runs inside a Codeman-managed session, which exports
// CODEMAN_API_URL pointing at the LIVE server. Discovery consults it, so it
// has to be out of the way before any test calls connect().
delete process.env.CODEMAN_API_URL;
delete process.env.CODEMAN_PORT;
server = http.createServer((req, res) => {
let body = '';
req.setEncoding('utf-8');
req.on('data', (chunk: string) => {
body += chunk;
});
req.on('end', () => {
recorded.push({ method: req.method ?? '', url: req.url ?? '', headers: req.headers, body });
(responder ?? defaultResponder)(req, res, body);
});
});
await new Promise<void>((resolve) => server.listen(PORT, '127.0.0.1', resolve));
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
if (originalApiUrl !== undefined) process.env.CODEMAN_API_URL = originalApiUrl;
if (originalPort !== undefined) process.env.CODEMAN_PORT = originalPort;
});
beforeEach(() => {
recorded.length = 0;
responder = null;
});
describe('parseEnvFile', () => {
it('reads plain assignments and skips comments and blanks', () => {
expect(parseEnvFile('# comment\n\nCODEMAN_USERNAME=bob\nCODEMAN_PASSWORD=hunter2\n')).toEqual({
CODEMAN_USERNAME: 'bob',
CODEMAN_PASSWORD: 'hunter2',
});
});
it('strips one layer of matching quotes', () => {
expect(parseEnvFile('A="quoted"\nB=\'single\'\nC="mismatched\'')).toEqual({
A: 'quoted',
B: 'single',
C: '"mismatched\'',
});
});
it('ignores lines that are not assignments', () => {
expect(parseEnvFile('not an assignment\n1BAD=x\nGOOD=y')).toEqual({ GOOD: 'y' });
});
});
describe('readCodemanCredentials', () => {
it('falls back to the data dir .env when the environment has nothing', () => {
const envPath = dataPath('.env');
mkdirSync(dirname(envPath), { recursive: true });
writeFileSync(envPath, 'CODEMAN_USERNAME=fileuser\nCODEMAN_PASSWORD=filepass\n', 'utf-8');
expect(readCodemanCredentials()).toEqual({ username: 'fileuser', password: 'filepass' });
});
it('lets the environment win over the file', () => {
const envPath = dataPath('.env');
writeFileSync(envPath, 'CODEMAN_USERNAME=fileuser\nCODEMAN_PASSWORD=filepass\n', 'utf-8');
process.env.CODEMAN_PASSWORD = 'envpass';
try {
expect(readCodemanCredentials()).toEqual({ username: 'fileuser', password: 'envpass' });
} finally {
delete process.env.CODEMAN_PASSWORD;
}
});
it('reports admin with no password when nothing is configured', () => {
expect(readCodemanCredentials('/nonexistent/codeman/.env')).toEqual({ username: 'admin' });
});
});
describe('basicAuthHeader', () => {
it('is absent without a password and base64 with one', () => {
expect(basicAuthHeader({ username: 'admin' })).toBeUndefined();
expect(basicAuthHeader({ username: 'admin', password: 's3cret' })).toBe(
`Basic ${Buffer.from('admin:s3cret').toString('base64')}`
);
});
});
describe('tuiServerCandidates', () => {
it('prefers an explicit API url and trims its trailing slash', () => {
expect(tuiServerCandidates({ apiUrl: 'https://box:8443/' })).toEqual(['https://box:8443']);
});
it('probes both schemes on loopback, https first', () => {
expect(tuiServerCandidates({ port: 5000 })).toEqual(['https://127.0.0.1:5000', 'http://127.0.0.1:5000']);
});
it('falls back to port 3000 for junk', () => {
expect(tuiServerCandidates({ port: 'not-a-port' })).toEqual(['https://127.0.0.1:3000', 'http://127.0.0.1:3000']);
});
});
describe('TuiClient envelope handling', () => {
it('unwraps the sessions list', async () => {
const sessions = await client().fetchUnifiedSessions(10);
expect(sessions).toEqual([{ sessionId: 'abc', name: 'w1-codeman', sources: ['live'] }]);
expect(recorded[0].url).toBe('/api/sessions/unified?limit=10');
});
it('unwraps pending approvals', async () => {
const approvals = await client().fetchApprovals();
expect(approvals).toHaveLength(1);
expect(approvals[0].id).toBe('abc:1');
});
it('turns a success:false envelope into a typed error carrying the code', async () => {
responder = (_req, res) =>
sendJson(res, 404, { success: false, error: 'Session not found', errorCode: 'NOT_FOUND' });
await expect(client().fetchTerminalTail('gone', 1000)).rejects.toMatchObject({
name: 'TuiApiError',
status: 404,
errorCode: 'NOT_FOUND',
message: 'Session not found',
});
});
it('turns a non-JSON failure into a typed error too', async () => {
responder = (_req, res) => {
res.writeHead(502, { 'Content-Type': 'text/plain' });
res.end('bad gateway');
};
const err = await client()
.fetchApprovals()
.catch((e: unknown) => e);
expect(err).toBeInstanceOf(TuiApiError);
expect((err as TuiApiError).status).toBe(502);
});
it('sends Basic auth when a password is configured, and none when it is not', async () => {
await client({ username: 'admin', password: 's3cret' }).fetchApprovals();
expect(recorded[0].headers.authorization).toBe(`Basic ${Buffer.from('admin:s3cret').toString('base64')}`);
recorded.length = 0;
await client({ envFilePath: '/nonexistent/codeman/.env' }).fetchApprovals();
expect(recorded[0].headers.authorization).toBeUndefined();
});
});
describe('TuiClient.answerApproval', () => {
it('reports success', async () => {
responder = (_req, res) =>
sendJson(res, 200, { success: true, data: { id: 'abc:1', sessionId: 'abc', action: 'approve' } });
await expect(client().answerApproval('abc:1', { action: 'approve' })).resolves.toEqual({
ok: true,
id: 'abc:1',
sessionId: 'abc',
action: 'approve',
});
});
it('reports a 409 as a typed "gone" result, not an exception', async () => {
responder = (_req, res) =>
sendJson(res, 409, { success: false, error: 'The dialog is no longer on screen', errorCode: 'CONFLICT' });
const result = await client().answerApproval('abc:1', { action: 'option', option: 2 });
expect(result).toEqual({ ok: false, reason: 'gone', message: 'The dialog is no longer on screen' });
});
it('separates "already resolved" from "digit rejected"', async () => {
responder = (_req, res) => sendJson(res, 404, { success: false, error: 'gone', errorCode: 'NOT_FOUND' });
expect((await client().answerApproval('x', { action: 'deny' })).ok).toBe(false);
expect(await client().answerApproval('x', { action: 'deny' })).toMatchObject({ reason: 'not-found' });
responder = (_req, res) => sendJson(res, 400, { success: false, error: 'bad option', errorCode: 'INVALID_INPUT' });
expect(await client().answerApproval('x', { action: 'option', option: 9 })).toMatchObject({ reason: 'rejected' });
});
it('posts the answer body verbatim', async () => {
responder = (_req, res) => sendJson(res, 200, { success: true, data: { id: 'a', sessionId: 'b', action: 'text' } });
await client().answerApproval('a b/c', { action: 'text', text: 'yes please' });
expect(recorded[0].method).toBe('POST');
expect(recorded[0].url).toBe('/api/approvals/a%20b%2Fc/answer');
expect(JSON.parse(recorded[0].body)).toEqual({ action: 'text', text: 'yes please' });
});
});
describe('TuiClient.sendInput', () => {
it('always terminates with a carriage return and never sends a bare newline', async () => {
await client().sendInput('abc', 'hello world');
expect(JSON.parse(recorded[0].body)).toMatchObject({ input: 'hello world\r', useMux: true });
});
it('collapses embedded newlines into spaces (multi-line breaks Ink)', async () => {
await client().sendInput('abc', 'echo A\necho B\r\nline three ');
expect(JSON.parse(recorded[0].body).input).toBe('echo A echo B line three\r');
});
it('tags every send with a stable clientId and a monotonic seq', async () => {
const c = client();
await c.sendInput('abc', 'one');
await c.sendInput('abc', 'two');
await c.sendInput('def', 'three');
const bodies = recorded.map((entry) => JSON.parse(entry.body) as { clientId: string; seq: number });
expect(bodies.map((b) => b.seq)).toEqual([1, 2, 3]);
expect(new Set(bodies.map((b) => b.clientId)).size).toBe(1);
expect(bodies[0].clientId).toMatch(/^codeman-tui-\d+$/);
expect(c.lastInputSeq).toBe(3);
});
});
describe('TuiClient remaining API surface', () => {
it('fetches a terminal tail by byte count', async () => {
await expect(client().fetchTerminalTail('abc', 4096)).resolves.toBe('tail bytes');
expect(recorded[0].url).toBe('/api/sessions/abc/terminal?tail=4096');
});
it('starts sessions through quick-start', async () => {
const result = await client().quickStart({ caseName: 'x', mode: 'claude', parentSessionId: 'abc' });
expect(result.sessionId).toBe('new-1');
expect(recorded[0].url).toBe('/api/quick-start');
expect(JSON.parse(recorded[0].body)).toEqual({ caseName: 'x', mode: 'claude', parentSessionId: 'abc' });
});
it('lists cases', async () => {
await expect(client().fetchCases()).resolves.toEqual([{ name: 'x', path: '/cases/x', location: 'local' }]);
});
it('deletes a session by exact id', async () => {
await client().deleteSession('abc');
expect(recorded[0].method).toBe('DELETE');
expect(recorded[0].url).toBe('/api/sessions/abc');
});
it('searches with an encoded query', async () => {
await client().search('a b', 5);
expect(recorded[0].url).toBe('/api/search?q=a+b&limit=5');
});
it('reads the away digest from its legacy top-level shape', async () => {
await expect(client().fetchAwayDigest('24h')).resolves.toEqual({ totals: { activeSessions: 2 } });
expect(recorded[0].url).toBe('/api/away-digest?range=24h');
});
it('reads plan usage off the status snapshot', async () => {
await expect(client().fetchPlanUsage()).resolves.toEqual({ fiveHour: { usedPercentage: 32, resetAt: 1000 } });
});
it('reports a missing plan-usage snapshot as null rather than throwing', async () => {
responder = (_req, res) => sendJson(res, 200, { success: true, data: { version: '1.0.0', planUsage: null } });
await expect(client().fetchPlanUsage()).resolves.toBeNull();
});
});
describe('TuiClient.connect', () => {
it('discovers the loopback server and reports its identity', async () => {
const info = await new TuiClient({ port: PORT, probeTimeoutMs: 1000 }).connect();
expect(info?.baseUrl).toBe(BASE_URL);
expect(info?.version).toBe('9.9.9');
expect(info?.hostname).toBeTruthy();
expect(info?.authRequired).toBeUndefined();
});
it('reports a server that rejects our credentials instead of calling it down', async () => {
responder = (_req, res) => {
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="codeman"' });
res.end('Unauthorized');
};
const info = await new TuiClient({ port: PORT, probeTimeoutMs: 1000 }).connect();
expect(info?.baseUrl).toBe(BASE_URL);
expect(info?.authRequired).toBe(true);
});
it('returns null when nothing answers', async () => {
await expect(new TuiClient({ port: DEAD_PORT, probeTimeoutMs: 500 }).connect()).resolves.toBeNull();
});
it('refuses to talk to an unconnected client', async () => {
await expect(new TuiClient({ port: DEAD_PORT }).fetchApprovals()).rejects.toThrow(/not connected/);
});
});
describe('degraded-mode tmux enumeration', () => {
const listing = [
'codeman-1a2b3c4d\t1\t1700000000\t1',
'codeman-deadbeef\t0\t1700000100\t2',
'claudeman-cafe0001\t0\t1700000200\t1',
'my-own-tmux-session\t1\t1700000300\t1',
'codeman-ssh-abc\t0\t1700000400\t1',
].join('\n');
it('parses the list format and keeps only Codeman-owned names', () => {
const rows = parseTmuxSessionList(listing);
expect(rows.map((row) => row.muxName)).toEqual(['codeman-1a2b3c4d', 'codeman-deadbeef', 'claudeman-cafe0001']);
expect(rows[0]).toMatchObject({ sessionIdPrefix: '1a2b3c4d', attached: true, createdAt: 1_700_000_000_000 });
expect(rows[1]).toMatchObject({ attached: false, windows: 2 });
});
it('never shells out: the tmux call is an argv array on the instance socket', async () => {
const calls: Array<{ file: string; args: readonly string[] }> = [];
const exec: TuiExecFile = async (file, args) => {
calls.push({ file, args });
return { stdout: listing, stderr: '' };
};
await enumerateTmuxSessions({ exec, socket: 'codeman-beta', statePath: '/nonexistent/state.json' });
expect(calls).toHaveLength(1);
expect(calls[0].file).toBe('tmux');
expect(calls[0].args.slice(0, 4)).toEqual(['-L', 'codeman-beta', 'list-sessions', '-F']);
});
it('decorates rows with names and dirs from state.json, matching on the id prefix', async () => {
const statePath = dataPath('state.json');
writeFileSync(
statePath,
JSON.stringify({
sessions: {
'1a2b3c4d-1111-2222-3333-444444444444': {
name: 'w1-codeman',
workingDir: '/home/dev/codeman',
mode: 'claude',
},
},
}),
'utf-8'
);
const exec: TuiExecFile = async () => ({ stdout: listing, stderr: '' });
const rows = await enumerateTmuxSessions({ exec, statePath });
expect(rows[0]).toMatchObject({
sessionId: '1a2b3c4d-1111-2222-3333-444444444444',
name: 'w1-codeman',
workingDir: '/home/dev/codeman',
mode: 'claude',
});
// No state entry: the row still exists, it just has no decoration.
expect(rows[1].sessionId).toBeUndefined();
expect(rows[1].name).toBeUndefined();
});
it('refuses to guess when two ids share a prefix', async () => {
const statePath = dataPath('ambiguous-state.json');
writeFileSync(
statePath,
JSON.stringify({
sessions: {
'1a2b3c4d-aaaa': { name: 'first' },
'1a2b3c4d-bbbb': { name: 'second' },
},
}),
'utf-8'
);
const exec: TuiExecFile = async () => ({ stdout: 'codeman-1a2b3c4d\t0\t1700000000\t1', stderr: '' });
const rows = await enumerateTmuxSessions({ exec, statePath });
expect(rows[0].name).toBeUndefined();
});
it('treats a dead tmux server as an empty list', async () => {
const exec: TuiExecFile = async () => {
throw new Error('no server running on /tmp/tmux-1000/codeman');
};
await expect(enumerateTmuxSessions({ exec })).resolves.toEqual([]);
});
});