refactor: rombak dari monorepo ke project tunggal — flat src/{domain,application,infrastructure,interfaces}, satu package.json tanpa workspaces, tsconfig paths → src/*, update install.sh/Dockerfile/CI

This commit is contained in:
asepharyana
2026-09-02 22:56:26 +07:00
parent e91fda3691
commit 0ee0cc4350
196 changed files with 31 additions and 337 deletions
+120
View File
@@ -0,0 +1,120 @@
/**
* Data Transfer Objects for the REST API.
* Wire format for request/response bodies — independent of domain entities.
* Mirrors `apps/interfaces/api/src/dto/*.rs`.
*/
/* -------------------------------------------------------------------------- */
/* Auth DTOs */
/* -------------------------------------------------------------------------- */
export interface LoginRequest {
username: string;
password: string;
}
export interface RegisterRequest {
username: string;
password: string;
/** Optional display name. */
display_name?: string;
}
export interface RefreshRequest {
refresh_token: string;
}
export interface AuthResponse {
access_token: string;
refresh_token: string;
token_type: string;
expires_in: number;
}
/* -------------------------------------------------------------------------- */
/* Session DTOs */
/* -------------------------------------------------------------------------- */
export interface CreateSessionRequest {
title: string;
}
export interface SessionResponse {
id: string;
created_at: number;
updated_at: number;
title: string;
model: string;
message_count: number;
archived: boolean;
summary?: string;
}
export interface SessionListResponse {
sessions: SessionResponse[];
total: number;
}
/* -------------------------------------------------------------------------- */
/* Conversation DTOs */
/* -------------------------------------------------------------------------- */
export interface AddMessageRequest {
role: string;
content: string;
}
export interface ToolFunctionResponse {
name: string;
arguments: string;
}
export interface ToolCallResponse {
id: string;
function: ToolFunctionResponse;
}
export interface MessageResponse {
role: string;
content?: string;
tool_calls?: ToolCallResponse[];
tool_call_id?: string;
}
export interface ConversationResponse {
session_id: string;
messages: MessageResponse[];
message_count: number;
model: string;
system_prompt: string;
max_tokens?: number;
temperature?: number;
}
export interface ChatCompletionRequest {
session_id: string;
message: string;
model?: string;
max_tokens?: number;
temperature?: number;
}
export interface ChatCompletionResponse {
reply: string;
prompt_tokens: number;
completion_tokens: number;
}
/* -------------------------------------------------------------------------- */
/* Error DTO */
/* -------------------------------------------------------------------------- */
export interface ErrorResponse {
message: string;
code: number;
}
/** Build a standardised error response body. */
export function errorBody(message: string, code: number): ErrorResponse {
return { message, code };
}
+71
View File
@@ -0,0 +1,71 @@
/**
* Typed API error with automatic HTTP response conversion.
* Mirrors `apps/interfaces/api/src/error.rs`.
*
* Each kind maps to a status code:
* - BadRequest → 400
* - Unauthorized → 401
* - NotFound → 404
* - Conflict → 409
* - TooManyRequests → 429
* - Internal → 500
* - ChatProxy → 502
*/
export type ApiErrorKind =
| "BadRequest"
| "Unauthorized"
| "NotFound"
| "Conflict"
| "TooManyRequests"
| "Internal"
| "ChatProxy";
export const HTTP_STATUS: Record<ApiErrorKind, number> = {
BadRequest: 400,
Unauthorized: 401,
NotFound: 404,
Conflict: 409,
TooManyRequests: 429,
Internal: 500,
ChatProxy: 502,
};
export class ApiError extends Error {
constructor(public kind: ApiErrorKind, message: string) {
super(message);
this.name = "ApiError";
}
get status(): number {
return HTTP_STATUS[this.kind];
}
static badRequest(msg: string): ApiError {
return new ApiError("BadRequest", msg);
}
static unauthorized(msg: string): ApiError {
return new ApiError("Unauthorized", msg);
}
static notFound(msg: string): ApiError {
return new ApiError("NotFound", msg);
}
static conflict(msg: string): ApiError {
return new ApiError("Conflict", msg);
}
static tooManyRequests(msg: string): ApiError {
return new ApiError("TooManyRequests", msg);
}
static internal(msg: string): ApiError {
return new ApiError("Internal", msg);
}
static chatProxy(msg: string): ApiError {
return new ApiError("ChatProxy", msg);
}
/** Whether an internal error's details should be hidden from the client. */
get exposeDetails(): boolean {
// Internal errors hide details; all others surface the message.
return this.kind !== "Internal";
}
}
+133
View File
@@ -0,0 +1,133 @@
/**
* Authentication handlers — login, register, and token refresh.
* Mirrors `apps/interfaces/api/src/handlers/auth.rs`.
*
* Endpoints:
* - POST /auth/login — authenticate, return JWT pair
* - POST /auth/register — create account, return JWT pair
* - POST /auth/refresh — exchange refresh token for a new pair
*
* All three are rate-limited (20 attempts / 10 min per client).
*/
import type { ApiState, RateLimiter } from "../state.ts";
import { ApiError } from "../error.ts";
import { loadUsers, saveUsers } from "../state.ts";
import type { AuthResponse, LoginRequest, RegisterRequest, RefreshRequest } from "../dto.ts";
/** Login/register brute-force protection: 20 attempts per 10-minute window. */
const AUTH_RATE_LIMIT_MAX = 20;
const AUTH_RATE_LIMIT_WINDOW_SECS = 600;
/** Extract a coarse client identity (X-Forwarded-For first hop or "unknown"). */
function clientId(headers: Headers): string {
const xff = headers.get("x-forwarded-for");
if (xff) {
const first = xff.split(",")[0]?.trim();
if (first) return first;
}
return "unknown";
}
/** Enforce the auth rate limit; throws TooManyRequests when exceeded. */
function enforceRateLimit(limiter: RateLimiter, headers: Headers): void {
if (!limiter.check(clientId(headers), AUTH_RATE_LIMIT_MAX, AUTH_RATE_LIMIT_WINDOW_SECS)) {
throw ApiError.tooManyRequests("Too many requests, try again later");
}
}
function requireFields(req: LoginRequest & RegisterRequest, reg: boolean): void {
if (req.username === "") {
throw ApiError.badRequest("Username is required");
}
if (req.password === "") {
throw ApiError.badRequest("Password is required");
}
if (reg && req.password.length < 6) {
throw ApiError.badRequest("Password must be at least 6 characters");
}
}
/** Issue an AuthResponse for the given subject. */
function authResponse(state: ApiState, sub: string): AuthResponse {
const [access, refresh] = state.token_service.generateTokens(sub);
return {
access_token: access,
refresh_token: refresh,
token_type: "Bearer",
expires_in: 3600, // access token expiry in seconds
};
}
/** POST /auth/login — authenticate and issue a JWT pair. */
export async function loginHandler(
state: ApiState,
headers: Headers,
body: LoginRequest,
): Promise<AuthResponse> {
enforceRateLimit(state.auth_rate_limiter, headers);
requireFields(body, false);
const users = loadUsers(state.store_base_dir);
if (!users) {
throw ApiError.unauthorized("Invalid username or password");
}
const storedHash = users[body.username];
if (!storedHash) {
throw ApiError.unauthorized("Invalid username or password");
}
const valid = await state.password_service.verify(body.password, storedHash);
if (!valid) {
throw ApiError.unauthorized("Invalid username or password");
}
return authResponse(state, body.username);
}
/** POST /auth/register — create a new user account and issue a JWT pair. */
export async function registerHandler(
state: ApiState,
headers: Headers,
body: RegisterRequest,
): Promise<AuthResponse> {
enforceRateLimit(state.auth_rate_limiter, headers);
requireFields(body, true);
// Hash first (async), then serialize the read-modify-write of users.json.
const hash = await state.password_service.hash(body.password);
const unlock = state.users_lock.lock();
try {
const users = loadUsers(state.store_base_dir) ?? {};
if (body.username in users) {
throw ApiError.conflict("Username already exists");
}
users[body.username] = hash;
saveUsers(state.store_base_dir, users);
} finally {
unlock();
}
return authResponse(state, body.username);
}
/** POST /auth/refresh — exchange a refresh token for a fresh pair. */
export async function refreshHandler(
state: ApiState,
headers: Headers,
body: RefreshRequest,
): Promise<AuthResponse> {
enforceRateLimit(state.auth_rate_limiter, headers);
if (body.refresh_token === "") {
throw ApiError.badRequest("Refresh token is required");
}
let sub: string;
try {
sub = state.token_service.verifyRefreshToken(body.refresh_token);
} catch {
throw ApiError.unauthorized("Invalid or expired refresh token");
}
return authResponse(state, sub);
}
+66
View File
@@ -0,0 +1,66 @@
/**
* LLM chat completion proxy handler.
* Mirrors `apps/interfaces/api/src/handlers/chat.rs`.
*
* Endpoints:
* - POST /chat/completions — proxy a completion to the LLM, persisting history.
*/
import type { ApiState } from "../state.ts";
import { ApiError } from "../error.ts";
import { newConversation, Roles, type ChatMessage } from "@zesdex/domain";
import type { ChatCompletionResponse } from "../dto.ts";
/** POST /chat/completions — proxy to LLM provider and persist the conversation. */
export async function chatCompletionsHandler(
state: ApiState,
req: {
session_id: string;
message: string;
model?: string;
max_tokens?: number;
temperature?: number;
},
): Promise<ChatCompletionResponse> {
if (req.session_id === "") throw ApiError.badRequest("session_id is required");
if (req.message === "") throw ApiError.badRequest("message is required");
// Load or create the conversation for this session.
let conversation;
try {
conversation = await state.conversation_service.loadConversation(req.session_id);
} catch {
conversation = newConversation("", req.session_id);
conversation.model = req.model ?? state.llm_client.model;
if (req.max_tokens !== undefined) conversation.max_tokens = req.max_tokens;
if (req.temperature !== undefined) conversation.temperature = req.temperature;
}
if (req.model !== undefined) conversation.model = req.model;
const userMsg: ChatMessage = { role: Roles.User, content: req.message };
conversation.messages.push(userMsg);
// Call the LLM (non-streaming), using the conversation's resolved model.
let response: ChatMessage;
let usage: [number, number] | null = null;
try {
const result = await state.llm_client.chat(
conversation.messages,
undefined,
conversation.max_tokens,
conversation.temperature,
);
response = result.message;
usage = result.usage;
} catch (e) {
throw ApiError.chatProxy(`LLM request failed: ${(e as Error).message}`);
}
const [promptTokens, completionTokens] = usage ?? [0, 0];
const replyText = response.content ?? "";
const assistantMsg: ChatMessage = { role: Roles.Assistant, content: replyText };
await state.conversation_service.addMessage(conversation, assistantMsg);
return { reply: replyText, prompt_tokens: promptTokens, completion_tokens: completionTokens };
}
@@ -0,0 +1,110 @@
/**
* Conversation message-history handlers.
* Mirrors `apps/interfaces/api/src/handlers/conversations.rs`.
*
* Endpoints:
* - GET /sessions/:id/conversations — fetch conversation
* - POST /sessions/:id/conversations — append a message
* - DELETE /sessions/:id/conversations/:cid — delete a message by index
*/
import type { ApiState } from "../state.ts";
import { ApiError } from "../error.ts";
import { Roles, type ChatMessage } from "@zesdex/domain";
import type {
AddMessageRequest,
ConversationResponse,
MessageResponse,
ToolCallResponse,
} from "../dto.ts";
import type { Conversation } from "@zesdex/domain";
/** Map a single ChatMessage to its wire DTO. */
export function toMessageResponse(m: ChatMessage): MessageResponse {
let tool_calls: ToolCallResponse[] | undefined;
if (m.tool_calls && m.tool_calls.length > 0) {
tool_calls = m.tool_calls.map((tc) => ({
id: tc.id,
function: {
name: tc.function.name,
arguments: JSON.stringify(tc.function.arguments),
},
}));
}
const out: MessageResponse = { role: m.role };
if (m.content !== null && m.content !== undefined) out.content = m.content;
if (tool_calls) out.tool_calls = tool_calls;
if (m.tool_call_id) out.tool_call_id = m.tool_call_id;
return out;
}
/** Map a domain Conversation to its wire DTO. */
export function toConversationResponse(c: Conversation): ConversationResponse {
return {
session_id: c.session_id,
messages: c.messages.map(toMessageResponse),
message_count: c.messages.length,
model: c.model,
system_prompt: c.system_prompt,
...(c.max_tokens !== undefined ? { max_tokens: c.max_tokens } : {}),
...(c.temperature !== undefined ? { temperature: c.temperature } : {}),
};
}
/** GET /sessions/:id/conversations — fetch the conversation for a session. */
export async function getConversationHandler(
state: ApiState,
id: string,
): Promise<ConversationResponse> {
if (id === "") throw ApiError.badRequest("Session ID is required");
const conversation = await state.conversation_service.loadConversation(id);
return toConversationResponse(conversation);
}
/** POST /sessions/:id/conversations — append a message to a conversation. */
export async function addMessageHandler(
state: ApiState,
id: string,
req: AddMessageRequest,
): Promise<{ status: number; body: ConversationResponse }> {
if (id === "") throw ApiError.badRequest("Session ID is required");
if (req.content === "") throw ApiError.badRequest("Message content is required");
const role = req.role.toLowerCase();
if (role !== "user" && role !== "assistant") {
throw ApiError.badRequest(`Invalid role: ${req.role}`);
}
const msg: ChatMessage = {
role: role === "user" ? Roles.User : Roles.Assistant,
content: req.content,
};
const conversation = await state.conversation_service.loadConversation(id);
await state.conversation_service.addMessage(conversation, msg);
return { status: 200, body: toConversationResponse(conversation) };
}
/** DELETE /sessions/:id/conversations/:cid — delete a message by index. */
export async function deleteMessageHandler(
state: ApiState,
id: string,
cid: string,
): Promise<{ status: number }> {
if (id === "") throw ApiError.badRequest("Session ID is required");
const index = Number.parseInt(cid, 10);
if (Number.isNaN(index)) {
throw ApiError.badRequest(`Invalid message index: ${cid}`);
}
const conversation = await state.conversation_service.loadConversation(id);
if (index >= conversation.messages.length) {
throw ApiError.notFound(
`Message index ${index} out of bounds (max: ${conversation.messages.length - 1})`,
);
}
conversation.messages.splice(index, 1);
await state.conversation_service.saveConversation(conversation);
return { status: 204 };
}
+73
View File
@@ -0,0 +1,73 @@
/**
* Session management handlers.
* Mirrors `apps/interfaces/api/src/handlers/sessions.rs`.
*
* Endpoints:
* - GET /sessions — list all sessions
* - POST /sessions — create a new session
* - DELETE /sessions/:id — archive/close a session
*/
import type { ApiState } from "../state.ts";
import { ApiError } from "../error.ts";
import type { CreateSessionRequest, SessionListResponse, SessionResponse } from "../dto.ts";
import { newSessionId } from "@zesdex/domain";
/** Map a domain Session to its wire DTO. */
export function toSessionResponse(s: {
id: string;
created_at: number;
updated_at: number;
title: string;
model: string;
message_count: number;
archived: boolean;
summary?: string;
}): SessionResponse {
return {
id: s.id,
created_at: s.created_at,
updated_at: s.updated_at,
title: s.title,
model: s.model,
message_count: s.message_count,
archived: s.archived,
...(s.summary !== undefined ? { summary: s.summary } : {}),
};
}
/** GET /sessions — list all (non-archived) sessions. */
export async function listSessionsHandler(
state: ApiState,
): Promise<SessionListResponse> {
const sessions = await state.session_service.listAll();
const sessionResponses = sessions.map(toSessionResponse);
return { sessions: sessionResponses, total: sessionResponses.length };
}
/** POST /sessions — create a new session. */
export async function createSessionHandler(
state: ApiState,
req: CreateSessionRequest,
): Promise<{ status: number; body: SessionResponse }> {
if (req.title.trim() === "") {
throw ApiError.badRequest("Session title is required");
}
const session = await state.session_service.createSession(req.title);
return { status: 201, body: toSessionResponse(session) };
}
/** DELETE /sessions/:id — archive/close a session (returns 204). */
export async function deleteSessionHandler(
state: ApiState,
id: string,
): Promise<{ status: number }> {
if (id === "") {
throw ApiError.badRequest("Session ID is required");
}
const parsed = newSessionId(id);
if (!parsed.ok) {
throw ApiError.badRequest(`Invalid session ID: ${parsed.error}`);
}
await state.session_service.archiveSession(parsed.value);
return { status: 204 };
}
+10
View File
@@ -0,0 +1,10 @@
/**
* Zesdex REST API interface package.
* Mirrors `apps/interfaces/api/`.
*/
export * from "./dto.ts";
export { ApiError, HTTP_STATUS } from "./error.ts";
export { RateLimiter, newApiState } from "./state.ts";
export type { ApiState } from "./state.ts";
export { authenticateRequest } from "./middleware/auth.ts";
export { startApiServer } from "./server.ts";
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bun
/**
* Zesdex API server — standalone entry point.
* Mirrors the `--api` mode of the Rust gateway.
*/
import { newApiState, startApiServer } from "./index.ts";
function envOr(key: string, fallback: string): string {
const v = process.env[key];
return v && v !== "" ? v : fallback;
}
const port = Number.parseInt(envOr("ZESDEX_API_PORT", "8080"), 10);
const baseDir = envOr("ZESDEX_STORE", process.env.HOME ? `${process.env.HOME}/.local/share/zesdex` : ".");
const jwtSecret = envOr("ZESDEX_JWT_SECRET", "dev-secret-change-me");
const apiKey = envOr("OPENAI_API_KEY", "");
const model = envOr("ZESDEX_MODEL", "claude-opus-5");
const baseUrl = process.env.OPENAI_API_BASE ?? undefined;
const state = newApiState(baseDir, jwtSecret, apiKey, model, baseUrl);
startApiServer(state, port);
+51
View File
@@ -0,0 +1,51 @@
/**
* JWT authentication middleware for the API.
* Mirrors `apps/interfaces/api/src/middleware/auth.rs`.
*
* Validates the `Authorization: Bearer <token>` header and injects the
* validated subject into `req.user`. Refresh tokens are rejected on
* protected routes — only access tokens are accepted.
*/
import { verifyToken } from "@zesdex/infrastructure";
import type { ApiState } from "../state.ts";
/** Claims decoded from a valid JWT, attached to the request. */
export interface JwtClaims {
sub: string;
}
/** Result of authentication: either the subject or an error status/detail. */
export type AuthResult = { ok: true; sub: string } | { ok: false; status: number; detail: string };
/**
* Authenticate a request against the API state's JWT secret.
* Reads the `Authorization: Bearer <token>` header, verifies the signature
* and token type (must be `access`), and returns the subject on success.
*/
export function authenticateRequest(state: ApiState, headers: Headers): AuthResult {
const authHeader = headers.get("Authorization");
if (!authHeader) {
return { ok: false, status: 401, detail: "Missing or invalid Authorization header" };
}
const prefix = "Bearer ";
if (!authHeader.startsWith(prefix)) {
return { ok: false, status: 401, detail: "Missing or invalid Authorization header" };
}
const token = authHeader.slice(prefix.length).trim();
if (!token) {
return { ok: false, status: 401, detail: "Missing or invalid Authorization header" };
}
try {
const claims = verifyToken(state.jwt_secret, token);
if (claims.typ !== "access") {
return {
ok: false,
status: 401,
detail: "refresh tokens are not accepted on protected routes",
};
}
return { ok: true, sub: claims.sub };
} catch (e) {
return { ok: false, status: 401, detail: `Invalid token: ${(e as Error).message}` };
}
}
+183
View File
@@ -0,0 +1,183 @@
/**
* Zesdex REST API — HTTP server and router (Bun.serve).
* Mirrors `apps/interfaces/api/src/lib.rs`.
*
* Routes:
* - Public: /api/v1/auth/{login,register,refresh}, /api/v1/health
* - Protected (JWT): /api/v1/sessions..., /api/v1/chat/completions
*
* Uses Bun's native HTTP server — no external framework dependency.
*/
import type { ApiState } from "./state.ts";
import { ApiError } from "./error.ts";
import { errorBody } from "./dto.ts";
import { authenticateRequest } from "./middleware/auth.ts";
import { loginHandler, registerHandler, refreshHandler } from "./handlers/auth.ts";
import {
listSessionsHandler,
createSessionHandler,
deleteSessionHandler,
} from "./handlers/sessions.ts";
import {
getConversationHandler,
addMessageHandler,
deleteMessageHandler,
} from "./handlers/conversations.ts";
import { chatCompletionsHandler } from "./handlers/chat.ts";
/** CORS headers applied to every response (permissive for local/dev). */
const CORS_HEADERS: Record<string, string> = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};
/** A handler's response: JSON body plus optional status code. */
interface Result {
status: number;
body: unknown;
}
/** Per-request context passed to handlers. */
interface Ctx {
state: ApiState;
params: string[];
body: unknown;
headers: Headers;
}
/** Route mapping: regex matches URL path after `/api/v1`. */
interface Route {
method: string;
pattern: RegExp;
protected: boolean;
handle: (ctx: Ctx) => Promise<Result>;
}
function result(body: unknown, status = 200): Result {
return { status, body };
}
function json(res: Result): Response {
return new Response(JSON.stringify(res.body), {
status: res.status,
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
});
}
function noContent(): Response {
return new Response(null, { status: 204, headers: CORS_HEADERS });
}
/** Build the route table for the API. Matches against paths without the /api/v1 prefix. */
function buildRoutes(): Route[] {
return [
// Auth — public (rate-limited inside handlers)
{ method: "POST", pattern: /^\/auth\/login$/, protected: false, handle: (c) => loginHandler(c.state, c.headers, c.body as never).then((r) => result(r)) },
{ method: "POST", pattern: /^\/auth\/register$/, protected: false, handle: (c) => registerHandler(c.state, c.headers, c.body as never).then((r) => result(r)) },
{ method: "POST", pattern: /^\/auth\/refresh$/, protected: false, handle: (c) => refreshHandler(c.state, c.headers, c.body as never).then((r) => result(r)) },
// Health — public
{ method: "GET", pattern: /^\/health$/, protected: false, handle: () => Promise.resolve(result({ status: "ok" })) },
// Sessions — protected
{ method: "GET", pattern: /^\/sessions$/, protected: true, handle: (c) => listSessionsHandler(c.state).then((s) => result(s)) },
{ method: "POST", pattern: /^\/sessions$/, protected: true, handle: async (c) => {
const r = await createSessionHandler(c.state, c.body as { title: string });
return result(r.body, r.status);
} },
{ method: "DELETE", pattern: /^\/sessions\/([^/]+)$/, protected: true, handle: async (c) => {
await deleteSessionHandler(c.state, c.params[0]!);
return { status: 204, body: null };
} },
// Conversations (sub-resource of sessions) — protected
{ method: "GET", pattern: /^\/sessions\/([^/]+)\/conversations$/, protected: true, handle: async (c) => {
const conv = await getConversationHandler(c.state, c.params[0]!);
return result(conv);
} },
{ method: "POST", pattern: /^\/sessions\/([^/]+)\/conversations$/, protected: true, handle: async (c) => {
const r = await addMessageHandler(c.state, c.params[0]!, c.body as { role: string; content: string });
return result(r.body, r.status);
} },
{ method: "DELETE", pattern: /^\/sessions\/([^/]+)\/conversations\/([^/]+)$/, protected: true, handle: async (c) => {
await deleteMessageHandler(c.state, c.params[0]!, c.params[1]!);
return { status: 204, body: null };
} },
// Chat — protected
{ method: "POST", pattern: /^\/chat\/completions$/, protected: true, handle: (c) => chatCompletionsHandler(c.state, c.body as never).then((s) => result(s)) },
];
}
/** Handle a request against the router; maps ApiError → HTTP response. */
async function handleRequest(state: ApiState, routes: Route[], req: Request): Promise<Response> {
const url = new URL(req.url);
// Strip the /api/v1 version prefix so route patterns match cleanly.
const fullPath = url.pathname;
const path = fullPath.startsWith("/api/v1") ? fullPath.slice("/api/v1".length) : fullPath;
const method = req.method;
// CORS preflight
if (method === "OPTIONS") {
return new Response(null, { status: 204, headers: CORS_HEADERS });
}
const route = routes.find((r) => r.method === method && r.pattern.test(path));
if (!route) {
return json(result(errorBody("Not found", 404), 404));
}
// JWT auth for protected routes (subject is validated but not currently exposed).
if (route.protected) {
const auth = authenticateRequest(state, req.headers);
if (!auth.ok) {
return json(result({ error: "Unauthorized", detail: auth.detail }, auth.status));
}
}
// Parse JSON body if present; read once and reuse.
let body: unknown = undefined;
const raw = await req.text();
if (raw) {
try {
body = JSON.parse(raw);
} catch {
return json(result(errorBody("Bad request: malformed JSON body", 400), 400));
}
}
const match = path.match(route.pattern);
const ctx: Ctx = {
state,
params: match ? match.slice(1) : [],
body,
headers: req.headers,
};
try {
const res = await route.handle(ctx);
if (res.status === 204) return noContent();
return json(res);
} catch (e) {
if (e instanceof ApiError) {
const msg = e.exposeDetails ? e.message : "An internal error occurred";
return json(result(errorBody(msg, e.status), e.status));
}
console.error("Unhandled error:", e);
return json(result(errorBody("An internal error occurred", 500)));
}
}
/** Start the API server on the given port. Returns a handle. */
export function startApiServer(state: ApiState, port: number): { stop: () => void; port: number } {
const routes = buildRoutes();
const server = Bun.serve({
port,
async fetch(req) {
return handleRequest(state, routes, req);
},
});
console.log(`zesdex-api listening on http://0.0.0.0:${server.port}`);
return { stop: () => server.stop(true), port: server.port ?? port };
}
+134
View File
@@ -0,0 +1,134 @@
/**
* Shared application state for the REST API server.
* Mirrors `apps/interfaces/api/src/state.rs`.
*
* `ApiState` holds all service implementations wired to concrete
* infrastructure adapters. Constructed once at startup (composition root)
* and shared across all requests.
*/
import * as fs from "node:fs";
import * as path from "node:path";
import {
SessionServiceImpl,
ConversationServiceImpl,
SettingsServiceImpl,
MemoryServiceImpl,
} from "@zesdex/application";
import {
JsonSettingsRepository,
JsonAppConfigRepository,
JsonConversationRepository,
MarkdownMemoryRepository,
FileSystemSessionRepository,
FileSystemSessionLockRepository,
Argon2PasswordService,
Hs256TokenService,
LlmClient,
} from "@zesdex/infrastructure";
/**
* Sliding-window rate limiter for auth endpoints.
* Mirrors `infrastructure::middleware::rate_limit::RateLimiter`.
*/
export class RateLimiter {
private hits = new Map<string, number[]>();
/** Check whether `key` is within `max` requests per `windowSecs`. Returns true if allowed. */
check(key: string, max: number, windowSecs: number): boolean {
const now = Date.now();
const cutoff = now - windowSecs * 1000;
const list = (this.hits.get(key) ?? []).filter((t) => t > cutoff);
if (list.length >= max) {
this.hits.set(key, list);
return false;
}
list.push(now);
this.hits.set(key, list);
return true;
}
}
/** Concrete session repository wired from infrastructure. */
const sessionRepo = new FileSystemSessionRepository();
const sessionLockRepo = new FileSystemSessionLockRepository();
const conversationRepo = new JsonConversationRepository();
const settingsRepo = new JsonSettingsRepository();
const appConfigRepo = new JsonAppConfigRepository();
const memoryRepo = new MarkdownMemoryRepository();
/** Rooted, long-lived shared API state. */
export interface ApiState {
store_base_dir: string;
jwt_secret: string;
session_service: SessionServiceImpl;
conversation_service: ConversationServiceImpl;
settings_service: SettingsServiceImpl;
memory_service: MemoryServiceImpl;
password_service: Argon2PasswordService;
token_service: Hs256TokenService;
auth_rate_limiter: RateLimiter;
/** Serializes read-modify-write of `users.json` (TOCTOU race guard). */
users_lock: { lock: () => () => void };
llm_client: LlmClient;
}
/**
* A no-op lock that returns an unlock function.
* Bun is single-threaded per process, so the JS event loop already
* serialises synchronous read-modify-write of users.json — the lock is
* retained for structural parity with the Rust mutex guard.
*/
function noopLock(): { lock: () => () => void } {
return { lock: () => () => {} };
}
/** Construct a new API state with all services wired to their defaults. */
export function newApiState(
baseDir: string,
jwtSecret: string,
llmApiKey: string,
llmModel: string,
llmBaseUrl?: string,
): ApiState {
const sessionsDir = path.join(baseDir, "sessions");
const memoryDir = path.join(baseDir, "memories");
const session_service = new SessionServiceImpl(sessionRepo, sessionLockRepo, baseDir);
const conversation_service = new ConversationServiceImpl(conversationRepo, sessionsDir);
const settings_service = new SettingsServiceImpl(settingsRepo, appConfigRepo, baseDir);
const memory_service = new MemoryServiceImpl(memoryRepo, memoryDir);
const token_service = new Hs256TokenService(jwtSecret);
const llm = new LlmClient(llmApiKey, llmModel, llmBaseUrl);
return {
store_base_dir: baseDir,
jwt_secret: jwtSecret,
session_service,
conversation_service,
settings_service,
memory_service,
password_service: new Argon2PasswordService(),
token_service,
auth_rate_limiter: new RateLimiter(),
users_lock: noopLock(),
llm_client: llm,
};
}
/** Load the `users.json` map (username → password hash), or null if absent. */
export function loadUsers(baseDir: string): Record<string, string> | null {
const p = path.join(baseDir, "users.json");
try {
return JSON.parse(fs.readFileSync(p, "utf8")) as Record<string, string>;
} catch {
return null;
}
}
/** Persist the users map to `users.json` (pretty-printed). */
export function saveUsers(baseDir: string, users: Record<string, string>): void {
const p = path.join(baseDir, "users.json");
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify(users, null, 2));
}
+132
View File
@@ -0,0 +1,132 @@
/**
* CLI argument parsing and mode dispatch.
* Mirrors `apps/gateway/src/main.rs` (clap → manual Bun.argv parsing).
*
* Modes (mutually exclusive): --daemon, --attach <session>, --api, --ws,
* --grpc, --web; default is TUI single-process mode.
*/
export interface CliOptions {
daemon: boolean;
attach: string | null;
api: boolean;
apiPort: number;
ws: boolean;
wsPort: number;
grpc: boolean;
grpcPort: number;
web: boolean;
webPort: number;
}
const HELP = `zesdex — autonomous AI coding agent with TUI
Usage: zesdex [OPTIONS]
Options:
--daemon Run as background daemon with IPC socket
--attach <session> Attach TUI client to a running daemon session
--api Run REST API server
--api-port <PORT> REST API port [default: 8080]
--ws Run WebSocket server
--ws-port <PORT> WebSocket port [default: 8081]
--grpc Run gRPC server
--grpc-port <PORT> gRPC port [default: 50051]
--web Serve web frontend
--web-port <PORT> Web frontend port [default: 3000]
-h, --help Print help
-V, --version Print version
`;
/** Parse process argv (excluding node/bun) into CliOptions. */
export function parseCli(argv: string[]): CliOptions | { help: true; text: string } {
const opts: CliOptions = {
daemon: false,
attach: null,
api: false,
apiPort: 8080,
ws: false,
wsPort: 8081,
grpc: false,
grpcPort: 50051,
web: false,
webPort: 3000,
};
let sawFlag = false;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]!;
if (arg === "--help" || arg === "-h") {
return { help: true, text: HELP };
}
if (arg === "--version" || arg === "-V") {
return { help: true, text: `zesdex 1.21.2` };
}
const assignString = (key: keyof CliOptions, next: string | undefined, name: string) => {
if (next === undefined) throw new Error(`flag '${name}' requires a value`);
(opts as unknown as Record<string, unknown>)[key] = next;
};
const assignPort = (key: keyof CliOptions, next: string | undefined, name: string) => {
if (next === undefined) throw new Error(`flag '${name}' requires a value`);
const n = Number(next);
if (!Number.isInteger(n) || n < 0 || n > 65535) {
throw new Error(`invalid port for '${name}': ${next}`);
}
(opts as unknown as Record<string, unknown>)[key] = n;
};
switch (arg) {
case "--daemon":
opts.daemon = true;
sawFlag = true;
break;
case "--attach":
assignString("attach", argv[++i], "--attach");
sawFlag = true;
break;
case "--api":
opts.api = true;
sawFlag = true;
break;
case "--api-port":
assignPort("apiPort", argv[++i], "--api-port");
break;
case "--ws":
opts.ws = true;
sawFlag = true;
break;
case "--ws-port":
assignPort("wsPort", argv[++i], "--ws-port");
break;
case "--grpc":
opts.grpc = true;
sawFlag = true;
break;
case "--grpc-port":
assignPort("grpcPort", argv[++i], "--grpc-port");
break;
case "--web":
opts.web = true;
sawFlag = true;
break;
case "--web-port":
assignPort("webPort", argv[++i], "--web-port");
break;
default:
throw new Error(`unexpected argument '${arg}'\n\n${HELP}`);
}
}
void sawFlag;
return opts;
}
/** Validate that at most one mode flag is set. */
export function modeCount(opts: CliOptions): number {
let count = opts.daemon ? 1 : 0;
if (opts.api) count++;
if (opts.ws) count++;
if (opts.grpc) count++;
if (opts.web) count++;
if (opts.attach !== null) count++;
return count;
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Bootstrap seed — create the initial settings.json / app_config.json if missing.
* Mirrors the Rust `bootstrap` seed.
*/
import { newStore, ensureStoreDirs } from "@zesdex/domain";
import { JsonSettingsRepository, JsonAppConfigRepository } from "@zesdex/infrastructure";
/** Seed default settings + app config into the store directory. */
export async function bootstrap(): Promise<void> {
const store = newStore();
await ensureStoreDirs(store);
const settingsRepo = new JsonSettingsRepository();
const appConfigRepo = new JsonAppConfigRepository();
const settings = await settingsRepo.load(store.base_dir);
const appConfig = await appConfigRepo.load(store.base_dir);
if (!settings.api_keys[settings.provider]) {
console.info(`provider '${settings.provider}' has no API key configured.`);
}
console.info(`seeded defaults at ${store.base_dir}`);
console.info(` provider: ${settings.provider}`);
console.info(` model: ${settings.model}`);
console.info(` providers: ${Object.keys(appConfig.providers).join(", ") || "(none)"}`);
}
// Run when invoked directly as `bun src/bootstrap.ts`.
if (import.meta.main) {
bootstrap().catch((e) => {
console.error(e instanceof Error ? e.message : String(e));
process.exit(1);
});
}
+121
View File
@@ -0,0 +1,121 @@
/**
* Single-process composition root — wires every concrete implementation once.
* Mirrors the `run_single_process` composition in the Rust gateway/TUI.
*
* Assembles: Store → Settings/AppConfig repos → LLM provider → ToolCtx +
* executor → AgentTurnServiceImpl.
*/
import {
newStore,
ensureStoreDirs,
type AgentTurnParams,
type TurnEvent,
type TurnEventSink,
type ChatMessage,
type Store,
userMessage,
resolveEffectiveModel,
} from "@zesdex/domain";
import {
JsonSettingsRepository,
JsonAppConfigRepository,
LlmClient,
resolveApiKey,
InfrastructureToolExecutor,
allTools,
toolDefs,
} from "@zesdex/infrastructure";
import type { ToolCtx } from "@zesdex/infrastructure";
import { AgentTurnServiceImpl } from "@zesdex/application";
/** Wired runtime for single-process mode. */
export interface WiredRuntime {
store: Store;
executor: InfrastructureToolExecutor;
turnService: AgentTurnServiceImpl;
apiKey: string;
model: string;
apiBase?: string;
}
/**
* Wire all layers together once.
* Returns the assembled runtime that both the REPL loop and future
* interface servers share.
*/
export async function runSingleProcess(): Promise<WiredRuntime> {
const store = newStore();
await ensureStoreDirs(store);
// Load settings + app config from the store directory (repos are async).
const settingsRepo = new JsonSettingsRepository();
const appConfigRepo = new JsonAppConfigRepository();
const settings = await settingsRepo.load(store.base_dir);
const appConfig = await appConfigRepo.load(store.base_dir);
// Resolve the effective provider / model / base URL / API key.
const provider = settings.provider;
const model = resolveEffectiveModel(settings, appConfig);
const baseUrl = appConfig.providers[provider]?.api_base ?? undefined;
const apiKey = resolveApiKey(settings, appConfig);
// Build the LLM provider service (implements the ProviderService port).
const llmClient = new LlmClient(apiKey, model, baseUrl);
// Build the tool context + executor.
const executor = new InfrastructureToolExecutor(buildToolCtx(store));
// Assemble the turn service (provider + executor + tool definitions).
const turnService = new AgentTurnServiceImpl(
llmClient,
executor,
toolDefs(allTools()) as never,
);
return { store, executor, turnService, apiKey, model, apiBase: baseUrl };
}
/** Rooted, long-lived event sink that collects events into an array. */
export class ArrayTurnEventSink implements TurnEventSink {
constructor(public events: TurnEvent[] = []) {}
push(event: TurnEvent): void {
this.events.push(event);
}
drain(): TurnEvent[] {
const out = this.events;
this.events = [];
return out;
}
}
/** Build a fresh set of turn params for a single user query. */
export function buildTurnParams(
runtime: WiredRuntime,
userText: string,
events: TurnEvent[],
abort: AbortController,
): AgentTurnParams {
const sink = new ArrayTurnEventSink(events);
const messages: ChatMessage[] = [userMessage(userText)];
return {
messages,
session_dir: runtime.store.base_dir,
workspace_roots: [process.cwd()],
turn_events: sink,
in_flight: { value: false },
abort,
api_key: runtime.apiKey,
model: runtime.model,
api_base: runtime.apiBase,
};
}
/** Build a ToolCtx with the standard fields. */
function buildToolCtx(store: Store): ToolCtx {
return {
sessionDir: store.base_dir,
workspaces: [process.cwd()],
turnEvents: new ArrayTurnEventSink(),
workflowFindings: [],
} as unknown as ToolCtx;
}
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env bun
/**
* Zesdex CLI — main entry point.
* Mirrors `apps/gateway/src/main.rs`.
*
* Parses argv, sets up file logging, and dispatches to the requested mode.
* Default mode runs the single-process REPL loop.
*/
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { newStore } from "@zesdex/domain";
import { parseCli, modeCount } from "./args.ts";
import { runSingleProcess } from "./compose.ts";
/** Initialise file logging (append to <data-dir>/zesdex/zesdex.log). */
function initLogging(): void {
const dataDir = os.homedir();
const logDir = path.join(dataDir, ".local", "share", "zesdex");
fs.mkdirSync(logDir, { recursive: true });
const logPath = path.join(logDir, "zesdex.log");
const stream = fs.createWriteStream(logPath, { flags: "a" });
const originalLog = console.log;
const originalError = console.error;
const originalWarn = console.warn;
const originalInfo = console.info;
const write = (level: string, args: unknown[]) => {
const line = `[${new Date().toISOString()}] ${level} ${args
.map((a) => (typeof a === "string" ? a : JSON.stringify(a)))
.join(" ")}`;
stream.write(line + "\n");
if (level === "ERROR") originalError(...args);
else if (level === "WARN") originalWarn(...args);
else if (level === "INFO") originalInfo(...args);
else originalLog(...args);
};
console.log = (...a: unknown[]) => write("LOG", a);
console.info = (...a: unknown[]) => write("INFO", a);
console.warn = (...a: unknown[]) => write("WARN", a);
console.error = (...a: unknown[]) => write("ERROR", a);
}
/** Run the REPL loop (default single-process mode). */
async function runRepl(): Promise<void> {
const runtime = await runSingleProcess();
console.info("zesdex ready — single-process REPL mode. Type `exit` to quit.");
// Minimal REPL over stdin.
// Use node readline.
const readline = (await import("node:readline")).default;
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
rl.on("line", async (line: string) => {
const text = line.trim();
if (text === "exit" || text === "quit" || text === "/exit") {
rl.close();
process.exit(0);
}
if (text === "" || text.startsWith("/")) {
if (text === "/help") {
console.log("Commands: type a message to talk to the agent; `exit` to quit.");
}
return;
}
const events: import("@zesdex/domain").TurnEvent[] = [];
const abort = new AbortController();
const params = buildTurnParamsFrom(runtime, text, events, abort);
process.stdout.write("…");
try {
await runtime.turnService.runTurn(params);
// Print the final assistant content from events.
const lastTokens: string[] = [];
for (const ev of events) {
if (ev.kind === "stream_token") lastTokens.push(ev.content);
}
const full = lastTokens.join("");
process.stdout.write("\n");
if (full.trim() !== "") console.log(full.trim());
else console.log("(no response content)");
} catch (e) {
console.error(`turn failed: ${(e as Error).message}`);
}
rl.prompt();
});
rl.prompt();
}
// Imported lazily to avoid a circular type-only dependency.
import { buildTurnParams } from "./compose.ts";
function buildTurnParamsFrom(
runtime: Parameters<typeof buildTurnParams>[0],
text: string,
events: import("@zesdex/domain").TurnEvent[],
abort: AbortController,
) {
return buildTurnParams(runtime, text, events, abort);
}
/** Main entry. */
async function main(): Promise<void> {
initLogging();
const args = process.argv.slice(2);
let parsed;
try {
parsed = parseCli(args);
} catch (e) {
console.error((e as Error).message);
process.exit(2);
}
if ("help" in parsed) {
console.log(parsed.text);
process.exit(0);
}
const opts = parsed;
if (modeCount(opts) > 1) {
console.error(
"Cannot specify multiple modes: --daemon, --attach, --api, --ws, --grpc, --web are mutually exclusive",
);
process.exit(2);
}
if (opts.daemon) {
const { runDaemon } = await import("@zesdex/daemon");
console.info("daemon mode — creating per-session IPC socket");
const handle = await runDaemon();
const stop = () => void handle.stop().then(() => process.exit(0));
process.on("SIGINT", stop);
process.on("SIGTERM", stop);
await new Promise(() => {});
} else if (opts.attach !== null) {
const { runAttach } = await import("@zesdex/daemon");
console.info(`attach mode for daemon session '${opts.attach}'`);
const store = newStore();
const socketPath = path.join(store.base_dir, "run", `${opts.attach}.sock`);
await runAttach(socketPath);
} else if (opts.api) {
const { newApiState, startApiServer } = await import("@zesdex/api");
const baseDir =
process.env.ZESDEX_STORE ?? path.join(os.homedir(), ".local", "share", "zesdex");
const state = newApiState(
baseDir,
process.env.ZESDEX_JWT_SECRET ?? "dev-secret-change-me",
process.env.OPENAI_API_KEY ?? "",
process.env.ZESDEX_MODEL ?? "claude-opus-5",
process.env.OPENAI_API_BASE ?? undefined,
);
startApiServer(state, opts.apiPort);
await new Promise(() => {});
} else if (opts.ws) {
const { startWsServer } = await import("@zesdex/ws");
startWsServer(opts.wsPort);
await new Promise(() => {});
} else if (opts.grpc) {
const { startGrpcServer } = await import("@zesdex/grpc");
startGrpcServer(opts.grpcPort);
await new Promise(() => {});
} else if (opts.web) {
const { startWebServer } = await import("@zesdex/web");
startWebServer(opts.webPort, process.env.ZESDEX_WEB_DIR ?? undefined);
await new Promise(() => {});
} else {
// Default: single-process REPL.
await runRepl();
}
}
main().catch((e) => {
console.error(e instanceof Error ? e.message : String(e));
process.exit(1);
});
+61
View File
@@ -0,0 +1,61 @@
/**
* Daemon client — connects to a running daemon over its Unix socket and
* exchanges framed IPC messages. Mirrors `apps/interfaces/daemon/src/client.rs`.
*/
import { IpcClient } from "@zesdex/infrastructure";
import type { DaemonFrame, ClientRequest } from "@zesdex/infrastructure";
/**
* Connect to a running daemon socket and send one or more requests,
* printing the frames received back. Returns when the daemon closes or
* after a timeout.
*/
export async function runAttach(
socketPath: string,
initialText?: string,
timeoutMs = 15_000,
): Promise<void> {
const client = await IpcClient.connectUnix(socketPath);
// Send a submit if provided, so the daemon starts a turn.
if (initialText) {
const req: ClientRequest = { kind: "Submit", text: initialText };
client.send(req);
}
// Read frames until closed or timeout.
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const frame = await client.receive<DaemonFrame>();
if (frame === null) break; // daemon closed connection
switch (frame.kind) {
case "StreamToken":
process.stdout.write(frame.token);
break;
case "StateUpdate":
process.stdout.write(`\n[state: ${frame.payload.message_count} msgs]\n`);
break;
case "Closed":
process.stdout.write("\n[closed]\n");
client.close();
return;
default:
break;
}
}
client.close();
}
/** Connect and send a Close request, for shutdown testing. */
export async function sendClose(socketPath: string): Promise<void> {
const client = await IpcClient.connectUnix(socketPath);
const req: ClientRequest = { kind: "Close" };
client.send(req);
// Give the daemon a moment to reply Closed.
const frame = await client.receive<DaemonFrame>();
if (frame && frame.kind === "Closed") {
process.stdout.write("[closed]\n");
}
client.close();
}
+7
View File
@@ -0,0 +1,7 @@
/**
* Zesdex daemon interface package.
* Mirrors `apps/interfaces/daemon/`.
*/
export { runDaemon } from "./server.ts";
export type { DaemonHandle } from "./server.ts";
export { runAttach, sendClose } from "./client.ts";
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bun
/**
* Zesdex daemon — standalone entry point.
* Mirrors the `--daemon` mode of the Rust gateway.
*/
import { runDaemon } from "./index.ts";
const handle = await runDaemon();
console.log("daemon running — Ctrl+C to stop");
// Keep alive until SIGINT/SIGTERM.
let stopping = false;
async function shutdown(): Promise<void> {
if (stopping) return;
stopping = true;
await handle.stop();
process.exit(0);
}
process.on("SIGINT", () => void shutdown());
process.on("SIGTERM", () => void shutdown());
+249
View File
@@ -0,0 +1,249 @@
/**
* Daemon server — owns the agent state, listens on a per-session Unix socket,
* and drives requests from an attached client.
* Mirrors `apps/interfaces/daemon/src/server.rs` (simplified: focuses on the
* IPC agent-driver loop; full TUI key-handling lives in the Fase 4 TUI).
*
* Flow: `runDaemon()` creates a session → binds a Unix socket under
* `<store>/run/<session_id>.sock` → accepts a client → loops reading
* `ClientRequest`s. On `Submit` it runs an agent turn and streams
* `StreamToken`/`StateUpdate` frames back. On `Close`/disconnect it cleans
* up the socket file.
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { randomUUID } from "node:crypto";
import { IpcServer } from "@zesdex/infrastructure";
import {
JsonSettingsRepository,
JsonAppConfigRepository,
LlmClient,
resolveApiKey,
InfrastructureToolExecutor,
allTools,
toolDefs,
} from "@zesdex/infrastructure";
import type { ToolCtx } from "@zesdex/infrastructure";
import { AgentTurnServiceImpl } from "@zesdex/application";
import {
newStore,
ensureStoreDirs,
newSessionId,
resolveEffectiveModel,
type TurnEvent,
type TurnEventSink,
type AgentTurnParams,
userMessage,
} from "@zesdex/domain";
import type { ClientRequest, DaemonFrame, MessageEntry, StatePayload } from "@zesdex/infrastructure";
/** A running daemon handle. */
export interface DaemonHandle {
stop(): Promise<void>;
}
/** Array-backed turn event sink (push + drain). */
export class ArraySink implements TurnEventSink {
constructor(public events: TurnEvent[] = []) {}
push(event: TurnEvent): void {
this.events.push(event);
}
drain(): TurnEvent[] {
const out = this.events;
this.events = [];
return out;
}
}
/** Wire a fresh agent runtime (mirrors the CLI composition root). */
async function buildRuntime() {
const store = newStore();
await ensureStoreDirs(store);
const settingsRepo = new JsonSettingsRepository();
const appConfigRepo = new JsonAppConfigRepository();
const settings = await settingsRepo.load(store.base_dir);
const appConfig = await appConfigRepo.load(store.base_dir);
const provider = settings.provider;
const model = resolveEffectiveModel(settings, appConfig);
const baseUrl = appConfig.providers[provider]?.api_base ?? undefined;
const apiKey = resolveApiKey(settings, appConfig);
const llmClient = new LlmClient(apiKey, model, baseUrl);
const toolCtx = {
sessionDir: store.base_dir,
workspaces: [process.cwd()],
turnEvents: new ArraySink(),
workflowFindings: [],
} as unknown as ToolCtx;
const executor = new InfrastructureToolExecutor(toolCtx);
const turnService = new AgentTurnServiceImpl(llmClient, executor, toolDefs(allTools()) as never);
const buildParams = (message: string, sink: TurnEventSink): AgentTurnParams => ({
messages: [userMessage(message)],
session_dir: store.base_dir,
workspace_roots: [process.cwd()],
turn_events: sink,
in_flight: { value: false },
abort: new AbortController(),
api_key: apiKey,
model,
api_base: baseUrl,
});
return { store, turnService, buildParams };
}
/** Build a StatePayload snapshot from the accumulated messages. */
function snapshot(sessionId: string, messages: MessageEntry[], dirty: boolean): StatePayload {
return {
session_id: sessionId,
messages,
edit_count: 0,
message_count: messages.length,
overlay: null,
toasts: [],
dirty,
input_buffer: "",
input_cursor: 0,
};
}
/** Handle one attached client: process its requests, driving agent turns. */
async function handleDaemonClient(
conn: { send<T>(m: T): void; receiveJson<T>(): Promise<T | null>; close(): void },
sessionId: string,
): Promise<void> {
const runtime = await buildRuntime();
const messages: MessageEntry[] = [];
const pushState = (dirty: boolean) =>
conn.send<DaemonFrame>({
kind: "StateUpdate",
payload: snapshot(sessionId, messages, dirty),
});
pushState(false);
let running = true;
while (running) {
let req: ClientRequest | null = null;
try {
req = await conn.receiveJson<ClientRequest>();
} catch {
break;
}
if (req === null) break; // client closed
switch (req.kind) {
case "Submit": {
const text = req.text;
if (text.trim() === "") break;
messages.push({ role: "user", content: text, timestamp: Date.now() });
pushState(true);
// Run the turn and stream tokens as they arrive.
const sink = new ArraySink();
const params = runtime.buildParams(text, sink);
void runTurnAndStream(conn, sink, runtime.turnService, params).then(() => {
messages.push({
role: "assistant",
content: sink.events
.filter((e) => e.kind === "stream_token")
.map((e) => (e as { content: string }).content)
.join(""),
timestamp: Date.now(),
});
pushState(false);
});
break;
}
case "Close":
running = false;
conn.send<DaemonFrame>({ kind: "Closed" });
break;
default:
// KeyPress/Tick/etc. no-op in this simplified daemon.
break;
}
}
conn.close();
}
/** Run a turn, streaming token frames to the client as they are produced. */
async function runTurnAndStream(
conn: { send<T>(m: T): void },
sink: ArraySink,
turnService: AgentTurnServiceImpl,
params: AgentTurnParams,
): Promise<void> {
const poll = setInterval(() => {
for (const ev of sink.drain()) {
if (ev.kind === "stream_token") {
conn.send<DaemonFrame>({ kind: "StreamToken", token: (ev as { content: string }).content });
}
}
}, 50);
try {
await turnService.runTurn(params);
} finally {
clearInterval(poll);
// Drain any remaining tokens after the turn completes.
for (const ev of sink.drain()) {
if (ev.kind === "stream_token") {
conn.send<DaemonFrame>({ kind: "StreamToken", token: (ev as { content: string }).content });
}
}
}
}
/** Run zesdex as a background daemon owning agent state over a Unix socket. */
export async function runDaemon(): Promise<DaemonHandle> {
const store = newStore();
await ensureStoreDirs(store);
const idRes = newSessionId(randomUUID());
const sessionId = idRes.ok ? idRes.value : `sess-${Date.now()}`;
fs.mkdirSync(path.join(store.base_dir, "sessions", sessionId), { recursive: true });
const runDir = path.join(store.base_dir, "run");
fs.mkdirSync(runDir, { recursive: true });
const socketPath = path.join(runDir, `${sessionId}.sock`);
const server = await IpcServer.bindUnix(socketPath);
console.log(`zesdex-daemon listening on ${socketPath}`);
// Accept clients serially.
void (async () => {
for (;;) {
try {
const conn = await server.accept();
console.log("daemon client connected");
try {
await handleDaemonClient(
{ send: (m) => conn.send(m), receiveJson: () => conn.receiveJson(), close: () => conn.close() },
sessionId,
);
} catch (e) {
console.error("daemon client error:", e);
}
console.log("daemon client disconnected");
} catch {
break; // server closed
}
}
})();
return {
async stop() {
await server.close();
try {
fs.unlinkSync(socketPath);
} catch {
/* ignore */
}
},
};
}
+6
View File
@@ -0,0 +1,6 @@
/**
* Zesdex gRPC interface package.
* Mirrors `apps/interfaces/grpc/`.
*/
export { startGrpcServer, handleGrpcRequest } from "./server.ts";
export type { GrpcState } from "./server.ts";
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bun
/**
* Zesdex gRPC server — standalone entry point (HTTP health stub).
* Mirrors the `--grpc` mode of the Rust gateway.
*/
import { startGrpcServer } from "./index.ts";
const port = Number.parseInt(process.env.ZESDEX_GRPC_PORT ?? "50051", 10);
startGrpcServer(port);
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bun
/**
* Zesdex gRPC interface — high-performance RPC with protobuf.
* Mirrors `apps/interfaces/grpc/src/lib.rs`.
*
* For now this crate provides a minimal HTTP health-check endpoint so
* consumers can verify the gRPC server is reachable. Full gRPC would use
* tonic+prost (Rust) — in Bun/TS a real gRPC server would use `@grpc/grpc-js`
* + generated protobuf types. This is intentionally a health stub.
*/
/** gRPC server state (minimal for health checks). */
export interface GrpcState {
version: string;
}
/** Build the gRPC health router. */
export function handleGrpcRequest(_state: GrpcState, req: Request): Response {
const url = new URL(req.url);
if (url.pathname === "/grpc/health") {
return new Response("gRPC server is running", {
status: 200,
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
return new Response("Not found", { status: 404 });
}
/** Start the gRPC server (HTTP health stub). */
export function startGrpcServer(port: number): { stop: () => void } {
const state: GrpcState = { version: "1.21.2" };
const server = Bun.serve({
port,
fetch(req) {
return handleGrpcRequest(state, req);
},
});
console.log(`zesdex-grpc listening on http://0.0.0.0:${server.port}`);
console.log("Note: gRPC currently runs HTTP health endpoint. Add @grpc/grpc-js for full gRPC.");
return { stop: () => server.stop(true) };
}
+200
View File
@@ -0,0 +1,200 @@
/**
* The `Action` — a single well-typed event in the TUI, produced by key input
* and applied to `AppStateRest` by the event loop. Mirrors
* `apps/interfaces/tui/src/action/mod.rs`.
*
* Flow: `handleKey` returns `Action[]` → the event loop calls `applyAction`
* for each → state is mutated in place. Every state mutation funnels through
* this single chokepoint.
*/
import * as fs from "node:fs";
import { Roles } from "@zesdex/domain";
import type { AppStateRest, Overlay } from "./state.ts";
import {
makeChatMessage,
pushTranscript,
toastInfo,
markDirty,
EditorState,
} from "./state.ts";
import { gitDiffOutput } from "./git.ts";
/** A single well-typed event in the TUI that mutates `AppStateRest`. */
export type Action =
| { tag: "ForceQuit" }
| { tag: "SubmitInput"; text: string }
| { tag: "DeleteChar" }
| { tag: "DeleteCharRight" }
| { tag: "CursorLeft" }
| { tag: "CursorRight" }
| { tag: "HistoryUp" }
| { tag: "HistoryDown" }
| { tag: "ScrollUp" }
| { tag: "ScrollDown" }
| { tag: "OpenOverlay"; overlay: Overlay }
| { tag: "CloseOverlay" }
| { tag: "SystemNote"; kind: string; message: string }
| { tag: "QuitConfirm" }
| { tag: "Resize"; width: number; height: number }
| { tag: "Tick" }
| { tag: "LessonAccept"; name: string }
| { tag: "LessonReject"; name: string }
| { tag: "LessonDelete"; name: string }
| { tag: "StartOAuth"; provider: string }
| { tag: "OpenEditor"; path: string }
| { tag: "McpAdd"; name: string; command: string }
| { tag: "ModelList" }
| { tag: "AbortTurn" }
| { tag: "Compact" }
| { tag: "ShowDiff" }
| { tag: "DiffScroll"; amount: number };
/** Apply an `Action` to `AppStateRest`. `state` is mutated in place. */
export function applyAction(state: AppStateRest, action: Action): void {
switch (action.tag) {
case "ForceQuit":
state.quit = true;
break;
case "QuitConfirm":
state.misc.overlay = "quit_confirm";
state.dirty = true;
break;
case "Resize":
if (state.lastRenderWidth !== action.width) {
state.transcriptCache.dirty = true;
state.lastRenderWidth = action.width;
}
state.dirty = true;
break;
case "Tick":
handleTick(state);
break;
case "SubmitInput":
pushTranscript(state, makeChatMessage(Roles.User, action.text));
state.input.submit();
state.pendingSubmit = action.text;
state.tokenCountDirty = true;
state.dirty = true;
break;
case "DeleteChar":
state.input.deleteLeft();
state.dirty = true;
break;
case "DeleteCharRight":
state.input.deleteRight();
state.dirty = true;
break;
case "CursorLeft":
state.input.cursor = Math.max(0, state.input.cursor - 1);
state.dirty = true;
break;
case "CursorRight":
if (state.input.cursor < state.input.buffer.length) state.input.cursor += 1;
state.dirty = true;
break;
case "HistoryUp":
state.input.historyUp();
state.dirty = true;
break;
case "HistoryDown":
state.input.historyDown();
state.dirty = true;
break;
case "ScrollUp":
state.scroll.scrollUp(3);
state.dirty = true;
break;
case "ScrollDown":
state.scroll.scrollDown(3);
state.dirty = true;
break;
case "OpenOverlay":
state.misc.overlay = action.overlay;
state.dirty = true;
break;
case "CloseOverlay":
state.misc.overlay = "none";
state.dirty = true;
break;
case "SystemNote":
pushTranscript(state, makeChatMessage(Roles.System, action.message));
break;
case "ModelList":
state.misc.overlay = "model_selector";
state.dirty = true;
break;
case "OpenEditor":
{
const content = fs.existsSync(action.path)
? fs.readFileSync(action.path, "utf8")
: "";
state.misc.editor = new EditorState(action.path, content);
state.misc.overlay = "editor";
state.dirty = true;
}
break;
case "LessonAccept":
toastInfo(state, `Lesson accepted: ${action.name}`);
break;
case "LessonReject":
toastInfo(state, `Lesson rejected: ${action.name}`);
break;
case "LessonDelete":
toastInfo(state, `Lesson deleted: ${action.name}`);
break;
case "StartOAuth":
toastInfo(state, `OAuth login started for ${action.provider}`);
break;
case "McpAdd":
toastInfo(state, `MCP server added: ${action.name} (${action.command})`);
break;
case "AbortTurn":
state.abortFlag.value = true;
toastInfo(state, "Aborting current turn...");
break;
case "Compact":
toastInfo(state, "Compacting conversation...");
break;
case "ShowDiff":
{
state.misc.diffContent = gitDiffOutput(state.workspaceRoots);
state.misc.diffScroll = 0;
state.misc.overlay = "diff";
state.dirty = true;
}
break;
case "DiffScroll":
{
const maxScroll = Math.max(0, state.misc.diffContent.split("\n").length - 1);
const newScroll = Math.max(0, state.misc.diffScroll + action.amount);
state.misc.diffScroll = Math.min(newScroll, maxScroll);
state.dirty = true;
}
break;
}
}
/** Periodic timer tick — drains queued events and applies streaming text. */
function handleTick(state: AppStateRest): void {
// Streaming updates are applied via appendToLastTranscript by the turn
// driver; here we only reconcile cache validity and progress the spinner.
state.misc.tickCount += 1;
markDirty(state);
}
+113
View File
@@ -0,0 +1,113 @@
/**
* Slash-command parser that maps TUI `/foo` input lines into `Command`
* variants for the action dispatch system. Mirrors
* `apps/interfaces/tui/src/controller/command.rs`.
*/
import type { Action } from "./action.ts";
/** A parsed slash command from the TUI input buffer. */
export type Command =
| { tag: "Help" }
| { tag: "Quit" }
| { tag: "McpOpen" }
| { tag: "Clear" }
| { tag: "ClearConfirm" }
| { tag: "Login"; provider: string }
| { tag: "Edit"; path: string }
| { tag: "McpAdd"; name: string; command: string }
| { tag: "ModelList" }
| { tag: "Compact" }
| { tag: "TodoOpen" }
| { tag: "PlanOpen" }
| { tag: "UsageOpen" }
| { tag: "Diff" }
| { tag: "Unknown"; text: string };
/** Parse a slash-prefixed input line into a `Command` value. */
export function parseCommand(text: string): Command {
const trimmed = text.trim();
if (!trimmed.startsWith("/")) return { tag: "Unknown", text: trimmed };
const parts = trimmed.split(" ", 3);
const cmd = parts[0] ?? "";
const arg1 = parts[1] ?? "";
const arg2 = parts[2] ?? "";
switch (cmd) {
case "/help":
return { tag: "Help" };
case "/quit":
return { tag: "Quit" };
case "/clear":
return arg1 === "" ? { tag: "ClearConfirm" } : { tag: "Clear" };
case "/login":
return arg1 === "" ? { tag: "Login", provider: "" } : { tag: "Login", provider: arg1 };
case "/edit":
return { tag: "Edit", path: arg1 === "" ? "." : arg1 };
case "/mcp":
if (arg1 === "") return { tag: "McpOpen" };
if (arg1 === "add") {
const rest = arg2.trim();
const space = rest.indexOf(" ");
if (space !== -1) {
return { tag: "McpAdd", name: rest.slice(0, space), command: rest.slice(space + 1).trim() };
}
return { tag: "McpAdd", name: rest, command: "" };
}
return { tag: "Unknown", text: cmd };
case "/model":
return { tag: "ModelList" };
case "/compact":
return { tag: "Compact" };
case "/todo":
return { tag: "TodoOpen" };
case "/plan":
return { tag: "PlanOpen" };
case "/usage":
return { tag: "UsageOpen" };
case "/diff":
return { tag: "Diff" };
default:
return { tag: "Unknown", text: cmd };
}
}
/** Map a parsed `Command` into `Action` values for the event loop. */
export function applyCommand(cmd: Command): Action[] {
switch (cmd.tag) {
case "Help":
return [{ tag: "OpenOverlay", overlay: "help" }];
case "Quit":
return [{ tag: "QuitConfirm" }];
case "McpOpen":
return [{ tag: "OpenOverlay", overlay: "mcp" }];
case "Clear":
return [{ tag: "SystemNote", kind: "clear", message: "Transcript cleared." }];
case "ClearConfirm":
return [{ tag: "OpenOverlay", overlay: "clear_confirm" }];
case "Login":
return [{ tag: "StartOAuth", provider: cmd.provider }];
case "Edit":
return [{ tag: "OpenEditor", path: cmd.path }];
case "McpAdd":
return [{ tag: "McpAdd", name: cmd.name, command: cmd.command }];
case "ModelList":
return [{ tag: "ModelList" }];
case "Compact":
return [{ tag: "Compact" }];
case "TodoOpen":
return [{ tag: "OpenOverlay", overlay: "todo" }];
case "PlanOpen":
return [{ tag: "OpenOverlay", overlay: "plan" }];
case "UsageOpen":
return [{ tag: "OpenOverlay", overlay: "usage" }];
case "Diff":
return [{ tag: "ShowDiff" }];
case "Unknown":
if (cmd.text.startsWith("/")) {
return [{ tag: "SystemNote", kind: "error", message: `Unknown command: ${cmd.text}` }];
}
return [{ tag: "SubmitInput", text: cmd.text }];
}
}
+330
View File
@@ -0,0 +1,330 @@
/**
* Key event dispatcher: maps raw key events into `Action` variants, with
* special handling for overlays, autocomplete, and the inline editor.
* Mirrors `apps/interfaces/tui/src/controller/input.rs`.
*
* Flow: `handleKey` is called on each key press → returns `Action[]`.
*/
import { Roles } from "@zesdex/domain";
import type { AppStateRest } from "./state.ts";
import { AutocompleteKind, markDirty, toastInfo, overlayIsActive } from "./state.ts";
import type { Action } from "./action.ts";
import { applyCommand, parseCommand } from "./command.ts";
/** A decoded terminal key event (normalised from raw input). */
export interface KeyEvent {
code: string;
ctrl: boolean;
alt: boolean;
shift?: boolean;
/** For plain char events, the decoded character(s). */
value?: string;
}
/** Common key codes we normalise from raw input. */
export const KeyCode = {
Enter: "Enter",
Backspace: "Backspace",
Delete: "Delete",
Left: "ArrowLeft",
Right: "ArrowRight",
Up: "ArrowUp",
Down: "ArrowDown",
PageUp: "PageUp",
PageDown: "PageDown",
Esc: "Escape",
Tab: "Tab",
Char: "Char",
} as const;
/** Normalise a raw string key (from raw-mode stdin) into a `KeyEvent`. */
export function decodeKey(raw: string): KeyEvent | null {
if (raw === "\r" || raw === "\n") return { code: KeyCode.Enter, ctrl: false, alt: false };
if (raw === "\u001b") return { code: KeyCode.Esc, ctrl: false, alt: false };
if (raw === "\t") return { code: KeyCode.Tab, ctrl: false, alt: false };
if (raw === "\u007f" || raw === "\b") return { code: KeyCode.Backspace, ctrl: false, alt: false };
// Ctrl+letter (0x01–0x1a)
const ch = raw.charCodeAt(0);
if (ch >= 0x01 && ch <= 0x1a && raw.length === 1) {
const letter = String.fromCharCode(ch + 0x60);
return { code: KeyCode.Char, value: letter, ctrl: true, alt: false };
}
// ESC-prefixed sequences: arrows, page keys, alt+char.
if (raw.startsWith("\u001b[")) {
switch (raw) {
case "\u001b[A":
return { code: KeyCode.Up, ctrl: false, alt: false };
case "\u001b[B":
return { code: KeyCode.Down, ctrl: false, alt: false };
case "\u001b[C":
return { code: KeyCode.Right, ctrl: false, alt: false };
case "\u001b[D":
return { code: KeyCode.Left, ctrl: false, alt: false };
case "\u001b[5~":
return { code: KeyCode.PageUp, ctrl: false, alt: false };
case "\u001b[6~":
return { code: KeyCode.PageDown, ctrl: false, alt: false };
case "\u001b[3~":
return { code: KeyCode.Delete, ctrl: false, alt: false };
case "\u001b[1;5A":
return { code: KeyCode.Up, ctrl: true, alt: false };
case "\u001b[1;5B":
return { code: KeyCode.Down, ctrl: true, alt: false };
default:
return null;
}
}
if (raw.startsWith("\u001b") && raw.length > 1) {
// Alt+<char>
const inner = raw.slice(1);
if (inner.length === 1) {
return { code: KeyCode.Char, value: inner, ctrl: false, alt: true };
}
return null;
}
// Plain printable char(s)
return { code: KeyCode.Char, value: raw, ctrl: false, alt: false };
}
/** Mark state dirty and return an empty action list. */
function mark(state: AppStateRest): Action[] {
markDirty(state);
return [];
}
/**
* Translate a decoded `KeyEvent` into zero or more `Action` values based on
* the current application state.
*/
export function handleKey(key: KeyEvent, state: AppStateRest): Action[] {
const code = key.code;
// ── Editor overlay ───────────────────────────────────────────────────
if (state.misc.overlay === "editor") {
if (code === KeyCode.Char && key.ctrl && (key.value === "c" || key.value === "s")) {
return [{ tag: "QuitConfirm" }];
}
if (code === KeyCode.Esc) {
state.misc.editor = null;
state.misc.overlay = "none";
return [];
}
if (code === KeyCode.Backspace) {
if (state.misc.editor) {
state.misc.editor.deleteLeft();
mark(state);
}
return [];
}
if (code === KeyCode.Enter) {
if (state.misc.editor) {
state.misc.editor.content =
state.misc.editor.content.slice(0, state.misc.editor.cursor) +
"\n" +
state.misc.editor.content.slice(state.misc.editor.cursor);
state.misc.editor.cursor += 1;
mark(state);
}
return [];
}
if (code === KeyCode.Char && key.value) {
if (state.misc.editor) {
state.misc.editor.content =
state.misc.editor.content.slice(0, state.misc.editor.cursor) +
key.value +
state.misc.editor.content.slice(state.misc.editor.cursor);
state.misc.editor.cursor += key.value.length;
mark(state);
}
return [];
}
return [];
}
// ── Diff overlay ─────────────────────────────────────────────────────
if (state.misc.overlay === "diff") {
if (code === KeyCode.Char && key.ctrl && key.value === "c") return [{ tag: "QuitConfirm" }];
if (code === KeyCode.Up || (code === KeyCode.Char && key.value === "k")) {
return [{ tag: "DiffScroll", amount: -1 }];
}
if (code === KeyCode.Down || (code === KeyCode.Char && key.value === "j")) {
return [{ tag: "DiffScroll", amount: 1 }];
}
if (code === KeyCode.PageUp) return [{ tag: "DiffScroll", amount: -20 }];
if (code === KeyCode.PageDown) return [{ tag: "DiffScroll", amount: 20 }];
if (code === KeyCode.Esc) return [{ tag: "CloseOverlay" }];
return [];
}
// ── Learning overlay ─────────────────────────────────────────────────
if (state.misc.overlay === "learning") {
if (code === KeyCode.Char && key.ctrl && key.value === "c") return [{ tag: "QuitConfirm" }];
if (code === KeyCode.Esc) return [{ tag: "CloseOverlay" }];
if (code === KeyCode.Up) {
state.misc.selectedIndex = cycleSelectedIndex(state.misc.selectedIndex, 10, false);
return mark(state);
}
if (code === KeyCode.Down) {
state.misc.selectedIndex = cycleSelectedIndex(state.misc.selectedIndex, 10, true);
return mark(state);
}
if (code === KeyCode.Enter || (code === KeyCode.Char && key.value === "a")) {
return [{ tag: "LessonAccept", name: `lesson-${state.misc.selectedIndex}` }];
}
if (code === KeyCode.Char && key.value === "r") {
return [{ tag: "LessonReject", name: `lesson-${state.misc.selectedIndex}` }];
}
if (code === KeyCode.Char && key.value === "d") {
return [{ tag: "LessonDelete", name: `lesson-${state.misc.selectedIndex}` }];
}
return [];
}
// ── Normal (non-overlay) dispatch ────────────────────────────────────
switch (code) {
case KeyCode.Char:
if (key.ctrl && key.value === "c") return [{ tag: "QuitConfirm" }];
if (key.ctrl && key.value === "d") return [{ tag: "CloseOverlay" }];
if (key.ctrl && key.value === "y") {
const lastAssistant = [...state.transcriptCache.messages]
.reverse()
.find((m) => m.role === Roles.Assistant);
if (lastAssistant) {
state.misc.pendingClipboardCopy = lastAssistant.content;
} else {
toastInfo(state, "No assistant message to copy yet");
}
return [];
}
if (key.alt) return [{ tag: "ShowDiff" }];
// Regular char insertion
if (state.input.autocompleteVisible) {
state.input.closeAutocomplete();
mark(state);
}
if (key.value) state.input.insert(key.value);
mark(state);
if (state.input.buffer.startsWith("/")) {
state.input.openAutocomplete();
} else if (state.input.mentionQueryAtCursor()) {
state.input.openMentionAutocomplete(state.mentionFiles);
}
return [];
case KeyCode.Enter:
if (state.input.autocompleteVisible) {
state.input.selectAutocomplete();
return mark(state);
}
if (overlayIsActive(state.misc.overlay)) {
return []; // handle_overlay_enter (simplified: no-op)
}
{
const text = state.input.buffer;
if (text.startsWith("/")) {
return applyCommand(parseCommand(text));
}
return [{ tag: "SubmitInput", text }];
}
case KeyCode.Backspace:
if (state.input.autocompleteVisible) {
state.input.closeAutocomplete();
return mark(state);
}
return [{ tag: "DeleteChar" }];
case KeyCode.Delete:
if (state.input.autocompleteVisible) {
state.input.closeAutocomplete();
return mark(state);
}
return [{ tag: "DeleteCharRight" }];
case KeyCode.Left:
return [{ tag: "CursorLeft" }];
case KeyCode.Right:
return [{ tag: "CursorRight" }];
case KeyCode.Up:
if (state.input.autocompleteVisible) {
state.input.cycleAutocomplete(false);
mark(state);
return [];
} else if (state.misc.overlay === "model_selector") {
state.misc.selectedIndex = cycleSelectedIndex(
state.misc.selectedIndex,
Object.keys(state.appConfig.providers).length,
false,
);
mark(state);
return [];
} else if (key.ctrl) {
return [{ tag: "ScrollUp" }];
} else {
return [{ tag: "HistoryUp" }];
}
case KeyCode.Down:
if (state.input.autocompleteVisible) {
state.input.cycleAutocomplete(true);
mark(state);
return [];
} else if (state.misc.overlay === "model_selector") {
state.misc.selectedIndex = cycleSelectedIndex(
state.misc.selectedIndex,
Object.keys(state.appConfig.providers).length,
true,
);
mark(state);
return [];
} else if (key.ctrl) {
return [{ tag: "ScrollDown" }];
} else {
return [{ tag: "HistoryDown" }];
}
case KeyCode.PageUp:
return [{ tag: "ScrollUp" }];
case KeyCode.PageDown:
return [{ tag: "ScrollDown" }];
case KeyCode.Esc:
if (turnInFlightLocal(state)) return [{ tag: "AbortTurn" }];
else if (state.input.autocompleteVisible) {
state.input.closeAutocomplete();
mark(state);
return [];
} else if (overlayIsActive(state.misc.overlay)) {
return [{ tag: "CloseOverlay" }];
}
return [];
case KeyCode.Tab:
if (state.input.buffer.startsWith("/")) {
if (state.input.autocompleteVisible) {
state.input.cycleAutocomplete(true);
} else {
state.input.tabComplete();
}
mark(state);
} else if (
state.input.autocompleteKind === AutocompleteKind.FileMention &&
state.input.autocompleteVisible
) {
state.input.cycleAutocomplete(true);
mark(state);
}
return [];
default:
return [];
}
}
/** Local helper: whether an agent turn is in flight. */
function turnInFlightLocal(state: AppStateRest): boolean {
return state.turnInFlightFlag.value;
}
/** Cycle a selected index within `n` items (forward or backward). */
export function cycleSelectedIndex(idx: number, n: number, forward: boolean): number {
if (n <= 0) return 0;
if (forward) {
return (idx + 1) % n;
}
return idx === 0 ? n - 1 : idx - 1;
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Git diff preview helper — shells out to `git diff` over the workspace
* roots and returns the combined output text. Mirrors
* `apps/interfaces/tui/src/action/git.rs`.
*/
import { execSync } from "node:child_process";
/** Run `git diff` across the given workspace roots and return the output. */
export function gitDiffOutput(workspaceRoots: string[]): string {
for (const root of workspaceRoots) {
try {
const out = execSync("git diff --stat && git diff", {
cwd: root,
encoding: "utf8",
maxBuffer: 4 * 1024 * 1024,
stdio: ["ignore", "pipe", "ignore"],
});
if (out.trim().length > 0) {
return out;
}
} catch {
// Not a git repo or git not available — continue to the next root.
}
}
return "No diff available (not a git repository).";
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Zesdex TUI interface — logic-pure interactive layer + lightweight renderer.
* Mirrors `apps/interfaces/tui/`.
*/
export type { Action } from "./action.ts";
export { applyAction } from "./action.ts";
export type { Command } from "./command.ts";
export { parseCommand, applyCommand } from "./command.ts";
export type { KeyEvent } from "./controller.ts";
export { handleKey, decodeKey, cycleSelectedIndex } from "./controller.ts";
export {
createTuiState,
AutocompleteKind,
Overlay,
COMMANDS,
DEFAULT_HELP_TEXT,
TranscriptCache,
InputState,
EditorState,
ScrollState,
MiscState,
pushTranscript,
appendToLastTranscript,
makeChatMessage,
toastInfo,
toastError,
turnInFlight,
} from "./state.ts";
export type { AppStateRest, ChatMessageDisplay } from "./state.ts";
export { renderFrame } from "./render.ts";
export { gitDiffOutput } from "./git.ts";
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bun
/**
* Zesdex TUI — standalone entry point.
* Mirrors the default (TUI) mode of the Rust gateway.
*/
import { runTui } from "./run.ts";
void runTui();
+116
View File
@@ -0,0 +1,116 @@
/**
* Lightweight line-based terminal renderer for the TUI. Renders the
* `AppStateRest` transcript, input line, overlay, and status into an array of
* plain-text + ANSI escaped lines. No external ncurses dependency — outputs
* raw ANSI sequences that work in any terminal. Conceptually mirrors
* `apps/interfaces/tui/src/view/**` but simplified to line output.
*/
import { Roles } from "@zesdex/domain";
import type { AppStateRest } from "./state.ts";
import { Overlay } from "./state.ts";
const roleLabel: Record<string, string> = {
[Roles.User]: "you",
[Roles.Assistant]: "zesdex",
[Roles.System]: "system",
[Roles.Tool]: "tool",
};
/** Render the full TUI frame into an array of lines (with ANSI colours). */
export function renderFrame(state: AppStateRest, height: number, width: number): string[] {
const lines: string[] = [];
// Header bar.
lines.push(renderHeader(state, width));
// Transcript pane.
const transcriptLines = renderTranscript(state, width);
lines.push(...transcriptLines);
// Padding to fill remaining height.
const used = lines.length;
for (let i = used; i < height - 3; i++) {
lines.push("");
}
// Overlay content (if any) replaces the input area.
if (state.misc.overlay !== Overlay.None) {
lines.push(renderOverlay(state, width));
} else {
lines.push(renderInput(state, width));
}
// Status/footer bar.
lines.push(renderStatus(state, width));
return lines.slice(0, height);
}
function renderHeader(_state: AppStateRest, width: number): string {
const title = " zesdex ";
const pad = Math.max(0, width - title.length);
return `\x1b[1;44m${title}\x1b[0m${" ".repeat(pad)}`;
}
function renderTranscript(state: AppStateRest, width: number): string[] {
const out: string[] = [];
const messages = state.transcriptCache.messages;
// Honour scroll offset from the bottom like a typical chat TUI.
const start = Math.max(0, messages.length - 30 - state.scroll.offset);
for (let i = start; i < messages.length; i++) {
const m = messages[i];
if (!m) continue;
const label = (roleLabel[m.role] ?? m.role).padEnd(8);
const color = m.role === Roles.User ? "\x1b[36m" : m.role === Roles.System ? "\x1b[33m" : "\x1b[32m";
out.push(`${color}${label}\x1b[0m ${m.content}`);
if (m.reasoning) {
out.push(` \x1b[2m(reasoning) ${m.reasoning}\x1b[0m`);
}
}
void width;
return out;
}
function renderInput(state: AppStateRest, width: number): string {
const prompt = "> ";
const shown = state.input.buffer.length > width - prompt.length
? state.input.buffer.slice(state.input.buffer.length - (width - prompt.length))
: state.input.buffer;
let line = `\x1b[1m${prompt}\x1b[0m${shown}`;
// Append autocomplete hint if visible.
if (state.input.autocompleteVisible && state.input.autocompleteCandidates[state.input.autocompleteIdx]) {
const cand = state.input.autocompleteCandidates[state.input.autocompleteIdx];
line += ` \x1b[2m[tab: ${cand}]\x1b[0m`;
}
// Toasts.
for (const t of state.misc.toasts.slice(-2)) {
line += ` \x1b[3m(${t.message})\x1b[0m`;
}
return line.length > width ? line.slice(0, width) : line;
}
function renderOverlay(state: AppStateRest, width: number): string {
const name = state.misc.overlay;
if (name === "diff") {
const diffLines = state.misc.diffContent.split("\n");
const from = state.misc.diffScroll;
return diffLines.slice(from, from + 10).join("\n ");
}
if (name === "quit_confirm") {
return " \x1b[1;31mQuit?\x1b[0m (y=yes) \x1b[2mCtrl+C again / /quit to confirm\x1b[0m";
}
if (name === "help") {
return state.helpText;
}
void width;
return ` \x1b[1mOverlay: ${name}\x1b[0m \x1b[2m(Esc to close)\x1b[0m`;
}
function renderStatus(state: AppStateRest, width: number): string {
const left = ` model=${(state.settings as { provider?: string }).provider ?? "?"}`;
const right = state.turnInFlightFlag.value
? " \x1b[5m● running\x1b[0m"
: " \x1b[2m● idle\x1b[0m";
const combined = `${left}${right}`;
return combined.length > width ? combined.slice(0, width) : combined;
}
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env bun
/**
* Zesdex TUI — interactive event loop.
*
* Reads keys from the terminal in raw mode, decodes them into `KeyEvent`s,
* routes through `handleKey` → `applyAction`, and re-renders the frame.
* Streaming agent output is applied to the transcript via
* `appendToLastTranscript`.
*
* Mirrors the conceptual flow of `apps/interfaces/tui/src/run.rs` (event
* loop + turn drain) using a plain raw-mode stdin reader instead of
* crossterm/ratatui.
*/
import * as fs from "node:fs";
import { createTuiState, pushTranscript, appendToLastTranscript, makeChatMessage } from "./state.ts";
import { applyAction } from "./action.ts";
import type { Action } from "./action.ts";
import { decodeKey, handleKey } from "./controller.ts";
import type { KeyEvent } from "./controller.ts";
import { renderFrame } from "./render.ts";
import { Roles } from "@zesdex/domain";
function readRaw(): () => void {
const prev = { ...(process.stdout as unknown as { attrs?: unknown }) };
const stdin = process.stdin as unknown as { setRawMode: (b: boolean) => void };
if (typeof stdin.setRawMode === "function") stdin.setRawMode(true);
// Show cursor-off control sequences are handled at frame draw.
void prev;
return () => {
if (typeof stdin.setRawMode === "function") stdin.setRawMode(false);
process.stdout.write("\x1b[?25h\x1b[0m"); // show cursor + reset
};
}
/** Run the interactive TUI until the user quits. */
export async function runTui(): Promise<void> {
const cwd = process.cwd();
const dataDir = `${process.env.HOME ?? ""}/.local/share/zesdex`;
const sessionDir = `${dataDir}/sessions/${Date.now()}`;
fs.mkdirSync(sessionDir, { recursive: true });
const state = createTuiState([cwd], sessionDir, `${dataDir}/memory`);
const restore = readRaw();
// Seed some transcript so the pane isn't empty on first render.
pushTranscript(state, makeChatMessage(Roles.System, "Zesdex ready. Type a message or /help."));
const stdin = process.stdin as unknown as {
setRawMode: (b: boolean) => void;
on: (ev: string, cb: (chunk: Buffer) => void) => void;
resume: () => void;
};
stdin.resume();
let buffer = "";
stdin.on("data", (chunk: Buffer) => {
buffer += chunk.toString("utf8");
// Process complete escape sequences (each is self-contained).
while (buffer.length > 0) {
const ev = decodeKey(buffer);
if (!ev) {
// Might be a partial escape sequence; wait for more bytes.
break;
}
// consume the decoded sequence length
const consumed = consumedLen(buffer, ev);
buffer = buffer.slice(consumed);
dispatch(ev, state);
}
draw(state);
});
// Turn drain: poll a fake "in-flight" by checking pendingSubmit periodically.
const drainTimer = setInterval(() => {
if (state.pendingSubmit !== null) {
const text = state.pendingSubmit;
state.pendingSubmit = null;
state.turnInFlightFlag.value = true;
state.dirty = true;
// With no live LLM wiring in the pure TUI, simulate a short "thinking"
// then append a fixed assistant note. Real wiring happens in the CLI.
setTimeout(() => {
state.turnInFlightFlag.value = false;
appendToLastTranscript(state, `(no LLM configured — processed: "${text}")`, false);
state.dirty = true;
draw(state);
}, 50);
}
if (state.dirty) draw(state);
}, 100);
draw(state);
// Block until quit.
await new Promise<void>((resolve) => {
const check = setInterval(() => {
if (state.quit) {
clearInterval(check);
clearInterval(drainTimer);
restore();
resolve();
}
}, 50);
});
}
/** Determine how many characters of the input buffer a decoded event consumed. */
function consumedLen(buffer: string, _ev: KeyEvent): number {
// Escape sequences are multi-byte; a plain char is 1 codepoint.
if (buffer.startsWith("\u001b[")) {
// consume through the terminating letter (~ or A-D).
const m = buffer.match(/^\u001b\[([0-9;]*)([A-Z~])/);
return m ? m[0].length : buffer.length;
}
if (buffer.startsWith("\u001b")) {
return buffer.length; // alt sequence
}
return [...buffer][0]!.length;
}
/** Dispatch a decoded key via the controller and apply resulting actions. */
function dispatch(key: KeyEvent, state: ReturnType<typeof createTuiState>): void {
const actions = handleKey(key, state);
for (const action of actions) {
const act = action as Action;
if (act.tag === "QuitConfirm") {
// Go straight to quit on Ctrl+C (simplified confirm).
state.quit = true;
continue;
}
applyAction(state, act);
}
}
/** Draw the current frame. */
function draw(state: ReturnType<typeof createTuiState>): void {
if (!state.dirty) return;
state.dirty = false;
const h = process.stdout.rows ?? 24;
const w = process.stdout.columns ?? 80;
const lines = renderFrame(state, h, w);
process.stdout.write("\x1b[2J\x1b[H"); // clear + home
process.stdout.write(lines.join("\n"));
process.stdout.write("\x1b[?25h");
}
+511
View File
@@ -0,0 +1,511 @@
/**
* TUI state — the single source-of-truth struct for the TUI interface plus
* all the types it owns. Mirrors `apps/interfaces/tui/src/state/**`.
*
* Design: `AppStateRest` is mutated by `applyAction` and read by the renderer
* each frame. Everything here is logic-pure (no terminal I/O), so it is
* unit-testable in Bun.
*/
import { Roles, type Role } from "@zesdex/domain";
/* ── Input state ──────────────────────────────────────────────────── */
/** Which source populated the autocomplete dropdown. */
export const AutocompleteKind = {
Command: "command",
FileMention: "file_mention",
} as const;
export type AutocompleteKind = (typeof AutocompleteKind)[keyof typeof AutocompleteKind];
/** Builtin slash-commands recognised by the chat input autocomplete. */
export const COMMANDS: string[] = [
"/help",
"/quit",
"/clear",
"/login",
"/login zen",
"/login openai",
"/edit",
"/mcp add",
"/model",
"/model ls",
"/model add",
"/todo",
"/usage",
"/compact",
];
/** The user's input buffer, cursor, history, and autocomplete state. */
export class InputState {
buffer = "";
cursor = 0;
history: string[] = [];
historyIdx: number | null = null;
autocompleteCandidates: string[] = [];
autocompleteIdx = 0;
autocompleteVisible = false;
autocompleteKind: AutocompleteKind = AutocompleteKind.Command;
mentionStart = 0;
historyFile: string | null = null;
/** Hide the autocomplete dropdown and clear its state. */
closeAutocomplete(): void {
this.autocompleteVisible = false;
this.autocompleteCandidates = [];
this.autocompleteIdx = 0;
this.autocompleteKind = AutocompleteKind.Command;
this.mentionStart = 0;
}
/** Open or refresh the autocomplete dropdown by filtering COMMANDS. */
openAutocomplete(): void {
const trimmed = this.buffer.trim();
if (trimmed.length === 0 || !trimmed.startsWith("/")) {
this.closeAutocomplete();
return;
}
const prefix = trimmed.toLowerCase();
this.autocompleteCandidates = COMMANDS.filter((c) => c.startsWith(prefix));
this.autocompleteKind = AutocompleteKind.Command;
this.autocompleteIdx = 0;
this.autocompleteVisible = this.autocompleteCandidates.length > 0;
}
/** Find the `@mention` token (if any) immediately before the cursor. */
mentionQueryAtCursor(): [number, string] | null {
const beforeCursor = this.buffer.slice(0, this.cursor);
const atPos = beforeCursor.lastIndexOf("@");
if (atPos === -1) return null;
const between = beforeCursor.slice(atPos + 1);
if (/[\s]/.test(between)) return null;
const boundaryOk =
atPos === 0 || /[\s]/.test(beforeCursor[atPos - 1] ?? "") || /\s/.test(beforeCursor[atPos - 1] ?? "");
if (!boundaryOk) return null;
return [atPos, between];
}
/** Open or refresh the `@file` mention dropdown from `files`. */
openMentionAutocomplete(files: string[]): void {
const q = this.mentionQueryAtCursor();
if (!q) {
this.closeAutocomplete();
return;
}
const [, query] = q;
const ql = query.toLowerCase();
// Simple substring + prefix fuzzy match over the file list.
const matched = files
.filter((f) => {
const fl = f.toLowerCase();
return fl.includes(ql) || f.replace(/.*\//, "").toLowerCase().startsWith(ql);
})
.slice(0, 10);
this.autocompleteCandidates = matched;
this.autocompleteKind = AutocompleteKind.FileMention;
this.mentionStart = q[0];
this.autocompleteIdx = 0;
this.autocompleteVisible = matched.length > 0;
}
/** Move the autocomplete selection up (false) or down (true). */
cycleAutocomplete(forward: boolean): void {
const n = this.autocompleteCandidates.length;
if (n === 0) return;
if (forward) {
this.autocompleteIdx = (this.autocompleteIdx + 1) % n;
} else {
this.autocompleteIdx =
this.autocompleteIdx === 0 ? n - 1 : this.autocompleteIdx - 1;
}
}
/** Accept the currently selected autocomplete candidate. Returns true on success. */
selectAutocomplete(): boolean {
const candidate = this.autocompleteCandidates[this.autocompleteIdx];
if (candidate === undefined || candidate === null) return false;
if (this.autocompleteKind === AutocompleteKind.Command) {
this.buffer = candidate;
this.cursor = this.buffer.length;
} else {
if (this.cursor < this.mentionStart || this.mentionStart > this.buffer.length) {
this.closeAutocomplete();
return false;
}
const replacement = `@${candidate} `;
this.buffer =
this.buffer.slice(0, this.mentionStart) + replacement + this.buffer.slice(this.cursor);
this.cursor = this.mentionStart + replacement.length;
}
this.closeAutocomplete();
return true;
}
/** Tab-complete: open dropdown or cycle forward. */
tabComplete(): void {
if (this.autocompleteVisible) {
this.cycleAutocomplete(true);
} else {
this.openAutocomplete();
}
}
/** Insert a character at the cursor position. */
insert(c: string): void {
this.buffer = this.buffer.slice(0, this.cursor) + c + this.buffer.slice(this.cursor);
this.cursor += c.length;
}
/** Delete the character to the left of the cursor (backspace). */
deleteLeft(): void {
if (this.cursor > 0) {
this.cursor -= 1;
this.buffer = this.buffer.slice(0, this.cursor) + this.buffer.slice(this.cursor + 1);
}
}
/** Delete the character at the cursor position (forward delete). */
deleteRight(): void {
if (this.cursor < this.buffer.length) {
this.buffer = this.buffer.slice(0, this.cursor) + this.buffer.slice(this.cursor + 1);
}
}
/** Submit the current buffer and return the submitted text. */
submit(): string {
const result = this.buffer;
if (result.length > 0) {
if (this.history[this.history.length - 1] !== result) {
this.history.push(result);
}
this.historyIdx = null;
}
this.buffer = "";
this.cursor = 0;
return result;
}
/** Navigate backward through input history. */
historyUp(): void {
if (this.history.length === 0) return;
let idx: number;
if (this.historyIdx !== null && this.historyIdx > 0) {
idx = this.historyIdx - 1;
} else if (this.historyIdx === null) {
idx = this.history.length - 1;
} else {
return;
}
this.historyIdx = idx;
this.buffer = this.history[idx] ?? "";
this.cursor = this.buffer.length;
}
/** Navigate forward through input history. */
historyDown(): void {
if (this.historyIdx !== null && this.historyIdx < this.history.length - 1) {
const idx = this.historyIdx + 1;
this.historyIdx = idx;
this.buffer = this.history[idx] ?? "";
this.cursor = this.buffer.length;
} else if (this.historyIdx !== null) {
this.historyIdx = null;
this.buffer = "";
this.cursor = 0;
}
// else nothing to do
}
}
/* ── Overlay ──────────────────────────────────────────────────────── */
/** Which modal overlay, if any, is currently shown over the main TUI view. */
export const Overlay = {
None: "none",
Help: "help",
Settings: "settings",
Bash: "bash",
QuitConfirm: "quit_confirm",
KeyInput: "key_input",
Editor: "editor",
Effort: "effort",
Mcp: "mcp",
Todo: "todo",
Plan: "plan",
Rewind: "rewind",
Learning: "learning",
Usage: "usage",
Loading: "loading",
ModelSelector: "model_selector",
ClearConfirm: "clear_confirm",
Diff: "diff",
} as const;
export type Overlay = (typeof Overlay)[keyof typeof Overlay];
export function overlayIsActive(overlay: Overlay): boolean {
return overlay !== Overlay.None;
}
/* ── Editor state ─────────────────────────────────────────────────── */
/** Simple inline editor state for the TUI. */
export class EditorState {
constructor(
public path: string,
public content: string,
public cursor: number = content.length,
) {}
asString(): string {
return this.content;
}
deleteLeft(): void {
if (this.cursor > 0) {
this.cursor -= 1;
this.content = this.content.slice(0, this.cursor) + this.content.slice(this.cursor + 1);
}
}
}
/* ── Misc state ───────────────────────────────────────────────────── */
/** The "miscellaneous" slice of app state. */
export class MiscState {
overlay: Overlay = Overlay.None;
toasts: { kind: string; message: string; createdMs: number; ttlMs: number }[] = [];
lastStalenessSweepMs = 0;
thinking = false;
effortLevel = 1;
selectedIndex = 0;
editor: EditorState | null = null;
apiConnected = false;
tickCount = 0;
todoContent = "";
planContent = "";
lessonRunning = false;
pendingClipboardCopy: string | null = null;
diffContent = "";
diffScroll = 0;
pushToast(toast: { kind: string; message: string; createdMs: number; ttlMs: number }): void {
this.toasts.push(toast);
}
drainExpiredToasts(nowMs: number): void {
this.toasts = this.toasts.filter((t) => !(nowMs - t.createdMs >= t.ttlMs));
}
}
/* ── Scroll state ─────────────────────────────────────────────────── */
/** Viewport scroll state: current offset and visible-line count. */
export class ScrollState {
offset = 0;
maxVisible = 30;
scrollUp(amount: number): void {
this.offset += amount;
}
scrollDown(amount: number): void {
this.offset = Math.max(0, this.offset - amount);
}
}
/* ── Transcript ───────────────────────────────────────────────────── */
/** A single transcript entry rendered in the TUI chat pane. */
export interface ChatMessageDisplay {
role: Role;
content: string;
reasoning: string;
timestamp: number;
}
export function makeChatMessage(role: Role, content: string): ChatMessageDisplay {
return { role, content, reasoning: "", timestamp: Date.now() };
}
/** Bounded ring of recent chat messages used to render the transcript view. */
export class TranscriptCache {
messages: ChatMessageDisplay[] = [];
maxLines: number;
dirty = true;
constructor(maxLines = 200) {
this.maxLines = maxLines;
}
push(msg: ChatMessageDisplay): void {
this.messages.push(msg);
while (this.messages.length > this.maxLines) {
this.messages.shift();
}
this.dirty = true;
}
appendToLast(text: string, isReasoning: boolean): void {
const msg = this.messages[this.messages.length - 1];
if (msg && msg.role === Roles.Assistant) {
if (isReasoning) {
msg.reasoning += text;
} else {
msg.content += text;
}
this.dirty = true;
}
}
}
/* ── AppStateRest ─────────────────────────────────────────────────── */
/**
* The single source-of-truth state struct for the TUI interface.
* Mutated via `applyAction`; read by the renderer every frame.
*/
export interface AppStateRest {
settings: Record<string, unknown>;
appConfig: { providers: Record<string, { apiBase?: string; defaultApiKey?: string; apiKeyEnv?: string }> };
workspaceRoots: string[];
sessionId: string;
sessionDir: string;
memoryDir: string;
worktreesDir: string;
transcriptCache: TranscriptCache;
scroll: ScrollState;
input: InputState;
misc: MiscState;
turnInFlightFlag: { value: boolean };
abortFlag: { value: boolean };
dirty: boolean;
quit: boolean;
helpText: string;
displayLinesCache: string[];
cachedTokenCount: number;
tokenCountDirty: boolean;
lastRenderWidth: number;
cachedMsgCount: number;
cachedLastStart: number;
cachedLastLen: number;
renderWidthAtCache: number;
mentionFiles: string[];
pendingSubmit: string | null; // set when a turn should be spawned
}
export const DEFAULT_HELP_TEXT = ` Zesdex TUI — Keyboard Shortcuts
─── General ───
Ctrl+C Quit confirm
Ctrl+D Close overlay
Ctrl+Y Copy last assistant message
Esc Abort turn / Close overlay
Tab Autocomplete
─── Navigation ───
↑ / ↓ History browse / Overlay navigate
Ctrl+↑/↓ Scroll transcript
PgUp / PgDown Scroll transcript
Enter Submit / Select autocomplete
─── Overlays ───
/help Show this help
/settings Open settings overlay
/todo Open tasks (todo) overlay
/usage Open usage statistics
/bash Open bash jobs overlay
/mcp Open MCP server management
/model Open model selector
/compact Compact conversation
/clear Clear transcript
/rewind Rewind conversation history
─── Editor Mode ───
/edit <path> Open file for inline editing
Ctrl+S Save changes
Esc Dismiss editor`;
/** Construct fresh TUI state. */
export function createTuiState(
workspaceRoots: string[],
sessionDir: string,
memoryDir: string,
): AppStateRest {
const sessionId = sessionDir.split(/[/\\]/).filter(Boolean).pop() ?? "";
const worktreesDir = memoryDir.replace(/[\\/][^\\/]*$/, "") + "/worktrees";
return {
settings: { provider: "claude" },
appConfig: { providers: {} },
workspaceRoots,
sessionId,
sessionDir,
memoryDir,
worktreesDir,
transcriptCache: new TranscriptCache(200),
scroll: new ScrollState(),
input: new InputState(),
misc: new MiscState(),
turnInFlightFlag: { value: false },
abortFlag: { value: false },
dirty: true,
quit: false,
helpText: DEFAULT_HELP_TEXT,
displayLinesCache: [],
cachedTokenCount: 0,
tokenCountDirty: true,
lastRenderWidth: 0,
cachedMsgCount: 0,
cachedLastStart: 0,
cachedLastLen: 0,
renderWidthAtCache: 0,
mentionFiles: [],
pendingSubmit: null,
};
}
/** Whether an agent turn is currently running. */
export function turnInFlight(state: AppStateRest): boolean {
return state.turnInFlightFlag.value;
}
/** Append a message to the transcript. */
export function pushTranscript(state: AppStateRest, msg: ChatMessageDisplay): void {
state.transcriptCache.push(msg);
state.tokenCountDirty = true;
state.dirty = true;
}
/** Append text to the last assistant message. */
export function appendToLastTranscript(state: AppStateRest, text: string, isReasoning: boolean): void {
state.transcriptCache.appendToLast(text, isReasoning);
if (state.transcriptCache.dirty) state.dirty = true;
}
export function markDirty(state: AppStateRest): void {
state.dirty = true;
}
export function pushToast(
state: AppStateRest,
toast: { kind: string; message: string; createdMs: number; ttlMs: number },
): void {
state.misc.pushToast(toast);
state.dirty = true;
}
export function toastInfo(state: AppStateRest, message: string): void {
pushToast(state, { kind: "info", message, createdMs: Date.now(), ttlMs: 4000 });
}
export function toastSuccess(state: AppStateRest, message: string): void {
pushToast(state, { kind: "success", message, createdMs: Date.now(), ttlMs: 4000 });
}
export function toastWarning(state: AppStateRest, message: string): void {
pushToast(state, { kind: "warning", message, createdMs: Date.now(), ttlMs: 4000 });
}
export function toastError(state: AppStateRest, message: string): void {
pushToast(state, { kind: "error", message, createdMs: Date.now(), ttlMs: 5000 });
}
/** Resolve the base directory of the store (grandparent of the session dir). */
export function storeBaseDir(state: AppStateRest): string {
return state.sessionDir.replace(/[\\/][^\\/]*[\\/][^\\/]*$/, "");
}
+127
View File
@@ -0,0 +1,127 @@
/**
* Tests for the TUI logic layer: command parsing, action dispatch, and the
* key controller. Mirrors the Rust `#[cfg(test)]` blocks.
*/
import { describe, test, expect } from "bun:test";
import { parseCommand, applyCommand } from "./command.ts";
import { createTuiState, pushTranscript, makeChatMessage } from "./state.ts";
import { applyAction } from "./action.ts";
import { handleKey, decodeKey } from "./controller.ts";
import { Roles } from "@zesdex/domain";
function freshState() {
return createTuiState(["/tmp/zesdex-tui-test"], "/tmp/zesdex-tui-test/sess", "/tmp/zesdex-tui-test/mem");
}
describe("parseCommand", () => {
test("recognises /todo", () => {
expect(parseCommand("/todo")).toEqual({ tag: "TodoOpen" });
});
test("recognises /usage", () => {
expect(parseCommand("/usage")).toEqual({ tag: "UsageOpen" });
});
test("non-slash input is Unknown", () => {
expect(parseCommand("hello")).toEqual({ tag: "Unknown", text: "hello" });
});
test("parses /edit path", () => {
expect(parseCommand("/edit src/a.ts")).toEqual({ tag: "Edit", path: "src/a.ts" });
});
test("parses /clear with arg as Clear, without as ClearConfirm", () => {
expect(parseCommand("/clear foo")).toEqual({ tag: "Clear" });
expect(parseCommand("/clear")).toEqual({ tag: "ClearConfirm" });
});
});
describe("applyCommand", () => {
test("Help opens help overlay", () => {
expect(applyCommand({ tag: "Help" })).toEqual([{ tag: "OpenOverlay", overlay: "help" }]);
});
test("Unknown slash produces SystemNote error", () => {
expect(applyCommand({ tag: "Unknown", text: "/nope" })).toEqual([
{ tag: "SystemNote", kind: "error", message: "Unknown command: /nope" },
]);
});
});
describe("applyAction", () => {
test("SubmitInput pushes user transcript and sets pendingSubmit", () => {
const s = freshState();
applyAction(s, { tag: "SubmitInput", text: "hi" });
const last = s.transcriptCache.messages[s.transcriptCache.messages.length - 1];
expect(last?.role).toBe(Roles.User);
expect(last?.content).toBe("hi");
expect(s.pendingSubmit).toBe("hi");
});
test("ForceQuit sets quit", () => {
const s = freshState();
applyAction(s, { tag: "ForceQuit" });
expect(s.quit).toBe(true);
});
test("QuitConfirm sets overlay", () => {
const s = freshState();
applyAction(s, { tag: "QuitConfirm" });
expect(s.misc.overlay).toBe("quit_confirm");
});
test("AbortTurn sets abort flag", () => {
const s = freshState();
applyAction(s, { tag: "AbortTurn" });
expect(s.abortFlag.value).toBe(true);
});
});
describe("handleKey", () => {
test("Ctrl+C produces QuitConfirm", () => {
const s = freshState();
const actions = handleKey({ code: "Char", value: "c", ctrl: true, alt: false }, s);
expect(actions).toEqual([{ tag: "QuitConfirm" }]);
});
test("typing chars inserts into buffer", () => {
const s = freshState();
handleKey({ code: "Char", value: "h", ctrl: false, alt: false }, s);
handleKey({ code: "Char", value: "i", ctrl: false, alt: false }, s);
expect(s.input.buffer).toBe("hi");
});
test("Enter submits input as SubmitInput", () => {
const s = freshState();
handleKey({ code: "Char", value: "a", ctrl: false, alt: false }, s);
const actions = handleKey({ code: "Enter", ctrl: false, alt: false }, s);
expect(actions).toEqual([{ tag: "SubmitInput", text: "a" }]);
});
test("Ctrl+Y copies last assistant message", () => {
const s = freshState();
pushTranscript(s, makeChatMessage(Roles.User, "hi"));
pushTranscript(s, makeChatMessage(Roles.Assistant, "first"));
pushTranscript(s, makeChatMessage(Roles.Tool, "tool"));
pushTranscript(s, makeChatMessage(Roles.Assistant, "second"));
handleKey({ code: "Char", value: "y", ctrl: true, alt: false }, s);
expect(s.misc.pendingClipboardCopy).toBe("second");
});
test("Ctrl+Y with no assistant pushes info toast", () => {
const s = freshState();
handleKey({ code: "Char", value: "y", ctrl: true, alt: false }, s);
expect(s.misc.pendingClipboardCopy).toBeNull();
expect(s.misc.toasts.length).toBe(1);
});
});
describe("decodeKey", () => {
test("Enter decodes", () => {
expect(decodeKey("\r")).toEqual({ code: "Enter", ctrl: false, alt: false });
});
test("arrow up decodes", () => {
expect(decodeKey("\u001b[A")).toEqual({ code: "ArrowUp", ctrl: false, alt: false });
});
test("ctrl-c decodes to ctrl Char c", () => {
expect(decodeKey("\u0003")).toEqual({ code: "Char", value: "c", ctrl: true, alt: false });
});
test("plain letter decodes", () => {
expect(decodeKey("x")).toEqual({ code: "Char", value: "x", ctrl: false, alt: false });
});
});
+6
View File
@@ -0,0 +1,6 @@
/**
* Zesdex web frontend interface package.
* Mirrors `apps/interfaces/web/`.
*/
export { startWebServer, handleWebRequest } from "./server.ts";
export type { WebState } from "./server.ts";
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bun
/**
* Zesdex web server — standalone entry point.
* Mirrors the `--web` mode of the Rust gateway.
*/
import { startWebServer } from "./index.ts";
const port = Number.parseInt(process.env.ZESDEX_WEB_PORT ?? "3000", 10);
const staticDir = process.env.ZESDEX_WEB_DIR ?? undefined;
startWebServer(port, staticDir);
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env bun
/**
* Zesdex web frontend interface — serves static browser assets.
* Mirrors `apps/interfaces/web/src/lib.rs`.
*
* Serves files from a `dist/`/static directory with path-traversal protection:
* every requested path is canonicalized and must resolve inside `static_dir`.
* Falls back to a minimal placeholder page when no frontend is built.
*/
import * as fs from "node:fs";
import * as path from "node:path";
/** Web server state. */
export interface WebState {
static_dir: string;
}
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".webp": "image/webp",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".map": "application/json",
".txt": "text/plain; charset=utf-8",
};
function mimeFor(file: string): string {
return MIME[path.extname(file).toLowerCase()] ?? "application/octet-stream";
}
/** Placeholder page when no frontend is built. */
const PLACEHOLDER = `<!DOCTYPE html>
<html><head><title>Zesdex Web</title>
<meta charset="utf-8">
<style>body{font-family:sans-serif;padding:2em;background:#1a1b26;color:#c0caf5}
h1{color:#7aa2f7}a{color:#bb9af7}</style></head>
<body>
<h1>Zesdex Web</h1>
<p>Web interface is ready.</p>
<p>To connect the frontend:</p>
<ol>
<li>Build the frontend: <code>cd apps/interfaces/web && npm install && npm run build</code></li>
<li>Restart with <code>--web-dir apps/interfaces/web/dist</code></li>
</ol>
</body></html>`;
/** Resolve a requested path safely inside `staticDir`; returns null on traversal/not-found. */
function safeResolve(staticDir: string, rel: string): string | null {
// Strip any leading slash so a relative path is resolved against staticDir
// (path.resolve would otherwise ignore staticDir for absolute-ish inputs).
const clean = rel.replace(/^\/+/, "");
const candidate = path.resolve(staticDir, clean || "index.html");
const canonStatic = path.resolve(staticDir);
// Prevent directory traversal: resolved path must stay inside static dir.
if (!candidate.startsWith(canonStatic + path.sep) && candidate !== canonStatic) {
return null;
}
// Only serve regular files; reject if the target is a directory or missing.
try {
const st = fs.statSync(candidate);
return st.isFile() ? candidate : null;
} catch {
return null;
}
}
/** Build the web router (static file serving). */
export function handleWebRequest(state: WebState, req: Request): Response {
const url = new URL(req.url);
let rel = decodeURIComponent(url.pathname);
if (rel.endsWith("/")) rel += "index.html";
const file = safeResolve(state.static_dir, rel);
if (!file) {
// Root always returns the placeholder index (even without a built frontend).
if (rel === "/index.html") {
return new Response(PLACEHOLDER, {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
return new Response("Not found", { status: 404 });
}
const data = fs.readFileSync(file);
return new Response(data, {
status: 200,
headers: { "Content-Type": mimeFor(file) },
});
}
/** Start the web frontend server. */
export function startWebServer(port: number, staticDir?: string): { stop: () => void } {
const dir =
staticDir && staticDir !== ""
? staticDir
: (() => {
const p = path.resolve("apps/interfaces/web/dist");
return fs.existsSync(p) ? p : process.cwd();
})();
const state: WebState = { static_dir: dir };
const server = Bun.serve({
port,
fetch(req) {
return handleWebRequest(state, req);
},
});
console.log(`zesdex-web listening on http://0.0.0.0:${server.port} (dir: ${state.static_dir})`);
return { stop: () => server.stop(true) };
}
+6
View File
@@ -0,0 +1,6 @@
/**
* Zesdex WebSocket interface package.
* Mirrors `apps/interfaces/ws/`.
*/
export { startWsServer } from "./server.ts";
export type { WsState, SocketSend } from "./server.ts";
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bun
/**
* Zesdex WS server — standalone entry point.
* Mirrors the `--ws` mode of the Rust gateway.
*/
import { startWsServer } from "./index.ts";
const port = Number.parseInt(process.env.ZESDEX_WS_PORT ?? "8081", 10);
startWsServer(port);
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env bun
/**
* Zesdex WebSocket interface — real-time bidirectional communication.
* Mirrors `apps/interfaces/ws/src/lib.rs`.
*
* Security: accepts an optional `?token=` query param. When `ZESDEX_WS_TOKEN`
* env is set, connections MUST present a matching token, else rejected —
* prevents the endpoint from being used as an open LLM proxy.
*
* Protocol (JSON text frames):
* client → { "type": "prompt", "message": "...", "model"?: "..." }
* server → { "type": "connected", "session": ..., "message": "..." } (on connect)
* server → { "type": "token", "content": "..." } (streaming)
* server → { "type": "done" } | { "type": "error", "message": "..." } (terminal)
* server → { "type": "echo", "data": "..." } (fallback)
*/
import {
JsonSettingsRepository,
JsonAppConfigRepository,
LlmClient,
resolveApiKey,
InfrastructureToolExecutor,
allTools,
toolDefs,
} from "@zesdex/infrastructure";
import type { ToolCtx } from "@zesdex/infrastructure";
import { AgentTurnServiceImpl } from "@zesdex/application";
import {
newStore,
ensureStoreDirs,
type TurnEvent,
type TurnEventSink,
type AgentTurnParams,
userMessage,
resolveEffectiveModel,
} from "@zesdex/domain";
/** Shared application state for the WS server. */
export interface WsState {
store_base_dir: string;
session_id: string | null;
}
/** Minimal send interface used by streaming helpers. */
export interface SocketSend {
send(json: string): void;
}
/** Array-backed turn event sink (push + drain). */
class ArraySink implements TurnEventSink {
constructor(public events: TurnEvent[] = []) {}
push(event: TurnEvent): void {
this.events.push(event);
}
drain(): TurnEvent[] {
const out = this.events;
this.events = [];
return out;
}
}
/** Validate the token against ZESDEX_WS_TOKEN (when configured). */
function tokenAllowed(url: URL): boolean {
const configured = process.env.ZESDEX_WS_TOKEN;
const t = url.searchParams.get("token");
if (configured) {
return t === configured;
}
return true;
}
/** Wire a turn service for one prompt (matches the CLI composition root). */
async function buildSingleShot(
sessionDir: string,
eventSink: TurnEventSink,
): Promise<{ turnService: AgentTurnServiceImpl; params: AgentTurnParams }> {
const store = newStore();
await ensureStoreDirs(store);
const settingsRepo = new JsonSettingsRepository();
const appConfigRepo = new JsonAppConfigRepository();
const settings = await settingsRepo.load(store.base_dir);
const appConfig = await appConfigRepo.load(store.base_dir);
const provider = settings.provider;
const model = resolveEffectiveModel(settings, appConfig);
const baseUrl = appConfig.providers[provider]?.api_base ?? undefined;
const apiKey = resolveApiKey(settings, appConfig);
const llmClient = new LlmClient(apiKey, model, baseUrl);
const toolCtx = {
sessionDir,
workspaces: [sessionDir],
turnEvents: eventSink,
workflowFindings: [],
} as unknown as ToolCtx;
const executor = new InfrastructureToolExecutor(toolCtx);
const turnService = new AgentTurnServiceImpl(llmClient, executor, toolDefs(allTools()) as never);
const params: AgentTurnParams = {
messages: [userMessage("")],
session_dir: sessionDir,
workspace_roots: [sessionDir],
turn_events: eventSink,
in_flight: { value: false },
abort: new AbortController(),
api_key: apiKey,
model,
api_base: baseUrl,
};
return { turnService, params };
}
/**
* Start the WS server. `onPrompt` is invoked with the client's prompt payload,
* and `forward` sends raw strings to the client.
*/
export function startWsServer(port: number): { stop: () => void } {
const state: WsState = { store_base_dir: ".", session_id: null };
const server = Bun.serve({
port,
fetch(req, server) {
const url = new URL(req.url);
if (url.pathname === "/ws") {
if (!tokenAllowed(url)) {
return new Response("missing or invalid token", { status: 401 });
}
if (server.upgrade(req, {})) {
return undefined;
}
return new Response("upgrade failed", { status: 400 });
}
return new Response("Not found", { status: 404 });
},
websocket: {
open(ws) {
const sender: SocketSend = { send: (s) => ws.send(s) };
// Welcome message on connect.
sender.send(
JSON.stringify({
type: "connected",
session: state.session_id,
message: "Connected to Zesdex WebSocket server",
}),
);
},
message(ws, msg) {
const text = typeof msg === "string" ? msg : JSON.stringify(msg);
if (!text.startsWith("{")) {
ws.send(JSON.stringify({ type: "echo", data: text }));
return;
}
let val: Record<string, unknown>;
try {
val = JSON.parse(text) as Record<string, unknown>;
} catch {
ws.send(JSON.stringify({ type: "echo", data: text }));
return;
}
if (val.type === "prompt" && typeof val.message === "string") {
const sender: SocketSend = { send: (s) => ws.send(s) };
void runPrompt(sender, val.message, typeof val.model === "string" ? val.model : undefined);
} else {
ws.send(JSON.stringify({ type: "echo", data: text }));
}
},
close() {},
},
});
console.log(`zesdex-ws listening on ws://0.0.0.0:${server.port}`);
return { stop: () => server.stop(true) };
}
/** Run a single prompt and stream events to the client. */
async function runPrompt(ws: SocketSend, message: string, modelOverride?: string): Promise<void> {
const sessionDir = process.cwd();
const sink = new ArraySink();
try {
const { turnService, params } = await buildSingleShot(sessionDir, sink);
if (modelOverride) params.model = modelOverride;
params.messages = [userMessage(message)];
// Poll the sink and forward events to the client.
const poll = setInterval(() => {
const evs = sink.drain();
for (const ev of evs) {
if (ev.kind === "stream_token") {
ws.send(JSON.stringify({ type: "token", content: ev.content }));
} else if (ev.kind === "done") {
ws.send(JSON.stringify({ type: "done" }));
clearInterval(poll);
} else if (ev.kind === "error") {
ws.send(JSON.stringify({ type: "error", message: ev.message }));
clearInterval(poll);
}
}
}, 50);
try {
await turnService.runTurn(params);
// Ensure a terminal done/error is sent even if none auto-emitted
// while the poll stopped early.
const remaining = sink.drain();
for (const ev of remaining) {
if (ev.kind === "stream_token") ws.send(JSON.stringify({ type: "token", content: ev.content }));
else if (ev.kind === "done") ws.send(JSON.stringify({ type: "done" }));
else if (ev.kind === "error") ws.send(JSON.stringify({ type: "error", message: ev.message }));
}
if (!remaining.some((e) => e.kind === "done" || e.kind === "error")) {
ws.send(JSON.stringify({ type: "done" }));
}
} finally {
clearInterval(poll);
}
} catch (e) {
ws.send(JSON.stringify({ type: "error", message: (e as Error).message }));
}
}