feat(T-1.5/1.6/1.7): stream+input+snapshot routes, sessions CRUD, commands, side-channel, health endpoint
This commit is contained in:
parent
db6be6dcf8
commit
b94b668df6
|
|
@ -0,0 +1,36 @@
|
||||||
|
/**
|
||||||
|
* S-08 — slash-command registry route.
|
||||||
|
*
|
||||||
|
* GET /sessions/:id/commands → [{ name, description, args }]
|
||||||
|
*
|
||||||
|
* Returns the list of slash commands available in the current pi session.
|
||||||
|
* Delegates to pi/commands.ts (T-1.4).
|
||||||
|
*
|
||||||
|
* Owner: T-1.6
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
import { getCommands } from "../../pi/commands.js";
|
||||||
|
import { getSession } from "../../tmux/manager.js";
|
||||||
|
import { sendJson } from "../util.js";
|
||||||
|
|
||||||
|
export async function handleCommands(
|
||||||
|
_req: IncomingMessage,
|
||||||
|
res: ServerResponse,
|
||||||
|
sessionId: string,
|
||||||
|
pi: ExtensionAPI,
|
||||||
|
): Promise<void> {
|
||||||
|
const session = await getSession(sessionId).catch(() => null);
|
||||||
|
if (!session) {
|
||||||
|
sendJson(res, 404, { error: "session_not_found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const commands = await getCommands(pi);
|
||||||
|
sendJson(res, 200, commands);
|
||||||
|
} catch (err) {
|
||||||
|
sendJson(res, 500, { error: "internal_error", message: String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
/**
|
||||||
|
* S-12 — health endpoint.
|
||||||
|
*
|
||||||
|
* GET /health → { ok, sessions, bufferBytes, uptime, version }
|
||||||
|
*
|
||||||
|
* Also integrates the disk watchdog: on each health call we check free space
|
||||||
|
* and total buffer size, returning a warning if caps are near.
|
||||||
|
*
|
||||||
|
* Owner: T-1.7
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { listSessions } from "../../tmux/manager.js";
|
||||||
|
import { sendJson } from "../util.js";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
const _startedAt = Date.now();
|
||||||
|
|
||||||
|
export interface HealthOptions {
|
||||||
|
stateDir?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleHealth(
|
||||||
|
_req: IncomingMessage,
|
||||||
|
res: ServerResponse,
|
||||||
|
opts: HealthOptions = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const stateDir =
|
||||||
|
opts.stateDir ?? path.join(os.homedir(), ".local", "share", "pi-remote");
|
||||||
|
|
||||||
|
const [sessions, bufferBytes, freeBytes] = await Promise.all([
|
||||||
|
listSessions().catch(() => []),
|
||||||
|
getTotalBufferBytes(stateDir),
|
||||||
|
getFreeBytes(stateDir),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const freeGb = freeBytes / (1024 * 1024 * 1024);
|
||||||
|
const bufferMb = bufferBytes / (1024 * 1024);
|
||||||
|
|
||||||
|
sendJson(res, 200, {
|
||||||
|
ok: true,
|
||||||
|
uptime: Math.floor((Date.now() - _startedAt) / 1000),
|
||||||
|
sessions: sessions.length,
|
||||||
|
sessionIds: sessions.map((s) => s.id),
|
||||||
|
bufferMb: Math.round(bufferMb * 10) / 10,
|
||||||
|
diskFreeGb: Math.round(freeGb * 10) / 10,
|
||||||
|
warnings: buildWarnings(freeGb, bufferMb),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWarnings(freeGb: number, bufferMb: number): string[] {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
if (freeGb < 1) warnings.push(`Low disk space: ${freeGb.toFixed(1)} GB free`);
|
||||||
|
if (bufferMb > 900)
|
||||||
|
warnings.push(`Buffer near cap: ${bufferMb.toFixed(0)} MB used`);
|
||||||
|
return warnings;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTotalBufferBytes(stateDir: string): Promise<number> {
|
||||||
|
const bufDir = path.join(stateDir, "buffers");
|
||||||
|
try {
|
||||||
|
const entries = await fs.readdir(bufDir, { withFileTypes: true });
|
||||||
|
let total = 0;
|
||||||
|
for (const e of entries) {
|
||||||
|
if (!e.name.endsWith(".buf")) continue;
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(path.join(bufDir, e.name));
|
||||||
|
total += stat.size;
|
||||||
|
} catch {
|
||||||
|
// skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFreeBytes(dir: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync("df", ["-k", dir]);
|
||||||
|
const lines = stdout.trim().split("\n");
|
||||||
|
const last = lines[lines.length - 1];
|
||||||
|
const parts = last.split(/\s+/);
|
||||||
|
const availKb = parseInt(parts[3], 10);
|
||||||
|
return availKb * 1024;
|
||||||
|
} catch {
|
||||||
|
return Number.POSITIVE_INFINITY; // unknown — don't warn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
/**
|
||||||
|
* S-03 — send-keys input route.
|
||||||
|
*
|
||||||
|
* POST /sessions/:id/input
|
||||||
|
*
|
||||||
|
* Body (IC-1 ClientToServer subset, but as HTTP POST for non-WS clients):
|
||||||
|
* { type: "key"; name: string }
|
||||||
|
* { type: "keys"; data: string }
|
||||||
|
* { type: "paste"; data: string }
|
||||||
|
*
|
||||||
|
* Response: 204 No Content on success, 400 on bad input, 404 if session missing.
|
||||||
|
*
|
||||||
|
* Note: the primary path for send-keys is via WS (T-1.5 stream route handles
|
||||||
|
* key/keys/paste messages inline). This HTTP endpoint is for clients that
|
||||||
|
* don't have an open stream (e.g. one-shot CLI tools).
|
||||||
|
*
|
||||||
|
* Owner: T-1.5
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
import { sendKey, sendKeys, sendPaste } from "../../tmux/input.js";
|
||||||
|
import { getSession } from "../../tmux/manager.js";
|
||||||
|
import { readBody, sendJson } from "../util.js";
|
||||||
|
|
||||||
|
export async function handleInput(
|
||||||
|
req: IncomingMessage,
|
||||||
|
res: ServerResponse,
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const session = await getSession(sessionId).catch(() => null);
|
||||||
|
if (!session) {
|
||||||
|
sendJson(res, 404, {
|
||||||
|
error: "session_not_found",
|
||||||
|
message: `Session "${sessionId}" not found`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = JSON.parse(await readBody(req));
|
||||||
|
} catch {
|
||||||
|
sendJson(res, 400, { error: "bad_request", message: "Invalid JSON body" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body || typeof body !== "object") {
|
||||||
|
sendJson(res, 400, {
|
||||||
|
error: "bad_request",
|
||||||
|
message: "Body must be a JSON object",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const m = body as Record<string, unknown>;
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (m.type) {
|
||||||
|
case "key": {
|
||||||
|
if (typeof m.name !== "string") {
|
||||||
|
sendJson(res, 400, {
|
||||||
|
error: "bad_request",
|
||||||
|
message: "key.name must be a string",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sendKey(sessionId, m.name);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "keys": {
|
||||||
|
if (typeof m.data !== "string") {
|
||||||
|
sendJson(res, 400, {
|
||||||
|
error: "bad_request",
|
||||||
|
message: "keys.data must be a string",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sendKeys(sessionId, m.data);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "paste": {
|
||||||
|
if (typeof m.data !== "string") {
|
||||||
|
sendJson(res, 400, {
|
||||||
|
error: "bad_request",
|
||||||
|
message: "paste.data must be a string",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sendPaste(sessionId, m.data);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
sendJson(res, 400, {
|
||||||
|
error: "bad_request",
|
||||||
|
message: `Unknown type: ${String(m.type)}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
sendJson(res, 500, { error: "internal_error", message: String(err) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(204).end();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,166 @@
|
||||||
|
/**
|
||||||
|
* S-09 — multi-session CRUD routes.
|
||||||
|
*
|
||||||
|
* POST /sessions → { id, name }
|
||||||
|
* GET /sessions → [{ id, name, description, state, lastOutputAt }]
|
||||||
|
* PATCH /sessions/:id → updates @description
|
||||||
|
* DELETE /sessions/:id → kills tmux session, optionally clears buffer
|
||||||
|
* GET /sessions/:id/thumbnail → text/plain capture-pane (40×12)
|
||||||
|
*
|
||||||
|
* Owner: T-1.6
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
import { BufferWriter } from "../../buffer/writer.js";
|
||||||
|
import {
|
||||||
|
getSession,
|
||||||
|
killSession,
|
||||||
|
listSessions,
|
||||||
|
setDescription,
|
||||||
|
spawnSession,
|
||||||
|
} from "../../tmux/manager.js";
|
||||||
|
import { captureThumbnail } from "../../tmux/snapshot.js";
|
||||||
|
import { readBody, sendJson } from "../util.js";
|
||||||
|
|
||||||
|
export async function handleSessions(
|
||||||
|
req: IncomingMessage,
|
||||||
|
res: ServerResponse,
|
||||||
|
sessionId?: string,
|
||||||
|
sub?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const method = req.method?.toUpperCase();
|
||||||
|
|
||||||
|
// GET /sessions/:id/thumbnail
|
||||||
|
if (sessionId && sub === "thumbnail" && method === "GET") {
|
||||||
|
const session = await getSession(sessionId).catch(() => null);
|
||||||
|
if (!session) {
|
||||||
|
sendJson(res, 404, { error: "session_not_found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = await captureThumbnail(sessionId).catch(() => "");
|
||||||
|
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
|
||||||
|
res.end(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// /sessions/:id — PATCH / DELETE
|
||||||
|
if (sessionId && !sub) {
|
||||||
|
if (method === "PATCH") {
|
||||||
|
await handlePatch(req, res, sessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (method === "DELETE") {
|
||||||
|
await handleDelete(req, res, sessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendJson(res, 405, { error: "method_not_allowed" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// /sessions — GET / POST
|
||||||
|
if (!sessionId) {
|
||||||
|
if (method === "GET") {
|
||||||
|
await handleList(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (method === "POST") {
|
||||||
|
await handleCreate(req, res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendJson(res, 405, { error: "method_not_allowed" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(res, 404, { error: "not_found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleList(res: ServerResponse): Promise<void> {
|
||||||
|
try {
|
||||||
|
const sessions = await listSessions();
|
||||||
|
const payload = sessions.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
description: s.description,
|
||||||
|
state: "idle", // T-1.4 events will feed real state in Phase 2
|
||||||
|
lastOutputAt: s.lastActivityAt,
|
||||||
|
}));
|
||||||
|
sendJson(res, 200, payload);
|
||||||
|
} catch (err) {
|
||||||
|
sendJson(res, 500, { error: "internal_error", message: String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate(
|
||||||
|
req: IncomingMessage,
|
||||||
|
res: ServerResponse,
|
||||||
|
): Promise<void> {
|
||||||
|
let body: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
const raw = await readBody(req);
|
||||||
|
if (raw.trim()) body = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
sendJson(res, 400, { error: "bad_request", message: "Invalid JSON" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name =
|
||||||
|
typeof body.name === "string" && body.name.trim()
|
||||||
|
? body.name.trim()
|
||||||
|
: `session-${Date.now()}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const id = await spawnSession({ name });
|
||||||
|
sendJson(res, 201, { id, name });
|
||||||
|
} catch (err) {
|
||||||
|
sendJson(res, 500, { error: "internal_error", message: String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePatch(
|
||||||
|
req: IncomingMessage,
|
||||||
|
res: ServerResponse,
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const session = await getSession(sessionId).catch(() => null);
|
||||||
|
if (!session) {
|
||||||
|
sendJson(res, 404, { error: "session_not_found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
const raw = await readBody(req);
|
||||||
|
if (raw.trim()) body = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
sendJson(res, 400, { error: "bad_request", message: "Invalid JSON" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof body.description === "string") {
|
||||||
|
await setDescription(sessionId, body.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(res, 200, { id: sessionId, description: body.description });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(
|
||||||
|
_req: IncomingMessage,
|
||||||
|
res: ServerResponse,
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const session = await getSession(sessionId).catch(() => null);
|
||||||
|
if (!session) {
|
||||||
|
sendJson(res, 404, { error: "session_not_found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await killSession(sessionId);
|
||||||
|
// Optionally clear buffer
|
||||||
|
const writer = new BufferWriter(sessionId);
|
||||||
|
await writer.delete().catch(() => {}); // best-effort
|
||||||
|
res.writeHead(204).end();
|
||||||
|
} catch (err) {
|
||||||
|
sendJson(res, 500, { error: "internal_error", message: String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
/**
|
||||||
|
* S-07 — state side-channel route.
|
||||||
|
*
|
||||||
|
* WS endpoint: GET /sessions/:id/side
|
||||||
|
*
|
||||||
|
* Pushes IC-1 ServerToClient JSON frames (state, session-meta, error).
|
||||||
|
* Does NOT carry binary output (that's /stream).
|
||||||
|
* Lightweight channel for UI state updates without full output stream.
|
||||||
|
*
|
||||||
|
* Owner: T-1.6
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IncomingMessage } from "node:http";
|
||||||
|
import type { Socket } from "node:net";
|
||||||
|
import type { StateEvent } from "../../pi/events.js";
|
||||||
|
import { getSession } from "../../tmux/manager.js";
|
||||||
|
import type { WsClient, WsServer } from "../types.js";
|
||||||
|
|
||||||
|
export interface SideRouteOptions {
|
||||||
|
wss: WsServer;
|
||||||
|
isAuthenticated: (req: IncomingMessage) => boolean;
|
||||||
|
getCurrentState?: () => StateEvent | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribers per session: sessionId → set of ws clients
|
||||||
|
const _subscribers = new Map<string, Set<WsClient>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast a state event to all side-channel subscribers of a session.
|
||||||
|
*/
|
||||||
|
export function broadcastState(sessionId: string, event: StateEvent): void {
|
||||||
|
const subs = _subscribers.get(sessionId);
|
||||||
|
if (!subs) return;
|
||||||
|
const msg = JSON.stringify({
|
||||||
|
type: "state",
|
||||||
|
value: event.value,
|
||||||
|
tool: event.tool,
|
||||||
|
ts: event.ts,
|
||||||
|
});
|
||||||
|
for (const ws of subs) {
|
||||||
|
if (ws.readyState === 1 /* OPEN */) {
|
||||||
|
ws.send(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a WebSocket upgrade for /sessions/:id/side.
|
||||||
|
*/
|
||||||
|
export function handleSideUpgrade(
|
||||||
|
sessionId: string,
|
||||||
|
request: IncomingMessage,
|
||||||
|
socket: Socket,
|
||||||
|
head: Buffer,
|
||||||
|
opts: SideRouteOptions,
|
||||||
|
): void {
|
||||||
|
opts.wss.handleUpgrade(request, socket, head, (ws: WsClient) => {
|
||||||
|
handleSideConnection(sessionId, ws, opts);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSideConnection(
|
||||||
|
sessionId: string,
|
||||||
|
ws: WsClient,
|
||||||
|
opts: SideRouteOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
const session = await getSession(sessionId).catch(() => null);
|
||||||
|
if (!session) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
code: "session_not_found",
|
||||||
|
message: `Session "${sessionId}" not found`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
ws.terminate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push session-meta on connect
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "session-meta",
|
||||||
|
name: session.name,
|
||||||
|
description: session.description,
|
||||||
|
createdAt: session.createdAt,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Push current state
|
||||||
|
const currentState = opts.getCurrentState?.();
|
||||||
|
if (currentState) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "state",
|
||||||
|
value: currentState.value,
|
||||||
|
tool: currentState.tool,
|
||||||
|
ts: currentState.ts,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register subscriber
|
||||||
|
let subs = _subscribers.get(sessionId);
|
||||||
|
if (!subs) {
|
||||||
|
subs = new Set();
|
||||||
|
_subscribers.set(sessionId, subs);
|
||||||
|
}
|
||||||
|
subs.add(ws);
|
||||||
|
|
||||||
|
ws.on("close", () => {
|
||||||
|
subs?.delete(ws);
|
||||||
|
if (subs?.size === 0) _subscribers.delete(sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("error", () => {
|
||||||
|
subs?.delete(ws);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,205 @@
|
||||||
|
/**
|
||||||
|
* S-02 binary stream + S-04 sequence cursor resume + S-05 snapshot route.
|
||||||
|
*
|
||||||
|
* WS endpoint: GET /sessions/:id/stream
|
||||||
|
*
|
||||||
|
* Protocol (IC-1):
|
||||||
|
* - On connect: client sends { type: "resume"; lastSeq: number | null }
|
||||||
|
* - Server replays buffer chunks after lastSeq (binary frames with 8-byte seq header)
|
||||||
|
* - Then live output arrives as binary frames
|
||||||
|
* - Client may send { type: "snapshot-request" } → server sends { type: "snapshot"; ... }
|
||||||
|
* - State events pushed unsolicited: { type: "state"; ... }
|
||||||
|
* - Session meta pushed on connect: { type: "session-meta"; ... }
|
||||||
|
*
|
||||||
|
* Binary frame format: [seq: 8 bytes BE uint64][data: N bytes]
|
||||||
|
*
|
||||||
|
* Owner: T-1.5
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IncomingMessage } from "node:http";
|
||||||
|
import type { Socket } from "node:net";
|
||||||
|
import { readChunks } from "../../buffer/reader.js";
|
||||||
|
import type { StateEvent } from "../../pi/events.js";
|
||||||
|
import { SequenceCounter } from "../../sequence.js";
|
||||||
|
import { ControlClient } from "../../tmux/control.js";
|
||||||
|
import { capturePane } from "../../tmux/snapshot.js";
|
||||||
|
import type { WsClient, WsServer } from "../types.js";
|
||||||
|
|
||||||
|
export interface StreamRouteOptions {
|
||||||
|
wss: WsServer;
|
||||||
|
isAuthenticated: (req: IncomingMessage) => boolean;
|
||||||
|
/** Called to get the current agent state for new connections */
|
||||||
|
getCurrentState?: () => StateEvent | null;
|
||||||
|
stateDir?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-session registry of active ControlClients and sequence counters
|
||||||
|
const _clients = new Map<
|
||||||
|
string,
|
||||||
|
{ control: ControlClient; seq: SequenceCounter }
|
||||||
|
>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create a ControlClient + SequenceCounter for a session.
|
||||||
|
*/
|
||||||
|
function getOrCreateSession(sessionId: string): {
|
||||||
|
control: ControlClient;
|
||||||
|
seq: SequenceCounter;
|
||||||
|
} {
|
||||||
|
const existing = _clients.get(sessionId);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const seq = new SequenceCounter();
|
||||||
|
const control = new ControlClient({
|
||||||
|
session: sessionId,
|
||||||
|
onClose: (reason) => {
|
||||||
|
console.error(
|
||||||
|
`[stream] control client closed for ${sessionId}: ${reason}`,
|
||||||
|
);
|
||||||
|
_clients.delete(sessionId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
control.start();
|
||||||
|
|
||||||
|
const entry = { control, seq };
|
||||||
|
_clients.set(sessionId, entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop and remove a session's ControlClient (call on session kill).
|
||||||
|
*/
|
||||||
|
export function stopSession(sessionId: string): void {
|
||||||
|
const entry = _clients.get(sessionId);
|
||||||
|
if (entry) {
|
||||||
|
entry.control.stop();
|
||||||
|
_clients.delete(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a WebSocket upgrade for /sessions/:id/stream.
|
||||||
|
*/
|
||||||
|
export function handleStreamUpgrade(
|
||||||
|
sessionId: string,
|
||||||
|
request: IncomingMessage,
|
||||||
|
socket: Socket,
|
||||||
|
head: Buffer,
|
||||||
|
opts: StreamRouteOptions,
|
||||||
|
): void {
|
||||||
|
opts.wss.handleUpgrade(request, socket, head, (ws: WsClient) => {
|
||||||
|
handleStreamConnection(sessionId, ws, opts);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStreamConnection(
|
||||||
|
sessionId: string,
|
||||||
|
ws: WsClient,
|
||||||
|
opts: StreamRouteOptions,
|
||||||
|
): void {
|
||||||
|
const { control, seq } = getOrCreateSession(sessionId);
|
||||||
|
let resumed = false;
|
||||||
|
|
||||||
|
// Push session-meta immediately
|
||||||
|
sendJson(ws, {
|
||||||
|
type: "session-meta",
|
||||||
|
name: sessionId,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Push current state if available
|
||||||
|
const currentState = opts.getCurrentState?.();
|
||||||
|
if (currentState) {
|
||||||
|
sendJson(ws, {
|
||||||
|
type: "state",
|
||||||
|
value: currentState.value,
|
||||||
|
tool: currentState.tool,
|
||||||
|
ts: currentState.ts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to live output
|
||||||
|
const unsubscribe = control.subscribe((chunk: Buffer) => {
|
||||||
|
if (ws.readyState !== 1 /* OPEN */) return;
|
||||||
|
const seqNum = seq.next();
|
||||||
|
const frame = buildBinaryFrame(seqNum, chunk);
|
||||||
|
sendBinary(ws, frame);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle client messages
|
||||||
|
ws.on("message", (data: Buffer) => {
|
||||||
|
let msg: unknown;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(data.toString());
|
||||||
|
} catch {
|
||||||
|
sendJson(ws, {
|
||||||
|
type: "error",
|
||||||
|
code: "bad_message",
|
||||||
|
message: "Invalid JSON",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!msg || typeof msg !== "object") return;
|
||||||
|
const m = msg as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (m.type === "resume" && !resumed) {
|
||||||
|
resumed = true;
|
||||||
|
const lastSeq = typeof m.lastSeq === "number" ? m.lastSeq : 0;
|
||||||
|
// Replay buffered chunks after lastSeq
|
||||||
|
const chunks = readChunks(sessionId, {
|
||||||
|
afterSeq: lastSeq,
|
||||||
|
cfg: opts.stateDir ? { stateDir: opts.stateDir } : undefined,
|
||||||
|
});
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
if (ws.readyState !== 1) break;
|
||||||
|
sendBinary(ws, buildBinaryFrame(chunk.seq, chunk.data));
|
||||||
|
}
|
||||||
|
} else if (m.type === "snapshot-request") {
|
||||||
|
capturePane({ session: sessionId })
|
||||||
|
.then((text) => {
|
||||||
|
const data = Buffer.from(text).toString("base64");
|
||||||
|
const s = seq.next();
|
||||||
|
sendJson(ws, { type: "snapshot", seq: s, data });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
sendJson(ws, {
|
||||||
|
type: "error",
|
||||||
|
code: "snapshot_failed",
|
||||||
|
message: "Failed to capture pane",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("close", () => {
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("error", () => {
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Build a binary frame: [seq: 8 bytes BE][data] */
|
||||||
|
function buildBinaryFrame(seqNum: number, data: Buffer): Buffer {
|
||||||
|
const header = Buffer.allocUnsafe(8);
|
||||||
|
header.writeBigUInt64BE(BigInt(seqNum), 0);
|
||||||
|
return Buffer.concat([header, data]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(ws: WsClient, msg: object): void {
|
||||||
|
if (ws.readyState === 1 /* OPEN */) {
|
||||||
|
ws.send(JSON.stringify(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendBinary(ws: WsClient, data: Buffer): void {
|
||||||
|
if (ws.readyState === 1 /* OPEN */) {
|
||||||
|
(ws as unknown as { send(data: Buffer): void }).send(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,7 @@ import type {
|
||||||
ExtensionAPI,
|
ExtensionAPI,
|
||||||
ExtensionContext,
|
ExtensionContext,
|
||||||
} from "@earendil-works/pi-coding-agent";
|
} from "@earendil-works/pi-coding-agent";
|
||||||
|
import { validateBearer } from "../auth/tokens.js";
|
||||||
import {
|
import {
|
||||||
generateSessionId,
|
generateSessionId,
|
||||||
loadOrCreateToken,
|
loadOrCreateToken,
|
||||||
|
|
@ -38,8 +39,13 @@ import {
|
||||||
import { parseBindAddress, readRemoteControlConfig } from "../config.js";
|
import { parseBindAddress, readRemoteControlConfig } from "../config.js";
|
||||||
import { buildHTML } from "../html.js"; // LEGACY: browser HTML client
|
import { buildHTML } from "../html.js"; // LEGACY: browser HTML client
|
||||||
import { buildSyncMessage } from "../messages.js";
|
import { buildSyncMessage } from "../messages.js";
|
||||||
|
import { handleCommands } from "./routes/commands.js";
|
||||||
|
import { handleHealth } from "./routes/health.js";
|
||||||
|
import { handleInput } from "./routes/input.js";
|
||||||
|
import { handleSessions } from "./routes/sessions.js";
|
||||||
import type { RemoteServer, WsClient, WsServer } from "./types.js";
|
import type { RemoteServer, WsClient, WsServer } from "./types.js";
|
||||||
import { createUpgradeHandler } from "./upgrade.js";
|
import { createUpgradeHandler } from "./upgrade.js";
|
||||||
|
import { extractBearer } from "./util.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Load ws (bundled with pi) without needing @types/ws installed locally
|
// Load ws (bundled with pi) without needing @types/ws installed locally
|
||||||
|
|
@ -196,8 +202,75 @@ export async function startServer(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
// New API routes (IC-2) — bearer token auth
|
||||||
res.end("Not found");
|
const asyncHandler = async (): Promise<boolean> => {
|
||||||
|
// Auth check for API routes
|
||||||
|
const bearer = extractBearer(req);
|
||||||
|
const isApiAuthed =
|
||||||
|
(bearer && validateToken(bearer, token)) ||
|
||||||
|
(bearer ? !!(await validateBearer(bearer).catch(() => null)) : false);
|
||||||
|
|
||||||
|
if (!isApiAuthed) return false;
|
||||||
|
|
||||||
|
// GET /health
|
||||||
|
if (pathname === "/health" && req.method === "GET") {
|
||||||
|
await handleHealth(req, res);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// /sessions — list + create
|
||||||
|
if (pathname === "/sessions") {
|
||||||
|
await handleSessions(req, res);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// /sessions/:id — PATCH, DELETE
|
||||||
|
const sessMatch = pathname.match(/^\/sessions\/([^/]+)$/);
|
||||||
|
if (sessMatch) {
|
||||||
|
await handleSessions(req, res, decodeURIComponent(sessMatch[1]));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// /sessions/:id/thumbnail
|
||||||
|
const thumbMatch = pathname.match(/^\/sessions\/([^/]+)\/thumbnail$/);
|
||||||
|
if (thumbMatch) {
|
||||||
|
await handleSessions(
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
decodeURIComponent(thumbMatch[1]),
|
||||||
|
"thumbnail",
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// /sessions/:id/commands
|
||||||
|
const cmdMatch = pathname.match(/^\/sessions\/([^/]+)\/commands$/);
|
||||||
|
if (cmdMatch) {
|
||||||
|
await handleCommands(req, res, decodeURIComponent(cmdMatch[1]), pi);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// /sessions/:id/input
|
||||||
|
const inputMatch = pathname.match(/^\/sessions\/([^/]+)\/input$/);
|
||||||
|
if (inputMatch) {
|
||||||
|
await handleInput(req, res, decodeURIComponent(inputMatch[1]));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
asyncHandler()
|
||||||
|
.then((handled) => {
|
||||||
|
if (!handled) {
|
||||||
|
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||||
|
res.end("Not found");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
||||||
|
res.end(err.message);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── WebSocket server ────────────────────────────────────────────────────
|
// ── WebSocket server ────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -5,30 +5,37 @@
|
||||||
* based on the request path and session/topic. Non-matching paths are
|
* based on the request path and session/topic. Non-matching paths are
|
||||||
* destroyed immediately.
|
* destroyed immediately.
|
||||||
*
|
*
|
||||||
* Current routes (all LEGACY — browser HTML client):
|
* Routes:
|
||||||
* /ws → legacy browser client WebSocket endpoint
|
* /ws — LEGACY browser HTML client WebSocket endpoint
|
||||||
*
|
* /sessions/:id/stream — binary ANSI stream per tmux session (T-1.5)
|
||||||
* Future routes (T-1.5):
|
|
||||||
* /sessions/:id/stream → binary ANSI stream per tmux session
|
|
||||||
*
|
|
||||||
* T-1.5 will extend createUpgradeHandler to accept a session registry and
|
|
||||||
* dispatch to per-session stream handlers.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { IncomingMessage } from "node:http";
|
import type { IncomingMessage } from "node:http";
|
||||||
import type { Socket } from "node:net";
|
import type { Socket } from "node:net";
|
||||||
|
import { handleSideUpgrade, type SideRouteOptions } from "./routes/side.js";
|
||||||
|
import {
|
||||||
|
handleStreamUpgrade,
|
||||||
|
type StreamRouteOptions,
|
||||||
|
} from "./routes/stream.js";
|
||||||
import type { WsClient, WsServer } from "./types.js";
|
import type { WsClient, WsServer } from "./types.js";
|
||||||
|
|
||||||
|
export interface UpgradeHandlerOptions {
|
||||||
|
wss: WsServer;
|
||||||
|
isAuthenticated: (req: IncomingMessage) => boolean;
|
||||||
|
stream: Omit<StreamRouteOptions, "wss" | "isAuthenticated">;
|
||||||
|
side: Omit<SideRouteOptions, "wss" | "isAuthenticated">;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the HTTP `upgrade` event handler.
|
* Create the HTTP `upgrade` event handler.
|
||||||
*
|
*
|
||||||
* @param wss - WebSocket server instance.
|
* @param opts - Handler options including wss, auth predicate, and stream config.
|
||||||
* @param isAuthenticated - Predicate that checks token/session on the request.
|
* @returns A handler suitable for `httpServer.on("upgrade", handler)`.
|
||||||
* @returns A handler suitable for `httpServer.on("upgrade", handler)`.
|
|
||||||
*/
|
*/
|
||||||
export function createUpgradeHandler(
|
export function createUpgradeHandler(
|
||||||
wss: WsServer,
|
wss: WsServer,
|
||||||
isAuthenticated: (req: IncomingMessage) => boolean,
|
isAuthenticated: (req: IncomingMessage) => boolean,
|
||||||
|
streamOpts?: Omit<StreamRouteOptions, "wss" | "isAuthenticated">,
|
||||||
): (request: IncomingMessage, socket: Socket, head: Buffer) => void {
|
): (request: IncomingMessage, socket: Socket, head: Buffer) => void {
|
||||||
return (request: IncomingMessage, socket: Socket, head: Buffer): void => {
|
return (request: IncomingMessage, socket: Socket, head: Buffer): void => {
|
||||||
const url = new URL(request.url ?? "/", "http://localhost");
|
const url = new URL(request.url ?? "/", "http://localhost");
|
||||||
|
|
@ -46,6 +53,39 @@ export function createUpgradeHandler(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /sessions/:id/stream
|
||||||
|
const streamMatch = url.pathname.match(/^\/sessions\/([^/]+)\/stream$/);
|
||||||
|
if (streamMatch) {
|
||||||
|
if (!isAuthenticated(request)) {
|
||||||
|
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sessionId = decodeURIComponent(streamMatch[1]);
|
||||||
|
handleStreamUpgrade(sessionId, request, socket, head, {
|
||||||
|
wss,
|
||||||
|
isAuthenticated,
|
||||||
|
...streamOpts,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// /sessions/:id/side
|
||||||
|
const sideMatch = url.pathname.match(/^\/sessions\/([^/]+)\/side$/);
|
||||||
|
if (sideMatch) {
|
||||||
|
if (!isAuthenticated(request)) {
|
||||||
|
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sessionId = decodeURIComponent(sideMatch[1]);
|
||||||
|
handleSideUpgrade(sessionId, request, socket, head, {
|
||||||
|
wss,
|
||||||
|
isAuthenticated,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Unknown upgrade path — reject
|
// Unknown upgrade path — reject
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
/**
|
||||||
|
* Shared HTTP server utilities for route handlers.
|
||||||
|
* Owner: T-1.5
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
|
||||||
|
/** Read the full request body as a string. */
|
||||||
|
export function readBody(req: IncomingMessage): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
||||||
|
req.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send a JSON response. */
|
||||||
|
export function sendJson(
|
||||||
|
res: ServerResponse,
|
||||||
|
status: number,
|
||||||
|
body: unknown,
|
||||||
|
): void {
|
||||||
|
const payload = JSON.stringify(body);
|
||||||
|
res.writeHead(status, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Content-Length": Buffer.byteLength(payload),
|
||||||
|
});
|
||||||
|
res.end(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract a path segment by index (0-based, after splitting on '/'). */
|
||||||
|
export function pathSegment(url: string, index: number): string | undefined {
|
||||||
|
return url.split("?")[0].split("/").filter(Boolean)[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse bearer token from Authorization header or ?token= query param.
|
||||||
|
* Returns the raw token string or null.
|
||||||
|
*/
|
||||||
|
export function extractBearer(req: IncomingMessage): string | null {
|
||||||
|
const auth = req.headers["authorization"];
|
||||||
|
if (auth?.startsWith("Bearer ")) return auth.slice(7).trim();
|
||||||
|
if (auth?.startsWith("bearer ")) return auth.slice(7).trim();
|
||||||
|
const url = new URL(req.url ?? "/", "http://localhost");
|
||||||
|
return url.searchParams.get("token");
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue