chore(remote-control): add Biome and fix all lint warnings
This commit is contained in:
parent
06fa1147f3
commit
82c463ec27
|
|
@ -0,0 +1,34 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.4.12/schema.json",
|
||||||
|
"vcs": {
|
||||||
|
"enabled": true,
|
||||||
|
"clientKind": "git",
|
||||||
|
"useIgnoreFile": true
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"ignoreUnknown": false
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "space"
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "double"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"assist": {
|
||||||
|
"enabled": true,
|
||||||
|
"actions": {
|
||||||
|
"source": {
|
||||||
|
"organizeImports": "on"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,34 +7,40 @@
|
||||||
import { randomBytes, timingSafeEqual } from "node:crypto";
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
export function generateToken(): string {
|
export function generateToken(): string {
|
||||||
return randomBytes(24).toString("base64url"); // 32 chars, URL-safe
|
return randomBytes(24).toString("base64url"); // 32 chars, URL-safe
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateToken(provided: string, expected: string): boolean {
|
export function validateToken(provided: string, expected: string): boolean {
|
||||||
const a = Buffer.from(provided);
|
const a = Buffer.from(provided);
|
||||||
const b = Buffer.from(expected);
|
const b = Buffer.from(expected);
|
||||||
if (a.length !== b.length) return false;
|
if (a.length !== b.length) return false;
|
||||||
return timingSafeEqual(a, b);
|
return timingSafeEqual(a, b);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Name of the cookie that grants access after initial token validation */
|
/** Name of the cookie that grants access after initial token validation */
|
||||||
export const SESSION_COOKIE = "pi_rc_session";
|
export const SESSION_COOKIE = "pi_rc_session";
|
||||||
|
|
||||||
export function generateSessionId(): string {
|
export function generateSessionId(): string {
|
||||||
return randomBytes(24).toString("base64url");
|
return randomBytes(24).toString("base64url");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseCookies(header: string | undefined): Record<string, string> {
|
export function parseCookies(
|
||||||
const cookies: Record<string, string> = {};
|
header: string | undefined,
|
||||||
if (!header) return cookies;
|
): Record<string, string> {
|
||||||
for (const pair of header.split(";")) {
|
const cookies: Record<string, string> = {};
|
||||||
const idx = pair.indexOf("=");
|
if (!header) return cookies;
|
||||||
if (idx < 0) continue;
|
for (const pair of header.split(";")) {
|
||||||
const name = pair.slice(0, idx).trim();
|
const idx = pair.indexOf("=");
|
||||||
const raw = pair.slice(idx + 1).trim();
|
if (idx < 0) continue;
|
||||||
let value = raw;
|
const name = pair.slice(0, idx).trim();
|
||||||
try { value = decodeURIComponent(raw); } catch { /* keep raw */ }
|
const raw = pair.slice(idx + 1).trim();
|
||||||
cookies[name] = value;
|
let value = raw;
|
||||||
}
|
try {
|
||||||
return cookies;
|
value = decodeURIComponent(raw);
|
||||||
|
} catch {
|
||||||
|
/* keep raw */
|
||||||
|
}
|
||||||
|
cookies[name] = value;
|
||||||
|
}
|
||||||
|
return cookies;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,94 +13,115 @@ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||||
const REMOTE_CONTROL_CONFIG_FILE = "remote-control.json";
|
const REMOTE_CONTROL_CONFIG_FILE = "remote-control.json";
|
||||||
|
|
||||||
export interface RemoteControlConfig {
|
export interface RemoteControlConfig {
|
||||||
publicBaseUrl?: string;
|
publicBaseUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAgentDir(): string {
|
function getAgentDir(): string {
|
||||||
const envCandidates = ["PI_CODING_AGENT_DIR", "TAU_CODING_AGENT_DIR"];
|
const envCandidates = ["PI_CODING_AGENT_DIR", "TAU_CODING_AGENT_DIR"];
|
||||||
let envDir: string | undefined;
|
let envDir: string | undefined;
|
||||||
for (const key of envCandidates) {
|
for (const key of envCandidates) {
|
||||||
if (process.env[key]) {
|
if (process.env[key]) {
|
||||||
envDir = process.env[key];
|
envDir = process.env[key];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!envDir) {
|
if (!envDir) {
|
||||||
for (const [key, value] of Object.entries(process.env)) {
|
for (const [key, value] of Object.entries(process.env)) {
|
||||||
if (key.endsWith("_CODING_AGENT_DIR") && value) {
|
if (key.endsWith("_CODING_AGENT_DIR") && value) {
|
||||||
envDir = value;
|
envDir = value;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (envDir === "~") return os.homedir();
|
if (envDir === "~") return os.homedir();
|
||||||
if (envDir?.startsWith("~/")) return path.join(os.homedir(), envDir.slice(2));
|
if (envDir?.startsWith("~/")) return path.join(os.homedir(), envDir.slice(2));
|
||||||
return envDir ?? path.join(os.homedir(), ".pi", "agent");
|
return envDir ?? path.join(os.homedir(), ".pi", "agent");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRemoteControlConfigPath(): string {
|
function getRemoteControlConfigPath(): string {
|
||||||
return path.join(getAgentDir(), REMOTE_CONTROL_CONFIG_FILE);
|
return path.join(getAgentDir(), REMOTE_CONTROL_CONFIG_FILE);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readRemoteControlConfig(): Promise<RemoteControlConfig> {
|
export async function readRemoteControlConfig(): Promise<RemoteControlConfig> {
|
||||||
try {
|
try {
|
||||||
const raw = await fs.readFile(getRemoteControlConfigPath(), "utf8");
|
const raw = await fs.readFile(getRemoteControlConfigPath(), "utf8");
|
||||||
const parsed = JSON.parse(raw) as RemoteControlConfig;
|
const parsed = JSON.parse(raw) as RemoteControlConfig;
|
||||||
if (!parsed || typeof parsed !== "object") return {};
|
if (!parsed || typeof parsed !== "object") return {};
|
||||||
return parsed;
|
return parsed;
|
||||||
} catch {
|
} catch {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeRemoteControlConfig(config: RemoteControlConfig): Promise<void> {
|
async function writeRemoteControlConfig(
|
||||||
const configPath = getRemoteControlConfigPath();
|
config: RemoteControlConfig,
|
||||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
): Promise<void> {
|
||||||
await fs.writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
|
const configPath = getRemoteControlConfigPath();
|
||||||
|
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
configPath,
|
||||||
|
`${JSON.stringify(config, null, 2)}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizePublicBaseUrl(value: string): string {
|
export function normalizePublicBaseUrl(value: string): string {
|
||||||
const parsed = new URL(value.trim());
|
const parsed = new URL(value.trim());
|
||||||
parsed.username = "";
|
parsed.username = "";
|
||||||
parsed.password = "";
|
parsed.password = "";
|
||||||
parsed.pathname = "";
|
parsed.pathname = "";
|
||||||
parsed.search = "";
|
parsed.search = "";
|
||||||
parsed.hash = "";
|
parsed.hash = "";
|
||||||
return parsed.toString().replace(/\/+$/, "");
|
return parsed.toString().replace(/\/+$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildRemoteControlUrl(publicBaseUrl: string, port: number, token: string): string {
|
export function buildRemoteControlUrl(
|
||||||
const parsed = new URL(normalizePublicBaseUrl(publicBaseUrl));
|
publicBaseUrl: string,
|
||||||
if (parsed.protocol === "http:") {
|
port: number,
|
||||||
parsed.port = String(port);
|
token: string,
|
||||||
}
|
): string {
|
||||||
parsed.searchParams.set("token", token);
|
const parsed = new URL(normalizePublicBaseUrl(publicBaseUrl));
|
||||||
return parsed.toString();
|
if (parsed.protocol === "http:") {
|
||||||
|
parsed.port = String(port);
|
||||||
|
}
|
||||||
|
parsed.searchParams.set("token", token);
|
||||||
|
return parsed.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function configureRemoteControlUI(ctx: ExtensionContext): Promise<void> {
|
export async function configureRemoteControlUI(
|
||||||
if (!ctx.hasUI) return;
|
ctx: ExtensionContext,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!ctx.hasUI) return;
|
||||||
|
|
||||||
const current = (await readRemoteControlConfig()).publicBaseUrl ?? "";
|
const current = (await readRemoteControlConfig()).publicBaseUrl ?? "";
|
||||||
const title = current
|
const title = current
|
||||||
? `Public base URL (current: ${current})`
|
? `Public base URL (current: ${current})`
|
||||||
: "Public base URL";
|
: "Public base URL";
|
||||||
const raw = await ctx.ui.input(title, "e.g. http://pi.myhost");
|
const raw = await ctx.ui.input(title, "e.g. http://pi.myhost");
|
||||||
if (raw === undefined) return;
|
if (raw === undefined) return;
|
||||||
|
|
||||||
let value: string;
|
let value: string;
|
||||||
try {
|
try {
|
||||||
value = normalizePublicBaseUrl(raw);
|
value = normalizePublicBaseUrl(raw);
|
||||||
} catch {
|
} catch {
|
||||||
ctx.ui.notify("Public base URL must be a valid http:// or https:// URL", "warning");
|
ctx.ui.notify(
|
||||||
return;
|
"Public base URL must be a valid http:// or https:// URL",
|
||||||
}
|
"warning",
|
||||||
if (!["http:", "https:"].includes(new URL(value).protocol)) {
|
);
|
||||||
ctx.ui.notify("Public base URL must start with http:// or https://", "warning");
|
return;
|
||||||
return;
|
}
|
||||||
}
|
if (!["http:", "https:"].includes(new URL(value).protocol)) {
|
||||||
|
ctx.ui.notify(
|
||||||
|
"Public base URL must start with http:// or https://",
|
||||||
|
"warning",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await writeRemoteControlConfig({ publicBaseUrl: value });
|
await writeRemoteControlConfig({ publicBaseUrl: value });
|
||||||
ctx.ui.notify(`Saved remote-control URL to ${getRemoteControlConfigPath()}`, "info");
|
ctx.ui.notify(
|
||||||
|
`Saved remote-control URL to ${getRemoteControlConfigPath()}`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function buildHTML(nonce: string): string {
|
export function buildHTML(nonce: string): string {
|
||||||
return /* html */ `<!DOCTYPE html>
|
return /* html */ `<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
|
|
|
||||||
|
|
@ -11,246 +11,273 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
import type {
|
||||||
|
ExtensionAPI,
|
||||||
|
ExtensionContext,
|
||||||
|
} from "@mariozechner/pi-coding-agent";
|
||||||
import { DynamicBorder, keyHint } from "@mariozechner/pi-coding-agent";
|
import { DynamicBorder, keyHint } from "@mariozechner/pi-coding-agent";
|
||||||
import { Container, Text } from "@mariozechner/pi-tui";
|
import { Container, Text } from "@mariozechner/pi-tui";
|
||||||
import {
|
import {
|
||||||
readRemoteControlConfig,
|
buildRemoteControlUrl,
|
||||||
buildRemoteControlUrl,
|
configureRemoteControlUI,
|
||||||
configureRemoteControlUI,
|
readRemoteControlConfig,
|
||||||
} from "./config.js";
|
} from "./config.js";
|
||||||
import { serializeMessage } from "./messages.js";
|
import { type RawMessage, serializeMessage } from "./messages.js";
|
||||||
import { type RemoteServer, startServer } from "./server.js";
|
import { type RemoteServer, startServer } from "./server.js";
|
||||||
|
|
||||||
// ── Extension entry point ────────────────────────────────────────────────────
|
// ── Extension entry point ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const _require = createRequire(import.meta.url);
|
const _require = createRequire(import.meta.url);
|
||||||
const QRCode = _require("qrcode") as { toString: (text: string, opts: any) => Promise<string> };
|
const QRCode = _require("qrcode") as {
|
||||||
|
toString: (text: string, opts: Record<string, unknown>) => Promise<string>;
|
||||||
|
};
|
||||||
|
|
||||||
export default function remoteControl(pi: ExtensionAPI) {
|
export default function remoteControl(pi: ExtensionAPI) {
|
||||||
let server: RemoteServer | undefined;
|
let server: RemoteServer | undefined;
|
||||||
let pendingSyncTimer: ReturnType<typeof setTimeout> | undefined;
|
let pendingSyncTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
function scheduleSync(ctx: ExtensionContext): void {
|
function scheduleSync(ctx: ExtensionContext): void {
|
||||||
if (pendingSyncTimer) clearTimeout(pendingSyncTimer);
|
if (pendingSyncTimer) clearTimeout(pendingSyncTimer);
|
||||||
pendingSyncTimer = setTimeout(() => {
|
pendingSyncTimer = setTimeout(() => {
|
||||||
pendingSyncTimer = undefined;
|
pendingSyncTimer = undefined;
|
||||||
server?.sync(ctx);
|
server?.sync(ctx);
|
||||||
updateStatus(ctx);
|
updateStatus(ctx);
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── CLI flag ──────────────────────────────────────────────────────────────
|
// ── CLI flag ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
pi.registerFlag("remote-control", {
|
pi.registerFlag("remote-control", {
|
||||||
description: "Start the remote-control server automatically on session start",
|
description:
|
||||||
type: "boolean",
|
"Start the remote-control server automatically on session start",
|
||||||
default: false,
|
type: "boolean",
|
||||||
});
|
default: false,
|
||||||
|
});
|
||||||
|
|
||||||
// ── Status indicator ──────────────────────────────────────────────────────
|
// ── Status indicator ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function updateStatus(ctx: ExtensionContext): void {
|
function updateStatus(ctx: ExtensionContext): void {
|
||||||
if (!ctx.hasUI || !server) return;
|
if (!ctx.hasUI || !server) return;
|
||||||
const clients = server.clientCount();
|
const clients = server.clientCount();
|
||||||
const label = clients > 0 ? `remote:${clients}` : "remote:on";
|
const label = clients > 0 ? `remote:${clients}` : "remote:on";
|
||||||
ctx.ui.setStatus("remote-control", ctx.ui.theme.fg("accent", label));
|
ctx.ui.setStatus("remote-control", ctx.ui.theme.fg("accent", label));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
pi.on("session_start", async (_event, ctx) => {
|
pi.on("session_start", async (_event, ctx) => {
|
||||||
// Clear any stale status from before a reload
|
// Clear any stale status from before a reload
|
||||||
if (ctx.hasUI) ctx.ui.setStatus("remote-control", undefined);
|
if (ctx.hasUI) ctx.ui.setStatus("remote-control", undefined);
|
||||||
|
|
||||||
if (pi.getFlag("remote-control") !== true) return;
|
if (pi.getFlag("remote-control") !== true) return;
|
||||||
|
|
||||||
const config = await readRemoteControlConfig();
|
const config = await readRemoteControlConfig();
|
||||||
const publicBaseUrl = config.publicBaseUrl?.trim();
|
const publicBaseUrl = config.publicBaseUrl?.trim();
|
||||||
if (!publicBaseUrl) {
|
if (!publicBaseUrl) {
|
||||||
if (ctx.hasUI) {
|
if (ctx.hasUI) {
|
||||||
ctx.ui.notify(
|
ctx.ui.notify(
|
||||||
"--remote-control: no publicBaseUrl configured. Run /remote-control config first.",
|
"--remote-control: no publicBaseUrl configured. Run /remote-control config first.",
|
||||||
"warning",
|
"warning",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
server = await startServer(pi, ctx);
|
server = await startServer(pi, ctx);
|
||||||
server.onClientChange(() => updateStatus(ctx));
|
server.onClientChange(() => updateStatus(ctx));
|
||||||
const url = buildRemoteControlUrl(publicBaseUrl, server.port, server.token);
|
const url = buildRemoteControlUrl(publicBaseUrl, server.port, server.token);
|
||||||
|
|
||||||
if (ctx.hasUI) {
|
if (ctx.hasUI) {
|
||||||
ctx.ui.notify(`Remote-control started: ${url}`, "info");
|
ctx.ui.notify(`Remote-control started: ${url}`, "info");
|
||||||
}
|
}
|
||||||
updateStatus(ctx);
|
updateStatus(ctx);
|
||||||
});
|
});
|
||||||
|
|
||||||
pi.on("session_switch", async (_event, ctx) => {
|
pi.on("session_switch", async (_event, ctx) => {
|
||||||
scheduleSync(ctx);
|
scheduleSync(ctx);
|
||||||
});
|
});
|
||||||
|
|
||||||
pi.on("model_select", async (_event, ctx) => {
|
pi.on("model_select", async (_event, ctx) => {
|
||||||
if (!ctx.isIdle()) return;
|
if (!ctx.isIdle()) return;
|
||||||
scheduleSync(ctx);
|
scheduleSync(ctx);
|
||||||
});
|
});
|
||||||
|
|
||||||
pi.on("session_shutdown", async () => {
|
pi.on("session_shutdown", async () => {
|
||||||
if (pendingSyncTimer) {
|
if (pendingSyncTimer) {
|
||||||
clearTimeout(pendingSyncTimer);
|
clearTimeout(pendingSyncTimer);
|
||||||
pendingSyncTimer = undefined;
|
pendingSyncTimer = undefined;
|
||||||
}
|
}
|
||||||
if (server) {
|
if (server) {
|
||||||
await server.stop();
|
await server.stop();
|
||||||
server = undefined;
|
server = undefined;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Event bridge: pi → clients ────────────────────────────────────────────
|
// ── Event bridge: pi → clients ────────────────────────────────────────────
|
||||||
|
|
||||||
pi.on("agent_start", async (_event, ctx) => {
|
pi.on("agent_start", async (_event, ctx) => {
|
||||||
server?.broadcast({ type: "agent_start" });
|
server?.broadcast({ type: "agent_start" });
|
||||||
updateStatus(ctx);
|
updateStatus(ctx);
|
||||||
});
|
});
|
||||||
|
|
||||||
pi.on("agent_end", async (_event, ctx) => {
|
pi.on("agent_end", async (_event, ctx) => {
|
||||||
server?.broadcast({ type: "agent_end" });
|
server?.broadcast({ type: "agent_end" });
|
||||||
updateStatus(ctx);
|
updateStatus(ctx);
|
||||||
});
|
});
|
||||||
|
|
||||||
pi.on("message_update", async (event) => {
|
pi.on("message_update", async (event) => {
|
||||||
const m = serializeMessage("pending", (event as any).message);
|
const m = serializeMessage(
|
||||||
if (m) server?.broadcast({ type: "message_update", message: m });
|
"pending",
|
||||||
});
|
(event as { message: RawMessage }).message,
|
||||||
|
);
|
||||||
|
if (m) server?.broadcast({ type: "message_update", message: m });
|
||||||
|
});
|
||||||
|
|
||||||
pi.on("message_end", async (event, ctx) => {
|
pi.on("message_end", async (event, ctx) => {
|
||||||
// Use the last branch entry to get the committed entry ID
|
// Use the last branch entry to get the committed entry ID
|
||||||
const branch = ctx.sessionManager.getBranch();
|
const branch = ctx.sessionManager.getBranch();
|
||||||
const last = branch[branch.length - 1];
|
const last = branch[branch.length - 1];
|
||||||
const id = last?.id ?? `msg_${Date.now()}`;
|
const id = last?.id ?? `msg_${Date.now()}`;
|
||||||
const m = serializeMessage(id, (event as any).message);
|
const m = serializeMessage(id, (event as { message: RawMessage }).message);
|
||||||
if (m) server?.broadcast({ type: "message_end", message: m });
|
if (m) server?.broadcast({ type: "message_end", message: m });
|
||||||
});
|
});
|
||||||
|
|
||||||
pi.on("tool_execution_start", async (event) => {
|
pi.on("tool_execution_start", async (event) => {
|
||||||
server?.broadcast({
|
server?.broadcast({
|
||||||
type: "tool_start",
|
type: "tool_start",
|
||||||
toolCallId: event.toolCallId,
|
toolCallId: event.toolCallId,
|
||||||
toolName: event.toolName,
|
toolName: event.toolName,
|
||||||
args: event.args,
|
args: event.args,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
pi.on("tool_execution_end", async (event) => {
|
pi.on("tool_execution_end", async (event) => {
|
||||||
const result = event.result as any;
|
type TextContent = { type: string; text: string };
|
||||||
const resultText = Array.isArray(result?.content)
|
type ToolResult = { content?: TextContent[] } | string;
|
||||||
? result.content
|
const result = event.result as ToolResult;
|
||||||
.filter((c: any) => c.type === "text")
|
const content = typeof result === "object" ? result.content : undefined;
|
||||||
.map((c: any) => c.text)
|
const resultText = Array.isArray(content)
|
||||||
.join("")
|
? content
|
||||||
: typeof result === "string"
|
.filter((c) => c.type === "text")
|
||||||
? result
|
.map((c) => c.text)
|
||||||
: "";
|
.join("")
|
||||||
server?.broadcast({
|
: typeof result === "string"
|
||||||
type: "tool_end",
|
? result
|
||||||
toolCallId: event.toolCallId,
|
: "";
|
||||||
result: resultText,
|
server?.broadcast({
|
||||||
isError: event.isError,
|
type: "tool_end",
|
||||||
});
|
toolCallId: event.toolCallId,
|
||||||
});
|
result: resultText,
|
||||||
|
isError: event.isError,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── /remote-control command ───────────────────────────────────────────────
|
// ── /remote-control command ───────────────────────────────────────────────
|
||||||
|
|
||||||
async function showConnectionInfo(ctx: ExtensionContext): Promise<void> {
|
async function showConnectionInfo(ctx: ExtensionContext): Promise<void> {
|
||||||
if (!server) return;
|
if (!server) return;
|
||||||
|
|
||||||
const config = await readRemoteControlConfig();
|
const config = await readRemoteControlConfig();
|
||||||
const publicBaseUrl = config.publicBaseUrl?.trim();
|
const publicBaseUrl = config.publicBaseUrl?.trim();
|
||||||
if (!publicBaseUrl) return;
|
if (!publicBaseUrl) return;
|
||||||
|
|
||||||
const url = buildRemoteControlUrl(publicBaseUrl, server.port, server.token);
|
const url = buildRemoteControlUrl(publicBaseUrl, server.port, server.token);
|
||||||
|
|
||||||
// Generate QR code
|
// Generate QR code
|
||||||
let qrLines: string[] = [];
|
let qrLines: string[] = [];
|
||||||
try {
|
try {
|
||||||
const qr = await QRCode.toString(url, { type: "utf8", margin: 2 });
|
const qr = await QRCode.toString(url, { type: "utf8", margin: 2 });
|
||||||
qrLines = qr.trimEnd().split("\n");
|
qrLines = qr.trimEnd().split("\n");
|
||||||
} catch {
|
} catch {
|
||||||
// QR code generation failed
|
// QR code generation failed
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show in editor area — use confirm/cancel to dismiss
|
// Show in editor area — use confirm/cancel to dismiss
|
||||||
await ctx.ui.custom<void>((_tui, theme, kb, done) => {
|
await ctx.ui.custom<void>((_tui, theme, kb, done) => {
|
||||||
const container = new Container();
|
const container = new Container();
|
||||||
container.addChild(new DynamicBorder((s) => theme.fg("accent", s)));
|
container.addChild(new DynamicBorder((s) => theme.fg("accent", s)));
|
||||||
container.addChild(new Text(
|
container.addChild(
|
||||||
theme.fg("accent", theme.bold(" Remote-control")) +
|
new Text(
|
||||||
" " +
|
theme.fg("accent", theme.bold(" Remote-control")) +
|
||||||
keyHint("tui.select.confirm", "close") +
|
" " +
|
||||||
theme.fg("muted", " · ") +
|
keyHint("tui.select.confirm", "close") +
|
||||||
keyHint("tui.select.cancel", "cancel"),
|
theme.fg("muted", " · ") +
|
||||||
1, 0,
|
keyHint("tui.select.cancel", "cancel"),
|
||||||
));
|
1,
|
||||||
container.addChild(new Text("\n" + qrLines.map((l) => ` ${l}`).join("\n") + "\n", 1, 0));
|
0,
|
||||||
container.addChild(new Text(theme.fg("accent", url), 1, 0));
|
),
|
||||||
container.addChild(new DynamicBorder((s) => theme.fg("accent", s)));
|
);
|
||||||
|
container.addChild(
|
||||||
|
new Text(`\n${qrLines.map((l) => ` ${l}`).join("\n")}\n`, 1, 0),
|
||||||
|
);
|
||||||
|
container.addChild(new Text(theme.fg("accent", url), 1, 0));
|
||||||
|
container.addChild(new DynamicBorder((s) => theme.fg("accent", s)));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
render: (w) => container.render(w),
|
render: (w) => container.render(w),
|
||||||
invalidate: () => container.invalidate(),
|
invalidate: () => container.invalidate(),
|
||||||
handleInput: (data) => {
|
handleInput: (data) => {
|
||||||
if (kb.matches(data, "tui.select.cancel") || kb.matches(data, "tui.select.confirm")) done();
|
if (
|
||||||
},
|
kb.matches(data, "tui.select.cancel") ||
|
||||||
};
|
kb.matches(data, "tui.select.confirm")
|
||||||
});
|
)
|
||||||
}
|
done();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
pi.registerCommand("remote-control", {
|
pi.registerCommand("remote-control", {
|
||||||
description: "Remote control — start/stop server, configure, show connection info",
|
description:
|
||||||
handler: async (args, ctx) => {
|
"Remote control — start/stop server, configure, show connection info",
|
||||||
if (!ctx.hasUI) return;
|
handler: async (_args, ctx) => {
|
||||||
|
if (!ctx.hasUI) return;
|
||||||
|
|
||||||
const isRunning = !!server;
|
const isRunning = !!server;
|
||||||
const config = await readRemoteControlConfig();
|
const config = await readRemoteControlConfig();
|
||||||
const currentUrl = config.publicBaseUrl?.trim();
|
const currentUrl = config.publicBaseUrl?.trim();
|
||||||
|
|
||||||
const configLabel = currentUrl ? `Configure URL (${currentUrl})` : "Configure URL (not set)";
|
const configLabel = currentUrl
|
||||||
const menuItems = [
|
? `Configure URL (${currentUrl})`
|
||||||
isRunning ? "Turn off" : "Turn on",
|
: "Configure URL (not set)";
|
||||||
configLabel,
|
const menuItems = [
|
||||||
...(isRunning ? ["Status"] : []),
|
isRunning ? "Turn off" : "Turn on",
|
||||||
];
|
configLabel,
|
||||||
|
...(isRunning ? ["Status"] : []),
|
||||||
|
];
|
||||||
|
|
||||||
const choice = await ctx.ui.select("Remote control", menuItems);
|
const choice = await ctx.ui.select("Remote control", menuItems);
|
||||||
if (choice === undefined) return;
|
if (choice === undefined) return;
|
||||||
|
|
||||||
if (choice === "Turn on") {
|
if (choice === "Turn on") {
|
||||||
const publicBaseUrl = currentUrl;
|
const publicBaseUrl = currentUrl;
|
||||||
if (!publicBaseUrl) {
|
if (!publicBaseUrl) {
|
||||||
ctx.ui.notify("Set the public URL first — opening config…", "warning");
|
ctx.ui.notify(
|
||||||
await configureRemoteControlUI(ctx);
|
"Set the public URL first — opening config…",
|
||||||
// Re-check after config
|
"warning",
|
||||||
const updated = await readRemoteControlConfig();
|
);
|
||||||
if (!updated.publicBaseUrl?.trim()) return;
|
await configureRemoteControlUI(ctx);
|
||||||
}
|
// Re-check after config
|
||||||
server = await startServer(pi, ctx);
|
const updated = await readRemoteControlConfig();
|
||||||
server.onClientChange(() => updateStatus(ctx));
|
if (!updated.publicBaseUrl?.trim()) return;
|
||||||
updateStatus(ctx);
|
}
|
||||||
ctx.ui.notify("Remote-control server started", "info");
|
server = await startServer(pi, ctx);
|
||||||
await showConnectionInfo(ctx);
|
server.onClientChange(() => updateStatus(ctx));
|
||||||
} else if (choice === "Turn off") {
|
updateStatus(ctx);
|
||||||
if (server) {
|
ctx.ui.notify("Remote-control server started", "info");
|
||||||
await server.stop();
|
await showConnectionInfo(ctx);
|
||||||
server = undefined;
|
} else if (choice === "Turn off") {
|
||||||
ctx.ui.setStatus("remote-control", undefined);
|
if (server) {
|
||||||
ctx.ui.notify("Remote-control server stopped", "info");
|
await server.stop();
|
||||||
}
|
server = undefined;
|
||||||
} else if (choice === configLabel) {
|
ctx.ui.setStatus("remote-control", undefined);
|
||||||
await configureRemoteControlUI(ctx);
|
ctx.ui.notify("Remote-control server stopped", "info");
|
||||||
} else if (choice === "Status") {
|
}
|
||||||
await showConnectionInfo(ctx);
|
} else if (choice === configLabel) {
|
||||||
}
|
await configureRemoteControlUI(ctx);
|
||||||
},
|
} else if (choice === "Status") {
|
||||||
});
|
await showConnectionInfo(ctx);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,104 +7,127 @@
|
||||||
|
|
||||||
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
export interface RenderMsg {
|
interface RawContent {
|
||||||
id: string; // SessionEntry id, or "pending" while streaming
|
type: string;
|
||||||
role: "user" | "assistant" | "tool_result";
|
text?: string;
|
||||||
text: string;
|
id?: string;
|
||||||
toolCalls?: Array<{ id: string; name: string; args: string }>;
|
name?: string;
|
||||||
toolName?: string;
|
arguments?: unknown;
|
||||||
toolCallId?: string;
|
|
||||||
isError?: boolean;
|
|
||||||
model?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serializeMessage(id: string, msg: any): RenderMsg | null {
|
export interface RawMessage {
|
||||||
if (msg.role === "user") {
|
role: string;
|
||||||
const text =
|
content: string | RawContent[];
|
||||||
typeof msg.content === "string"
|
model?: string;
|
||||||
? msg.content
|
toolName?: string;
|
||||||
: (msg.content as any[])
|
toolCallId?: string;
|
||||||
.filter((c) => c.type === "text")
|
isError?: boolean;
|
||||||
.map((c) => c.text)
|
}
|
||||||
.join("");
|
|
||||||
return { id, role: "user", text };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.role === "assistant") {
|
export interface RenderMsg {
|
||||||
const text = (msg.content as any[])
|
id: string; // SessionEntry id, or "pending" while streaming
|
||||||
.filter((c) => c.type === "text")
|
role: "user" | "assistant" | "tool_result";
|
||||||
.map((c) => c.text)
|
text: string;
|
||||||
.join("");
|
toolCalls?: Array<{ id: string; name: string; args: string }>;
|
||||||
const toolCalls = (msg.content as any[])
|
toolName?: string;
|
||||||
.filter((c) => c.type === "toolCall")
|
toolCallId?: string;
|
||||||
.map((c) => ({
|
isError?: boolean;
|
||||||
id: c.id,
|
model?: string;
|
||||||
name: c.name,
|
}
|
||||||
args: JSON.stringify(c.arguments, null, 2),
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
role: "assistant",
|
|
||||||
text,
|
|
||||||
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
||||||
model: msg.model,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.role === "toolResult") {
|
export function serializeMessage(
|
||||||
const text = (msg.content as any[])
|
id: string,
|
||||||
.filter((c) => c.type === "text")
|
msg: RawMessage,
|
||||||
.map((c) => c.text)
|
): RenderMsg | null {
|
||||||
.join("");
|
if (msg.role === "user") {
|
||||||
return {
|
const text =
|
||||||
id,
|
typeof msg.content === "string"
|
||||||
role: "tool_result",
|
? msg.content
|
||||||
text,
|
: (msg.content as RawContent[])
|
||||||
toolName: msg.toolName,
|
.filter((c) => c.type === "text")
|
||||||
toolCallId: msg.toolCallId,
|
.map((c) => c.text)
|
||||||
isError: msg.isError,
|
.join("");
|
||||||
};
|
return { id, role: "user", text };
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
if (msg.role === "assistant") {
|
||||||
|
const text = (msg.content as RawContent[])
|
||||||
|
.filter((c) => c.type === "text")
|
||||||
|
.map((c) => c.text)
|
||||||
|
.join("");
|
||||||
|
const toolCalls = (msg.content as RawContent[])
|
||||||
|
.filter((c) => c.type === "toolCall")
|
||||||
|
.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
args: JSON.stringify(c.arguments, null, 2),
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
role: "assistant",
|
||||||
|
text,
|
||||||
|
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
||||||
|
model: msg.model,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.role === "toolResult") {
|
||||||
|
const text = (msg.content as RawContent[])
|
||||||
|
.filter((c) => c.type === "text")
|
||||||
|
.map((c) => c.text)
|
||||||
|
.join("");
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
role: "tool_result",
|
||||||
|
text,
|
||||||
|
toolName: msg.toolName,
|
||||||
|
toolCallId: msg.toolCallId,
|
||||||
|
isError: msg.isError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBranchMessages(ctx: ExtensionContext): RenderMsg[] {
|
export function getBranchMessages(ctx: ExtensionContext): RenderMsg[] {
|
||||||
const branch = ctx.sessionManager.getBranch();
|
const branch = ctx.sessionManager.getBranch();
|
||||||
const out: RenderMsg[] = [];
|
const out: RenderMsg[] = [];
|
||||||
for (const entry of branch) {
|
for (const entry of branch) {
|
||||||
if (entry.type !== "message") continue;
|
if (entry.type !== "message") continue;
|
||||||
const m = serializeMessage(entry.id, (entry as any).message);
|
const m = serializeMessage(
|
||||||
if (m) out.push(m);
|
entry.id,
|
||||||
}
|
(entry as { id: string; type: string; message: RawMessage }).message,
|
||||||
return out;
|
);
|
||||||
|
if (m) out.push(m);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function abbreviateHome(p: string): string {
|
function abbreviateHome(p: string): string {
|
||||||
const home = process.env.HOME;
|
const home = process.env.HOME;
|
||||||
if (home && p === home) return "~";
|
if (home && p === home) return "~";
|
||||||
if (home && p.startsWith(home + "/")) return "~" + p.slice(home.length);
|
if (home && p.startsWith(`${home}/`)) return `~${p.slice(home.length)}`;
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSyncMessage(ctx: ExtensionContext): {
|
export function buildSyncMessage(ctx: ExtensionContext): {
|
||||||
type: "sync";
|
type: "sync";
|
||||||
messages: RenderMsg[];
|
messages: RenderMsg[];
|
||||||
state: {
|
state: {
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
model: string | undefined;
|
model: string | undefined;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
sessionName: string | undefined;
|
sessionName: string | undefined;
|
||||||
};
|
};
|
||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
type: "sync",
|
type: "sync",
|
||||||
messages: getBranchMessages(ctx),
|
messages: getBranchMessages(ctx),
|
||||||
state: {
|
state: {
|
||||||
isStreaming: !ctx.isIdle(),
|
isStreaming: !ctx.isIdle(),
|
||||||
model: ctx.model?.id,
|
model: ctx.model?.id,
|
||||||
cwd: abbreviateHome(ctx.cwd),
|
cwd: abbreviateHome(ctx.cwd),
|
||||||
sessionName: ctx.sessionManager.getSessionName(),
|
sessionName: ctx.sessionManager.getSessionName(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,260 +5,304 @@
|
||||||
* for real-time message streaming between the pi session and browser clients.
|
* for real-time message streaming between the pi session and browser clients.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import type { IncomingMessage } from "node:http";
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import { randomBytes } from "node:crypto";
|
import type { AddressInfo, Socket } from "node:net";
|
||||||
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
import type {
|
||||||
|
ExtensionAPI,
|
||||||
|
ExtensionContext,
|
||||||
|
} from "@mariozechner/pi-coding-agent";
|
||||||
import {
|
import {
|
||||||
generateToken,
|
generateSessionId,
|
||||||
validateToken,
|
generateToken,
|
||||||
SESSION_COOKIE,
|
parseCookies,
|
||||||
generateSessionId,
|
SESSION_COOKIE,
|
||||||
parseCookies,
|
validateToken,
|
||||||
} from "./auth.js";
|
} from "./auth.js";
|
||||||
import { buildSyncMessage } from "./messages.js";
|
|
||||||
import { buildHTML } from "./html.js";
|
import { buildHTML } from "./html.js";
|
||||||
|
import { buildSyncMessage } from "./messages.js";
|
||||||
|
|
||||||
|
interface WsClient {
|
||||||
|
readyState: number;
|
||||||
|
send(data: string): void;
|
||||||
|
terminate(): void;
|
||||||
|
on(event: "message", listener: (data: Buffer) => void): void;
|
||||||
|
on(event: "close" | "error", listener: () => void): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WsServer {
|
||||||
|
on(event: "connection", listener: (ws: WsClient) => void): void;
|
||||||
|
on(event: "error", listener: (err: Error) => void): void;
|
||||||
|
handleUpgrade(
|
||||||
|
request: IncomingMessage,
|
||||||
|
socket: Socket,
|
||||||
|
head: Buffer,
|
||||||
|
cb: (ws: WsClient) => void,
|
||||||
|
): void;
|
||||||
|
emit(event: string, ...args: unknown[]): void;
|
||||||
|
close(cb?: () => void): void;
|
||||||
|
}
|
||||||
|
|
||||||
// Load ws (bundled with pi) without needing @types/ws installed locally
|
// Load ws (bundled with pi) without needing @types/ws installed locally
|
||||||
const _require = createRequire(import.meta.url);
|
const _require = createRequire(import.meta.url);
|
||||||
const wsModule = _require("ws") as {
|
const wsModule = _require("ws") as {
|
||||||
WebSocketServer: new (opts: { noServer: boolean }) => any;
|
WebSocketServer: new (opts: { noServer: boolean }) => WsServer;
|
||||||
OPEN: number;
|
OPEN: number;
|
||||||
};
|
};
|
||||||
const { WebSocketServer, OPEN } = wsModule;
|
const { WebSocketServer, OPEN } = wsModule;
|
||||||
|
|
||||||
export interface RemoteServer {
|
export interface RemoteServer {
|
||||||
broadcast: (msg: object) => void;
|
broadcast: (msg: object) => void;
|
||||||
sync: (ctx: ExtensionContext) => void;
|
sync: (ctx: ExtensionContext) => void;
|
||||||
stop: () => Promise<void>;
|
stop: () => Promise<void>;
|
||||||
clientCount: () => number;
|
clientCount: () => number;
|
||||||
onClientChange: (cb: () => void) => void;
|
onClientChange: (cb: () => void) => void;
|
||||||
port: number;
|
port: number;
|
||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startServer(pi: ExtensionAPI, ctx: ExtensionContext): Promise<RemoteServer> {
|
export function startServer(
|
||||||
const clientChangeListeners: Array<() => void> = [];
|
pi: ExtensionAPI,
|
||||||
const clients = new Set<any>();
|
ctx: ExtensionContext,
|
||||||
const token = generateToken();
|
): Promise<RemoteServer> {
|
||||||
// Map of valid session IDs → expiry timestamp (ms since epoch)
|
const clientChangeListeners: Array<() => void> = [];
|
||||||
const SESSION_TTL_MS = 86_400_000; // 24 h — matches cookie Max-Age
|
const clients = new Set<WsClient>();
|
||||||
const validSessions = new Map<string, number>();
|
const token = generateToken();
|
||||||
const pruneExpiredSessions = (): void => {
|
// Map of valid session IDs → expiry timestamp (ms since epoch)
|
||||||
const now = Date.now();
|
const SESSION_TTL_MS = 86_400_000; // 24 h — matches cookie Max-Age
|
||||||
for (const [id, expiresAt] of validSessions) {
|
const validSessions = new Map<string, number>();
|
||||||
if (expiresAt <= now) validSessions.delete(id);
|
const pruneExpiredSessions = (): void => {
|
||||||
}
|
const now = Date.now();
|
||||||
};
|
for (const [id, expiresAt] of validSessions) {
|
||||||
|
if (expiresAt <= now) validSessions.delete(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/** Check if a request is authenticated (valid token query param OR valid session cookie) */
|
/** Check if a request is authenticated (valid token query param OR valid session cookie) */
|
||||||
function isAuthenticated(req: any): boolean {
|
function isAuthenticated(req: IncomingMessage): boolean {
|
||||||
// Check session cookie first
|
// Check session cookie first
|
||||||
const cookies = parseCookies(req.headers.cookie);
|
const cookies = parseCookies(req.headers.cookie);
|
||||||
const sessionId = cookies[SESSION_COOKIE];
|
const sessionId = cookies[SESSION_COOKIE];
|
||||||
const sessionExpiry = sessionId ? validSessions.get(sessionId) : undefined;
|
const sessionExpiry = sessionId ? validSessions.get(sessionId) : undefined;
|
||||||
if (sessionExpiry !== undefined && sessionExpiry > Date.now()) return true;
|
if (sessionExpiry !== undefined && sessionExpiry > Date.now()) return true;
|
||||||
|
|
||||||
// Check token query param
|
// Check token query param
|
||||||
const url = new URL(req.url ?? "/", "http://localhost");
|
const url = new URL(req.url ?? "/", "http://localhost");
|
||||||
const providedToken = url.searchParams.get("token");
|
const providedToken = url.searchParams.get("token");
|
||||||
if (providedToken && validateToken(providedToken, token)) return true;
|
if (providedToken && validateToken(providedToken, token)) return true;
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function broadcast(msg: object): void {
|
function broadcast(msg: object): void {
|
||||||
const data = JSON.stringify(msg);
|
const data = JSON.stringify(msg);
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
if (client.readyState === OPEN) {
|
if (client.readyState === OPEN) {
|
||||||
try {
|
try {
|
||||||
client.send(data);
|
client.send(data);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sync(currentCtx: ExtensionContext): void {
|
function sync(currentCtx: ExtensionContext): void {
|
||||||
broadcast(buildSyncMessage(currentCtx));
|
broadcast(buildSyncMessage(currentCtx));
|
||||||
}
|
}
|
||||||
|
|
||||||
const httpServer = createServer((req, res) => {
|
const httpServer = createServer((req, res) => {
|
||||||
const url = new URL(req.url ?? "/", "http://localhost");
|
const url = new URL(req.url ?? "/", "http://localhost");
|
||||||
const pathname = url.pathname;
|
const pathname = url.pathname;
|
||||||
|
|
||||||
if (pathname === "/" || pathname === "/index.html") {
|
if (pathname === "/" || pathname === "/index.html") {
|
||||||
// Check authentication
|
// Check authentication
|
||||||
const cookies = parseCookies(req.headers.cookie);
|
const cookies = parseCookies(req.headers.cookie);
|
||||||
const sc = cookies[SESSION_COOKIE];
|
const sc = cookies[SESSION_COOKIE];
|
||||||
const hasValidSession = sc !== undefined && (validSessions.get(sc) ?? 0) > Date.now();
|
const hasValidSession =
|
||||||
const providedToken = url.searchParams.get("token");
|
sc !== undefined && (validSessions.get(sc) ?? 0) > Date.now();
|
||||||
const hasValidToken = providedToken && validateToken(providedToken, token);
|
const providedToken = url.searchParams.get("token");
|
||||||
|
const hasValidToken =
|
||||||
|
providedToken && validateToken(providedToken, token);
|
||||||
|
|
||||||
if (!hasValidSession && !hasValidToken) {
|
if (!hasValidSession && !hasValidToken) {
|
||||||
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
|
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
|
||||||
res.end("Forbidden — valid token required. Use the URL shown in the pi terminal.");
|
res.end(
|
||||||
return;
|
"Forbidden — valid token required. Use the URL shown in the pi terminal.",
|
||||||
}
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If authenticated via token (first visit), issue a session cookie and redirect to clean URL
|
// If authenticated via token (first visit), issue a session cookie and redirect to clean URL
|
||||||
if (!hasValidSession && hasValidToken) {
|
if (!hasValidSession && hasValidToken) {
|
||||||
pruneExpiredSessions();
|
pruneExpiredSessions();
|
||||||
const sessionId = generateSessionId();
|
const sessionId = generateSessionId();
|
||||||
validSessions.set(sessionId, Date.now() + SESSION_TTL_MS);
|
validSessions.set(sessionId, Date.now() + SESSION_TTL_MS);
|
||||||
res.writeHead(302, {
|
res.writeHead(302, {
|
||||||
"Set-Cookie": `${SESSION_COOKIE}=${sessionId}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
|
"Set-Cookie": `${SESSION_COOKIE}=${sessionId}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
|
||||||
Location: "/",
|
Location: "/",
|
||||||
});
|
});
|
||||||
res.end();
|
res.end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Valid session cookie — serve the page
|
// Valid session cookie — serve the page
|
||||||
const nonce = randomBytes(16).toString("base64");
|
const nonce = randomBytes(16).toString("base64");
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
"Content-Type": "text/html; charset=utf-8",
|
"Content-Type": "text/html; charset=utf-8",
|
||||||
"X-Frame-Options": "DENY",
|
"X-Frame-Options": "DENY",
|
||||||
"X-Content-Type-Options": "nosniff",
|
"X-Content-Type-Options": "nosniff",
|
||||||
"Referrer-Policy": "no-referrer",
|
"Referrer-Policy": "no-referrer",
|
||||||
"Content-Security-Policy":
|
"Content-Security-Policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'`,
|
||||||
`default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'`,
|
});
|
||||||
});
|
res.end(buildHTML(nonce));
|
||||||
res.end(buildHTML(nonce));
|
} else {
|
||||||
} else {
|
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||||
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
res.end("Not found");
|
||||||
res.end("Not found");
|
}
|
||||||
}
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const wss = new WebSocketServer({ noServer: true });
|
const wss = new WebSocketServer({ noServer: true });
|
||||||
|
|
||||||
httpServer.on("error", (err: Error) => {
|
httpServer.on("error", (err: Error) => {
|
||||||
console.error("[remote-control] httpServer error:", err.message);
|
console.error("[remote-control] httpServer error:", err.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
wss.on("error", (err: Error) => {
|
wss.on("error", (err: Error) => {
|
||||||
console.error("[remote-control] wss error:", err.message);
|
console.error("[remote-control] wss error:", err.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
httpServer.on("upgrade", (request: any, socket: any, head: any) => {
|
httpServer.on(
|
||||||
const url = new URL(request.url, "http://localhost");
|
"upgrade",
|
||||||
if (url.pathname === "/ws") {
|
(request: IncomingMessage, socket: Socket, head: Buffer) => {
|
||||||
// Validate auth: session cookie or token query param
|
const url = new URL(request.url, "http://localhost");
|
||||||
if (!isAuthenticated(request)) {
|
if (url.pathname === "/ws") {
|
||||||
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
|
// Validate auth: session cookie or token query param
|
||||||
socket.destroy();
|
if (!isAuthenticated(request)) {
|
||||||
return;
|
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
|
||||||
}
|
socket.destroy();
|
||||||
wss.handleUpgrade(request, socket, head, (ws: any) => {
|
return;
|
||||||
wss.emit("connection", ws, request);
|
}
|
||||||
});
|
wss.handleUpgrade(request, socket, head, (ws: WsClient) => {
|
||||||
} else {
|
wss.emit("connection", ws, request);
|
||||||
socket.destroy();
|
});
|
||||||
}
|
} else {
|
||||||
});
|
socket.destroy();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
wss.on("connection", (ws: any) => {
|
wss.on("connection", (ws: WsClient) => {
|
||||||
clients.add(ws);
|
clients.add(ws);
|
||||||
for (const cb of clientChangeListeners) cb();
|
for (const cb of clientChangeListeners) cb();
|
||||||
|
|
||||||
// Send full state snapshot to the new client
|
// Send full state snapshot to the new client
|
||||||
try {
|
try {
|
||||||
ws.send(JSON.stringify(buildSyncMessage(ctx)));
|
ws.send(JSON.stringify(buildSyncMessage(ctx)));
|
||||||
} catch {
|
} catch {
|
||||||
/* client disconnected before first send */
|
/* client disconnected before first send */
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-connection rate limiting: max 30 prompts per 60 seconds
|
// Per-connection rate limiting: max 30 prompts per 60 seconds
|
||||||
const RATE_WINDOW_MS = 60_000;
|
const RATE_WINDOW_MS = 60_000;
|
||||||
const RATE_MAX = 30;
|
const RATE_MAX = 30;
|
||||||
const MAX_MSG_BYTES = 64 * 1024;
|
const MAX_MSG_BYTES = 64 * 1024;
|
||||||
const recentPrompts: number[] = [];
|
const recentPrompts: number[] = [];
|
||||||
|
|
||||||
ws.on("message", (data: any) => {
|
ws.on("message", (data: Buffer) => {
|
||||||
if (data.length > MAX_MSG_BYTES) return;
|
if (data.length > MAX_MSG_BYTES) return;
|
||||||
let msg: any;
|
let msg: { type?: string; text?: string };
|
||||||
try {
|
try {
|
||||||
msg = JSON.parse(data.toString());
|
const parsed: unknown = JSON.parse(data.toString());
|
||||||
} catch {
|
if (typeof parsed !== "object" || parsed === null) return;
|
||||||
return;
|
msg = parsed as { type?: string; text?: string };
|
||||||
}
|
} catch {
|
||||||
if (msg.type === "stop") {
|
return;
|
||||||
if (!ctx.isIdle()) {
|
}
|
||||||
ctx.abort();
|
if (msg.type === "stop") {
|
||||||
}
|
if (!ctx.isIdle()) {
|
||||||
return;
|
ctx.abort();
|
||||||
}
|
}
|
||||||
if (msg.type === "prompt" && typeof msg.text === "string" && msg.text.trim()) {
|
return;
|
||||||
const text = msg.text.trim();
|
}
|
||||||
// Sliding-window rate limit
|
if (
|
||||||
const now = Date.now();
|
msg.type === "prompt" &&
|
||||||
const cutoff = now - RATE_WINDOW_MS;
|
typeof msg.text === "string" &&
|
||||||
while (recentPrompts.length > 0 && recentPrompts[0] < cutoff) recentPrompts.shift();
|
msg.text.trim()
|
||||||
if (recentPrompts.length >= RATE_MAX) return;
|
) {
|
||||||
recentPrompts.push(now);
|
const text = msg.text.trim();
|
||||||
if (ctx.isIdle()) {
|
// Sliding-window rate limit
|
||||||
pi.sendUserMessage(text);
|
const now = Date.now();
|
||||||
} else {
|
const cutoff = now - RATE_WINDOW_MS;
|
||||||
pi.sendUserMessage(text, { deliverAs: "followUp" });
|
while (recentPrompts.length > 0 && recentPrompts[0] < cutoff)
|
||||||
}
|
recentPrompts.shift();
|
||||||
}
|
if (recentPrompts.length >= RATE_MAX) return;
|
||||||
});
|
recentPrompts.push(now);
|
||||||
|
if (ctx.isIdle()) {
|
||||||
|
pi.sendUserMessage(text);
|
||||||
|
} else {
|
||||||
|
pi.sendUserMessage(text, { deliverAs: "followUp" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const onClose = () => {
|
const onClose = () => {
|
||||||
clients.delete(ws);
|
clients.delete(ws);
|
||||||
broadcast({ type: "status", clientCount: clients.size });
|
broadcast({ type: "status", clientCount: clients.size });
|
||||||
for (const cb of clientChangeListeners) cb();
|
for (const cb of clientChangeListeners) cb();
|
||||||
};
|
};
|
||||||
ws.on("close", onClose);
|
ws.on("close", onClose);
|
||||||
ws.on("error", onClose);
|
ws.on("error", onClose);
|
||||||
});
|
});
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
httpServer.listen(0, "127.0.0.1", () => {
|
httpServer.listen(0, "127.0.0.1", () => {
|
||||||
resolve({
|
resolve({
|
||||||
broadcast,
|
broadcast,
|
||||||
sync,
|
sync,
|
||||||
stop: () =>
|
stop: () =>
|
||||||
new Promise<void>((res) => {
|
new Promise<void>((res) => {
|
||||||
// Forcefully kill all WebSocket clients — terminate() sends no
|
// Forcefully kill all WebSocket clients — terminate() sends no
|
||||||
// close frame and doesn't wait for the remote end to acknowledge,
|
// close frame and doesn't wait for the remote end to acknowledge,
|
||||||
// so it can't hang on an unresponsive client.
|
// so it can't hang on an unresponsive client.
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
try {
|
try {
|
||||||
client.terminate();
|
client.terminate();
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
clients.clear();
|
clients.clear();
|
||||||
|
|
||||||
// Safety timeout — if wss/http shutdown callbacks never fire
|
// Safety timeout — if wss/http shutdown callbacks never fire
|
||||||
// (e.g. lingering keep-alive sockets), resolve anyway so the
|
// (e.g. lingering keep-alive sockets), resolve anyway so the
|
||||||
// session_shutdown handler doesn't block pi from exiting.
|
// session_shutdown handler doesn't block pi from exiting.
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
httpServer.close(() => {});
|
httpServer.close(() => {});
|
||||||
httpServer.closeAllConnections?.();
|
httpServer.closeAllConnections?.();
|
||||||
res();
|
res();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
|
||||||
wss.close(() =>
|
wss.close(() =>
|
||||||
httpServer.close(() => {
|
httpServer.close(() => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
res();
|
res();
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
clientCount: () => clients.size,
|
clientCount: () => clients.size,
|
||||||
onClientChange: (cb: () => void) => { clientChangeListeners.push(cb); },
|
onClientChange: (cb: () => void) => {
|
||||||
get port() {
|
clientChangeListeners.push(cb);
|
||||||
return (httpServer.address() as any)?.port ?? 0;
|
},
|
||||||
},
|
get port() {
|
||||||
get token() {
|
return (httpServer.address() as AddressInfo | null)?.port ?? 0;
|
||||||
return token;
|
},
|
||||||
},
|
get token() {
|
||||||
});
|
return token;
|
||||||
});
|
},
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
"ws": "^8.0.0"
|
"ws": "^8.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^2.4.12",
|
||||||
"@types/qrcode": "^1.5.6"
|
"@types/qrcode": "^1.5.6"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|
@ -785,6 +786,169 @@
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@biomejs/biome": {
|
||||||
|
"version": "2.4.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.12.tgz",
|
||||||
|
"integrity": "sha512-Rro7adQl3NLq/zJCIL98eElXKI8eEiBtoeu5TbXF/U3qbjuSc7Jb5rjUbeHHcquDWeSf3HnGP7XI5qGrlRk/pA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"biome": "bin/biome"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.21.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/biome"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@biomejs/cli-darwin-arm64": "2.4.12",
|
||||||
|
"@biomejs/cli-darwin-x64": "2.4.12",
|
||||||
|
"@biomejs/cli-linux-arm64": "2.4.12",
|
||||||
|
"@biomejs/cli-linux-arm64-musl": "2.4.12",
|
||||||
|
"@biomejs/cli-linux-x64": "2.4.12",
|
||||||
|
"@biomejs/cli-linux-x64-musl": "2.4.12",
|
||||||
|
"@biomejs/cli-win32-arm64": "2.4.12",
|
||||||
|
"@biomejs/cli-win32-x64": "2.4.12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@biomejs/cli-darwin-arm64": {
|
||||||
|
"version": "2.4.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.12.tgz",
|
||||||
|
"integrity": "sha512-BnMU4Pc3ciEVteVpZ0BK33MLr7X57F5w1dwDLDn+/iy/yTrA4Q/N2yftidFtsA4vrDh0FMXDpacNV/Tl3fbmng==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.21.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@biomejs/cli-darwin-x64": {
|
||||||
|
"version": "2.4.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.12.tgz",
|
||||||
|
"integrity": "sha512-x9uJ0bI1rJsWICp3VH8w/5PnAVD3A7SqzDpbrfoUQX1QyWrK5jSU4fRLo/wSgGeplCivbxBRKmt5Xq4/nWvq8A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.21.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@biomejs/cli-linux-arm64": {
|
||||||
|
"version": "2.4.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.12.tgz",
|
||||||
|
"integrity": "sha512-tOwuCuZZtKi1jVzbk/5nXmIsziOB6yqN8c9r9QM0EJYPU6DpQWf11uBOSCfFKKM4H3d9ZoarvlgMfbcuD051Pw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.21.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
||||||
|
"version": "2.4.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.12.tgz",
|
||||||
|
"integrity": "sha512-FhfpkAAlKL6kwvcVap0Hgp4AhZmtd3YImg0kK1jd7C/aSoh4SfsB2f++yG1rU0lr8Y5MCFJrcSkmssiL9Xnnig==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.21.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@biomejs/cli-linux-x64": {
|
||||||
|
"version": "2.4.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.12.tgz",
|
||||||
|
"integrity": "sha512-8pFeAnLU9QdW9jCIslB/v82bI0lhBmz2ZAKc8pVMFPO0t0wAHsoEkrUQUbMkIorTRIjbqyNZHA3lEXavsPWYSw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.21.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@biomejs/cli-linux-x64-musl": {
|
||||||
|
"version": "2.4.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.12.tgz",
|
||||||
|
"integrity": "sha512-dwTIgZrGutzhkQCuvHynCkyW6hJxUuyZqKKO0YNfaS2GUoRO+tOvxXZqZB6SkWAOdfZTzwaw8IEdUnIkHKHoew==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.21.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@biomejs/cli-win32-arm64": {
|
||||||
|
"version": "2.4.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.12.tgz",
|
||||||
|
"integrity": "sha512-B0DLnx0vA9ya/3v7XyCaP+/lCpnbWbMOfUFFve+xb5OxyYvdHaS55YsSddr228Y+JAFk58agCuZTsqNiw2a6ig==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.21.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@biomejs/cli-win32-x64": {
|
||||||
|
"version": "2.4.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.12.tgz",
|
||||||
|
"integrity": "sha512-yMckRzTyZ83hkk8iDFWswqSdU8tvZxspJKnYNh7JZr/zhZNOlzH13k4ecboU6MurKExCe2HUkH75pGI/O2JwGA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.21.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@borewit/text-codec": {
|
"node_modules/@borewit/text-codec": {
|
||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,11 @@
|
||||||
"@mariozechner/pi-coding-agent": "*",
|
"@mariozechner/pi-coding-agent": "*",
|
||||||
"@mariozechner/pi-tui": "*"
|
"@mariozechner/pi-tui": "*"
|
||||||
},
|
},
|
||||||
|
"scripts": {
|
||||||
|
"lint": "biome check --write ."
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^2.4.12",
|
||||||
"@types/qrcode": "^1.5.6"
|
"@types/qrcode": "^1.5.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue