From 82c463ec27d11b7907b01073238d6728813b6348 Mon Sep 17 00:00:00 2001 From: Yejun Su Date: Tue, 21 Apr 2026 14:09:42 +0800 Subject: [PATCH] chore(remote-control): add Biome and fix all lint warnings --- biome.json | 34 ++ extensions/remote-control/auth.ts | 44 ++- extensions/remote-control/config.ts | 157 +++++---- extensions/remote-control/html.ts | 2 +- extensions/remote-control/index.ts | 423 +++++++++++----------- extensions/remote-control/messages.ts | 193 ++++++----- extensions/remote-control/server.ts | 482 ++++++++++++++------------ package-lock.json | 164 +++++++++ package.json | 4 + 9 files changed, 913 insertions(+), 590 deletions(-) create mode 100644 biome.json diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..5eafcd1 --- /dev/null +++ b/biome.json @@ -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" + } + } + } +} diff --git a/extensions/remote-control/auth.ts b/extensions/remote-control/auth.ts index f42a097..7765fd3 100644 --- a/extensions/remote-control/auth.ts +++ b/extensions/remote-control/auth.ts @@ -7,34 +7,40 @@ import { randomBytes, timingSafeEqual } from "node:crypto"; 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 { - const a = Buffer.from(provided); - const b = Buffer.from(expected); - if (a.length !== b.length) return false; - return timingSafeEqual(a, b); + const a = Buffer.from(provided); + const b = Buffer.from(expected); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); } /** Name of the cookie that grants access after initial token validation */ export const SESSION_COOKIE = "pi_rc_session"; export function generateSessionId(): string { - return randomBytes(24).toString("base64url"); + return randomBytes(24).toString("base64url"); } -export function parseCookies(header: string | undefined): Record { - const cookies: Record = {}; - if (!header) return cookies; - for (const pair of header.split(";")) { - const idx = pair.indexOf("="); - if (idx < 0) continue; - const name = pair.slice(0, idx).trim(); - const raw = pair.slice(idx + 1).trim(); - let value = raw; - try { value = decodeURIComponent(raw); } catch { /* keep raw */ } - cookies[name] = value; - } - return cookies; +export function parseCookies( + header: string | undefined, +): Record { + const cookies: Record = {}; + if (!header) return cookies; + for (const pair of header.split(";")) { + const idx = pair.indexOf("="); + if (idx < 0) continue; + const name = pair.slice(0, idx).trim(); + const raw = pair.slice(idx + 1).trim(); + let value = raw; + try { + value = decodeURIComponent(raw); + } catch { + /* keep raw */ + } + cookies[name] = value; + } + return cookies; } diff --git a/extensions/remote-control/config.ts b/extensions/remote-control/config.ts index fe39270..ba48582 100644 --- a/extensions/remote-control/config.ts +++ b/extensions/remote-control/config.ts @@ -13,94 +13,115 @@ import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; const REMOTE_CONTROL_CONFIG_FILE = "remote-control.json"; export interface RemoteControlConfig { - publicBaseUrl?: string; + publicBaseUrl?: string; } function getAgentDir(): string { - const envCandidates = ["PI_CODING_AGENT_DIR", "TAU_CODING_AGENT_DIR"]; - let envDir: string | undefined; - for (const key of envCandidates) { - if (process.env[key]) { - envDir = process.env[key]; - break; - } - } - if (!envDir) { - for (const [key, value] of Object.entries(process.env)) { - if (key.endsWith("_CODING_AGENT_DIR") && value) { - envDir = value; - break; - } - } - } + const envCandidates = ["PI_CODING_AGENT_DIR", "TAU_CODING_AGENT_DIR"]; + let envDir: string | undefined; + for (const key of envCandidates) { + if (process.env[key]) { + envDir = process.env[key]; + break; + } + } + if (!envDir) { + for (const [key, value] of Object.entries(process.env)) { + if (key.endsWith("_CODING_AGENT_DIR") && value) { + envDir = value; + break; + } + } + } - if (envDir === "~") return os.homedir(); - if (envDir?.startsWith("~/")) return path.join(os.homedir(), envDir.slice(2)); - return envDir ?? path.join(os.homedir(), ".pi", "agent"); + if (envDir === "~") return os.homedir(); + if (envDir?.startsWith("~/")) return path.join(os.homedir(), envDir.slice(2)); + return envDir ?? path.join(os.homedir(), ".pi", "agent"); } function getRemoteControlConfigPath(): string { - return path.join(getAgentDir(), REMOTE_CONTROL_CONFIG_FILE); + return path.join(getAgentDir(), REMOTE_CONTROL_CONFIG_FILE); } export async function readRemoteControlConfig(): Promise { - try { - const raw = await fs.readFile(getRemoteControlConfigPath(), "utf8"); - const parsed = JSON.parse(raw) as RemoteControlConfig; - if (!parsed || typeof parsed !== "object") return {}; - return parsed; - } catch { - return {}; - } + try { + const raw = await fs.readFile(getRemoteControlConfigPath(), "utf8"); + const parsed = JSON.parse(raw) as RemoteControlConfig; + if (!parsed || typeof parsed !== "object") return {}; + return parsed; + } catch { + return {}; + } } -async function writeRemoteControlConfig(config: RemoteControlConfig): Promise { - const configPath = getRemoteControlConfigPath(); - await fs.mkdir(path.dirname(configPath), { recursive: true }); - await fs.writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf8"); +async function writeRemoteControlConfig( + config: RemoteControlConfig, +): Promise { + 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 { - const parsed = new URL(value.trim()); - parsed.username = ""; - parsed.password = ""; - parsed.pathname = ""; - parsed.search = ""; - parsed.hash = ""; - return parsed.toString().replace(/\/+$/, ""); + const parsed = new URL(value.trim()); + parsed.username = ""; + parsed.password = ""; + parsed.pathname = ""; + parsed.search = ""; + parsed.hash = ""; + return parsed.toString().replace(/\/+$/, ""); } -export function buildRemoteControlUrl(publicBaseUrl: string, port: number, token: string): string { - const parsed = new URL(normalizePublicBaseUrl(publicBaseUrl)); - if (parsed.protocol === "http:") { - parsed.port = String(port); - } - parsed.searchParams.set("token", token); - return parsed.toString(); +export function buildRemoteControlUrl( + publicBaseUrl: string, + port: number, + token: string, +): string { + const parsed = new URL(normalizePublicBaseUrl(publicBaseUrl)); + if (parsed.protocol === "http:") { + parsed.port = String(port); + } + parsed.searchParams.set("token", token); + return parsed.toString(); } -export async function configureRemoteControlUI(ctx: ExtensionContext): Promise { - if (!ctx.hasUI) return; +export async function configureRemoteControlUI( + ctx: ExtensionContext, +): Promise { + if (!ctx.hasUI) return; - const current = (await readRemoteControlConfig()).publicBaseUrl ?? ""; - const title = current - ? `Public base URL (current: ${current})` - : "Public base URL"; - const raw = await ctx.ui.input(title, "e.g. http://pi.myhost"); - if (raw === undefined) return; + const current = (await readRemoteControlConfig()).publicBaseUrl ?? ""; + const title = current + ? `Public base URL (current: ${current})` + : "Public base URL"; + const raw = await ctx.ui.input(title, "e.g. http://pi.myhost"); + if (raw === undefined) return; - let value: string; - try { - value = normalizePublicBaseUrl(raw); - } catch { - ctx.ui.notify("Public base URL must be a valid http:// or https:// URL", "warning"); - return; - } - if (!["http:", "https:"].includes(new URL(value).protocol)) { - ctx.ui.notify("Public base URL must start with http:// or https://", "warning"); - return; - } + let value: string; + try { + value = normalizePublicBaseUrl(raw); + } catch { + ctx.ui.notify( + "Public base URL must be a valid http:// or https:// URL", + "warning", + ); + 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 }); - ctx.ui.notify(`Saved remote-control URL to ${getRemoteControlConfigPath()}`, "info"); + await writeRemoteControlConfig({ publicBaseUrl: value }); + ctx.ui.notify( + `Saved remote-control URL to ${getRemoteControlConfigPath()}`, + "info", + ); } diff --git a/extensions/remote-control/html.ts b/extensions/remote-control/html.ts index 4e624c5..8bbd04f 100644 --- a/extensions/remote-control/html.ts +++ b/extensions/remote-control/html.ts @@ -6,7 +6,7 @@ */ export function buildHTML(nonce: string): string { -return /* html */ ` + return /* html */ ` diff --git a/extensions/remote-control/index.ts b/extensions/remote-control/index.ts index 49a74e8..22514f6 100644 --- a/extensions/remote-control/index.ts +++ b/extensions/remote-control/index.ts @@ -11,246 +11,273 @@ */ 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 { Container, Text } from "@mariozechner/pi-tui"; import { - readRemoteControlConfig, - buildRemoteControlUrl, - configureRemoteControlUI, + buildRemoteControlUrl, + configureRemoteControlUI, + readRemoteControlConfig, } from "./config.js"; -import { serializeMessage } from "./messages.js"; +import { type RawMessage, serializeMessage } from "./messages.js"; import { type RemoteServer, startServer } from "./server.js"; // ── Extension entry point ──────────────────────────────────────────────────── const _require = createRequire(import.meta.url); -const QRCode = _require("qrcode") as { toString: (text: string, opts: any) => Promise }; +const QRCode = _require("qrcode") as { + toString: (text: string, opts: Record) => Promise; +}; export default function remoteControl(pi: ExtensionAPI) { - let server: RemoteServer | undefined; - let pendingSyncTimer: ReturnType | undefined; + let server: RemoteServer | undefined; + let pendingSyncTimer: ReturnType | undefined; - function scheduleSync(ctx: ExtensionContext): void { - if (pendingSyncTimer) clearTimeout(pendingSyncTimer); - pendingSyncTimer = setTimeout(() => { - pendingSyncTimer = undefined; - server?.sync(ctx); - updateStatus(ctx); - }, 0); - } + function scheduleSync(ctx: ExtensionContext): void { + if (pendingSyncTimer) clearTimeout(pendingSyncTimer); + pendingSyncTimer = setTimeout(() => { + pendingSyncTimer = undefined; + server?.sync(ctx); + updateStatus(ctx); + }, 0); + } - // ── CLI flag ────────────────────────────────────────────────────────────── + // ── CLI flag ────────────────────────────────────────────────────────────── - pi.registerFlag("remote-control", { - description: "Start the remote-control server automatically on session start", - type: "boolean", - default: false, - }); + pi.registerFlag("remote-control", { + description: + "Start the remote-control server automatically on session start", + type: "boolean", + default: false, + }); - // ── Status indicator ────────────────────────────────────────────────────── + // ── Status indicator ────────────────────────────────────────────────────── - function updateStatus(ctx: ExtensionContext): void { - if (!ctx.hasUI || !server) return; - const clients = server.clientCount(); - const label = clients > 0 ? `remote:${clients}` : "remote:on"; - ctx.ui.setStatus("remote-control", ctx.ui.theme.fg("accent", label)); - } + function updateStatus(ctx: ExtensionContext): void { + if (!ctx.hasUI || !server) return; + const clients = server.clientCount(); + const label = clients > 0 ? `remote:${clients}` : "remote:on"; + ctx.ui.setStatus("remote-control", ctx.ui.theme.fg("accent", label)); + } - // ── Lifecycle ────────────────────────────────────────────────────────────── + // ── Lifecycle ────────────────────────────────────────────────────────────── - pi.on("session_start", async (_event, ctx) => { - // Clear any stale status from before a reload - if (ctx.hasUI) ctx.ui.setStatus("remote-control", undefined); + pi.on("session_start", async (_event, ctx) => { + // Clear any stale status from before a reload + 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 publicBaseUrl = config.publicBaseUrl?.trim(); - if (!publicBaseUrl) { - if (ctx.hasUI) { - ctx.ui.notify( - "--remote-control: no publicBaseUrl configured. Run /remote-control config first.", - "warning", - ); - } - return; - } + const config = await readRemoteControlConfig(); + const publicBaseUrl = config.publicBaseUrl?.trim(); + if (!publicBaseUrl) { + if (ctx.hasUI) { + ctx.ui.notify( + "--remote-control: no publicBaseUrl configured. Run /remote-control config first.", + "warning", + ); + } + return; + } - server = await startServer(pi, ctx); - server.onClientChange(() => updateStatus(ctx)); - const url = buildRemoteControlUrl(publicBaseUrl, server.port, server.token); + server = await startServer(pi, ctx); + server.onClientChange(() => updateStatus(ctx)); + const url = buildRemoteControlUrl(publicBaseUrl, server.port, server.token); - if (ctx.hasUI) { - ctx.ui.notify(`Remote-control started: ${url}`, "info"); - } - updateStatus(ctx); - }); + if (ctx.hasUI) { + ctx.ui.notify(`Remote-control started: ${url}`, "info"); + } + updateStatus(ctx); + }); - pi.on("session_switch", async (_event, ctx) => { - scheduleSync(ctx); - }); + pi.on("session_switch", async (_event, ctx) => { + scheduleSync(ctx); + }); - pi.on("model_select", async (_event, ctx) => { - if (!ctx.isIdle()) return; - scheduleSync(ctx); - }); + pi.on("model_select", async (_event, ctx) => { + if (!ctx.isIdle()) return; + scheduleSync(ctx); + }); - pi.on("session_shutdown", async () => { - if (pendingSyncTimer) { - clearTimeout(pendingSyncTimer); - pendingSyncTimer = undefined; - } - if (server) { - await server.stop(); - server = undefined; - } - }); + pi.on("session_shutdown", async () => { + if (pendingSyncTimer) { + clearTimeout(pendingSyncTimer); + pendingSyncTimer = undefined; + } + if (server) { + await server.stop(); + server = undefined; + } + }); - // ── Event bridge: pi → clients ──────────────────────────────────────────── + // ── Event bridge: pi → clients ──────────────────────────────────────────── - pi.on("agent_start", async (_event, ctx) => { - server?.broadcast({ type: "agent_start" }); - updateStatus(ctx); - }); + pi.on("agent_start", async (_event, ctx) => { + server?.broadcast({ type: "agent_start" }); + updateStatus(ctx); + }); - pi.on("agent_end", async (_event, ctx) => { - server?.broadcast({ type: "agent_end" }); - updateStatus(ctx); - }); + pi.on("agent_end", async (_event, ctx) => { + server?.broadcast({ type: "agent_end" }); + updateStatus(ctx); + }); - pi.on("message_update", async (event) => { - const m = serializeMessage("pending", (event as any).message); - if (m) server?.broadcast({ type: "message_update", message: m }); - }); + pi.on("message_update", async (event) => { + const m = serializeMessage( + "pending", + (event as { message: RawMessage }).message, + ); + if (m) server?.broadcast({ type: "message_update", message: m }); + }); - pi.on("message_end", async (event, ctx) => { - // Use the last branch entry to get the committed entry ID - const branch = ctx.sessionManager.getBranch(); - const last = branch[branch.length - 1]; - const id = last?.id ?? `msg_${Date.now()}`; - const m = serializeMessage(id, (event as any).message); - if (m) server?.broadcast({ type: "message_end", message: m }); - }); + pi.on("message_end", async (event, ctx) => { + // Use the last branch entry to get the committed entry ID + const branch = ctx.sessionManager.getBranch(); + const last = branch[branch.length - 1]; + const id = last?.id ?? `msg_${Date.now()}`; + const m = serializeMessage(id, (event as { message: RawMessage }).message); + if (m) server?.broadcast({ type: "message_end", message: m }); + }); - pi.on("tool_execution_start", async (event) => { - server?.broadcast({ - type: "tool_start", - toolCallId: event.toolCallId, - toolName: event.toolName, - args: event.args, - }); - }); + pi.on("tool_execution_start", async (event) => { + server?.broadcast({ + type: "tool_start", + toolCallId: event.toolCallId, + toolName: event.toolName, + args: event.args, + }); + }); - pi.on("tool_execution_end", async (event) => { - const result = event.result as any; - const resultText = Array.isArray(result?.content) - ? result.content - .filter((c: any) => c.type === "text") - .map((c: any) => c.text) - .join("") - : typeof result === "string" - ? result - : ""; - server?.broadcast({ - type: "tool_end", - toolCallId: event.toolCallId, - result: resultText, - isError: event.isError, - }); - }); + pi.on("tool_execution_end", async (event) => { + type TextContent = { type: string; text: string }; + type ToolResult = { content?: TextContent[] } | string; + const result = event.result as ToolResult; + const content = typeof result === "object" ? result.content : undefined; + const resultText = Array.isArray(content) + ? content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join("") + : typeof result === "string" + ? result + : ""; + server?.broadcast({ + type: "tool_end", + toolCallId: event.toolCallId, + result: resultText, + isError: event.isError, + }); + }); - // ── /remote-control command ─────────────────────────────────────────────── + // ── /remote-control command ─────────────────────────────────────────────── - async function showConnectionInfo(ctx: ExtensionContext): Promise { - if (!server) return; + async function showConnectionInfo(ctx: ExtensionContext): Promise { + if (!server) return; - const config = await readRemoteControlConfig(); - const publicBaseUrl = config.publicBaseUrl?.trim(); - if (!publicBaseUrl) return; + const config = await readRemoteControlConfig(); + const publicBaseUrl = config.publicBaseUrl?.trim(); + if (!publicBaseUrl) return; - const url = buildRemoteControlUrl(publicBaseUrl, server.port, server.token); + const url = buildRemoteControlUrl(publicBaseUrl, server.port, server.token); - // Generate QR code - let qrLines: string[] = []; - try { - const qr = await QRCode.toString(url, { type: "utf8", margin: 2 }); - qrLines = qr.trimEnd().split("\n"); - } catch { - // QR code generation failed - } + // Generate QR code + let qrLines: string[] = []; + try { + const qr = await QRCode.toString(url, { type: "utf8", margin: 2 }); + qrLines = qr.trimEnd().split("\n"); + } catch { + // QR code generation failed + } - // Show in editor area — use confirm/cancel to dismiss - await ctx.ui.custom((_tui, theme, kb, done) => { - const container = new Container(); - container.addChild(new DynamicBorder((s) => theme.fg("accent", s))); - container.addChild(new Text( - theme.fg("accent", theme.bold(" Remote-control")) + - " " + - keyHint("tui.select.confirm", "close") + - theme.fg("muted", " · ") + - keyHint("tui.select.cancel", "cancel"), - 1, 0, - )); - 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))); + // Show in editor area — use confirm/cancel to dismiss + await ctx.ui.custom((_tui, theme, kb, done) => { + const container = new Container(); + container.addChild(new DynamicBorder((s) => theme.fg("accent", s))); + container.addChild( + new Text( + theme.fg("accent", theme.bold(" Remote-control")) + + " " + + keyHint("tui.select.confirm", "close") + + theme.fg("muted", " · ") + + keyHint("tui.select.cancel", "cancel"), + 1, + 0, + ), + ); + 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 { - render: (w) => container.render(w), - invalidate: () => container.invalidate(), - handleInput: (data) => { - if (kb.matches(data, "tui.select.cancel") || kb.matches(data, "tui.select.confirm")) done(); - }, - }; - }); - } + return { + render: (w) => container.render(w), + invalidate: () => container.invalidate(), + handleInput: (data) => { + if ( + kb.matches(data, "tui.select.cancel") || + kb.matches(data, "tui.select.confirm") + ) + done(); + }, + }; + }); + } - pi.registerCommand("remote-control", { - description: "Remote control — start/stop server, configure, show connection info", - handler: async (args, ctx) => { - if (!ctx.hasUI) return; + pi.registerCommand("remote-control", { + description: + "Remote control — start/stop server, configure, show connection info", + handler: async (_args, ctx) => { + if (!ctx.hasUI) return; - const isRunning = !!server; - const config = await readRemoteControlConfig(); - const currentUrl = config.publicBaseUrl?.trim(); + const isRunning = !!server; + const config = await readRemoteControlConfig(); + const currentUrl = config.publicBaseUrl?.trim(); - const configLabel = currentUrl ? `Configure URL (${currentUrl})` : "Configure URL (not set)"; - const menuItems = [ - isRunning ? "Turn off" : "Turn on", - configLabel, - ...(isRunning ? ["Status"] : []), - ]; + const configLabel = currentUrl + ? `Configure URL (${currentUrl})` + : "Configure URL (not set)"; + const menuItems = [ + isRunning ? "Turn off" : "Turn on", + configLabel, + ...(isRunning ? ["Status"] : []), + ]; - const choice = await ctx.ui.select("Remote control", menuItems); - if (choice === undefined) return; + const choice = await ctx.ui.select("Remote control", menuItems); + if (choice === undefined) return; - if (choice === "Turn on") { - const publicBaseUrl = currentUrl; - if (!publicBaseUrl) { - ctx.ui.notify("Set the public URL first — opening config…", "warning"); - await configureRemoteControlUI(ctx); - // Re-check after config - const updated = await readRemoteControlConfig(); - if (!updated.publicBaseUrl?.trim()) return; - } - server = await startServer(pi, ctx); - server.onClientChange(() => updateStatus(ctx)); - updateStatus(ctx); - ctx.ui.notify("Remote-control server started", "info"); - await showConnectionInfo(ctx); - } else if (choice === "Turn off") { - if (server) { - await server.stop(); - server = undefined; - ctx.ui.setStatus("remote-control", undefined); - ctx.ui.notify("Remote-control server stopped", "info"); - } - } else if (choice === configLabel) { - await configureRemoteControlUI(ctx); - } else if (choice === "Status") { - await showConnectionInfo(ctx); - } - }, - }); + if (choice === "Turn on") { + const publicBaseUrl = currentUrl; + if (!publicBaseUrl) { + ctx.ui.notify( + "Set the public URL first — opening config…", + "warning", + ); + await configureRemoteControlUI(ctx); + // Re-check after config + const updated = await readRemoteControlConfig(); + if (!updated.publicBaseUrl?.trim()) return; + } + server = await startServer(pi, ctx); + server.onClientChange(() => updateStatus(ctx)); + updateStatus(ctx); + ctx.ui.notify("Remote-control server started", "info"); + await showConnectionInfo(ctx); + } else if (choice === "Turn off") { + if (server) { + await server.stop(); + server = undefined; + ctx.ui.setStatus("remote-control", undefined); + ctx.ui.notify("Remote-control server stopped", "info"); + } + } else if (choice === configLabel) { + await configureRemoteControlUI(ctx); + } else if (choice === "Status") { + await showConnectionInfo(ctx); + } + }, + }); } diff --git a/extensions/remote-control/messages.ts b/extensions/remote-control/messages.ts index 1ffbeee..47539fd 100644 --- a/extensions/remote-control/messages.ts +++ b/extensions/remote-control/messages.ts @@ -7,104 +7,127 @@ import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; -export interface RenderMsg { - id: string; // SessionEntry id, or "pending" while streaming - role: "user" | "assistant" | "tool_result"; - text: string; - toolCalls?: Array<{ id: string; name: string; args: string }>; - toolName?: string; - toolCallId?: string; - isError?: boolean; - model?: string; +interface RawContent { + type: string; + text?: string; + id?: string; + name?: string; + arguments?: unknown; } -export function serializeMessage(id: string, msg: any): RenderMsg | null { - if (msg.role === "user") { - const text = - typeof msg.content === "string" - ? msg.content - : (msg.content as any[]) - .filter((c) => c.type === "text") - .map((c) => c.text) - .join(""); - return { id, role: "user", text }; - } +export interface RawMessage { + role: string; + content: string | RawContent[]; + model?: string; + toolName?: string; + toolCallId?: string; + isError?: boolean; +} - if (msg.role === "assistant") { - const text = (msg.content as any[]) - .filter((c) => c.type === "text") - .map((c) => c.text) - .join(""); - const toolCalls = (msg.content as any[]) - .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, - }; - } +export interface RenderMsg { + id: string; // SessionEntry id, or "pending" while streaming + role: "user" | "assistant" | "tool_result"; + text: string; + toolCalls?: Array<{ id: string; name: string; args: string }>; + toolName?: string; + toolCallId?: string; + isError?: boolean; + model?: string; +} - if (msg.role === "toolResult") { - const text = (msg.content as any[]) - .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, - }; - } +export function serializeMessage( + id: string, + msg: RawMessage, +): RenderMsg | null { + if (msg.role === "user") { + const text = + typeof msg.content === "string" + ? msg.content + : (msg.content as RawContent[]) + .filter((c) => c.type === "text") + .map((c) => c.text) + .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[] { - const branch = ctx.sessionManager.getBranch(); - const out: RenderMsg[] = []; - for (const entry of branch) { - if (entry.type !== "message") continue; - const m = serializeMessage(entry.id, (entry as any).message); - if (m) out.push(m); - } - return out; + const branch = ctx.sessionManager.getBranch(); + const out: RenderMsg[] = []; + for (const entry of branch) { + if (entry.type !== "message") continue; + const m = serializeMessage( + entry.id, + (entry as { id: string; type: string; message: RawMessage }).message, + ); + if (m) out.push(m); + } + return out; } function abbreviateHome(p: string): string { - const home = process.env.HOME; - if (home && p === home) return "~"; - if (home && p.startsWith(home + "/")) return "~" + p.slice(home.length); - return p; + const home = process.env.HOME; + if (home && p === home) return "~"; + if (home && p.startsWith(`${home}/`)) return `~${p.slice(home.length)}`; + return p; } export function buildSyncMessage(ctx: ExtensionContext): { - type: "sync"; - messages: RenderMsg[]; - state: { - isStreaming: boolean; - model: string | undefined; - cwd: string; - sessionName: string | undefined; - }; + type: "sync"; + messages: RenderMsg[]; + state: { + isStreaming: boolean; + model: string | undefined; + cwd: string; + sessionName: string | undefined; + }; } { - return { - type: "sync", - messages: getBranchMessages(ctx), - state: { - isStreaming: !ctx.isIdle(), - model: ctx.model?.id, - cwd: abbreviateHome(ctx.cwd), - sessionName: ctx.sessionManager.getSessionName(), - }, - }; + return { + type: "sync", + messages: getBranchMessages(ctx), + state: { + isStreaming: !ctx.isIdle(), + model: ctx.model?.id, + cwd: abbreviateHome(ctx.cwd), + sessionName: ctx.sessionManager.getSessionName(), + }, + }; } diff --git a/extensions/remote-control/server.ts b/extensions/remote-control/server.ts index fd43234..2565e6f 100644 --- a/extensions/remote-control/server.ts +++ b/extensions/remote-control/server.ts @@ -5,260 +5,304 @@ * 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 { createRequire } from "node:module"; -import { randomBytes } from "node:crypto"; -import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import type { AddressInfo, Socket } from "node:net"; +import type { + ExtensionAPI, + ExtensionContext, +} from "@mariozechner/pi-coding-agent"; import { - generateToken, - validateToken, - SESSION_COOKIE, - generateSessionId, - parseCookies, + generateSessionId, + generateToken, + parseCookies, + SESSION_COOKIE, + validateToken, } from "./auth.js"; -import { buildSyncMessage } from "./messages.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 const _require = createRequire(import.meta.url); const wsModule = _require("ws") as { - WebSocketServer: new (opts: { noServer: boolean }) => any; - OPEN: number; + WebSocketServer: new (opts: { noServer: boolean }) => WsServer; + OPEN: number; }; const { WebSocketServer, OPEN } = wsModule; export interface RemoteServer { - broadcast: (msg: object) => void; - sync: (ctx: ExtensionContext) => void; - stop: () => Promise; - clientCount: () => number; - onClientChange: (cb: () => void) => void; - port: number; - token: string; + broadcast: (msg: object) => void; + sync: (ctx: ExtensionContext) => void; + stop: () => Promise; + clientCount: () => number; + onClientChange: (cb: () => void) => void; + port: number; + token: string; } -export function startServer(pi: ExtensionAPI, ctx: ExtensionContext): Promise { - const clientChangeListeners: Array<() => void> = []; - const clients = new Set(); - const token = generateToken(); - // Map of valid session IDs → expiry timestamp (ms since epoch) - const SESSION_TTL_MS = 86_400_000; // 24 h — matches cookie Max-Age - const validSessions = new Map(); - const pruneExpiredSessions = (): void => { - const now = Date.now(); - for (const [id, expiresAt] of validSessions) { - if (expiresAt <= now) validSessions.delete(id); - } - }; +export function startServer( + pi: ExtensionAPI, + ctx: ExtensionContext, +): Promise { + const clientChangeListeners: Array<() => void> = []; + const clients = new Set(); + const token = generateToken(); + // Map of valid session IDs → expiry timestamp (ms since epoch) + const SESSION_TTL_MS = 86_400_000; // 24 h — matches cookie Max-Age + const validSessions = new Map(); + 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) */ - function isAuthenticated(req: any): boolean { - // Check session cookie first - const cookies = parseCookies(req.headers.cookie); - const sessionId = cookies[SESSION_COOKIE]; - const sessionExpiry = sessionId ? validSessions.get(sessionId) : undefined; - if (sessionExpiry !== undefined && sessionExpiry > Date.now()) return true; + /** Check if a request is authenticated (valid token query param OR valid session cookie) */ + function isAuthenticated(req: IncomingMessage): boolean { + // Check session cookie first + const cookies = parseCookies(req.headers.cookie); + const sessionId = cookies[SESSION_COOKIE]; + const sessionExpiry = sessionId ? validSessions.get(sessionId) : undefined; + if (sessionExpiry !== undefined && sessionExpiry > Date.now()) return true; - // Check token query param - const url = new URL(req.url ?? "/", "http://localhost"); - const providedToken = url.searchParams.get("token"); - if (providedToken && validateToken(providedToken, token)) return true; + // Check token query param + const url = new URL(req.url ?? "/", "http://localhost"); + const providedToken = url.searchParams.get("token"); + if (providedToken && validateToken(providedToken, token)) return true; - return false; - } + return false; + } - function broadcast(msg: object): void { - const data = JSON.stringify(msg); - for (const client of clients) { - if (client.readyState === OPEN) { - try { - client.send(data); - } catch { - /* ignore */ - } - } - } - } + function broadcast(msg: object): void { + const data = JSON.stringify(msg); + for (const client of clients) { + if (client.readyState === OPEN) { + try { + client.send(data); + } catch { + /* ignore */ + } + } + } + } - function sync(currentCtx: ExtensionContext): void { - broadcast(buildSyncMessage(currentCtx)); - } + function sync(currentCtx: ExtensionContext): void { + broadcast(buildSyncMessage(currentCtx)); + } - const httpServer = createServer((req, res) => { - const url = new URL(req.url ?? "/", "http://localhost"); - const pathname = url.pathname; + const httpServer = createServer((req, res) => { + const url = new URL(req.url ?? "/", "http://localhost"); + const pathname = url.pathname; - if (pathname === "/" || pathname === "/index.html") { - // Check authentication - const cookies = parseCookies(req.headers.cookie); - const sc = cookies[SESSION_COOKIE]; - const hasValidSession = sc !== undefined && (validSessions.get(sc) ?? 0) > Date.now(); - const providedToken = url.searchParams.get("token"); - const hasValidToken = providedToken && validateToken(providedToken, token); + if (pathname === "/" || pathname === "/index.html") { + // Check authentication + const cookies = parseCookies(req.headers.cookie); + const sc = cookies[SESSION_COOKIE]; + const hasValidSession = + sc !== undefined && (validSessions.get(sc) ?? 0) > Date.now(); + const providedToken = url.searchParams.get("token"); + const hasValidToken = + providedToken && validateToken(providedToken, token); - if (!hasValidSession && !hasValidToken) { - res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" }); - res.end("Forbidden — valid token required. Use the URL shown in the pi terminal."); - return; - } + if (!hasValidSession && !hasValidToken) { + res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" }); + res.end( + "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 (!hasValidSession && hasValidToken) { - pruneExpiredSessions(); - const sessionId = generateSessionId(); - validSessions.set(sessionId, Date.now() + SESSION_TTL_MS); - res.writeHead(302, { - "Set-Cookie": `${SESSION_COOKIE}=${sessionId}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`, - Location: "/", - }); - res.end(); - return; - } + // If authenticated via token (first visit), issue a session cookie and redirect to clean URL + if (!hasValidSession && hasValidToken) { + pruneExpiredSessions(); + const sessionId = generateSessionId(); + validSessions.set(sessionId, Date.now() + SESSION_TTL_MS); + res.writeHead(302, { + "Set-Cookie": `${SESSION_COOKIE}=${sessionId}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`, + Location: "/", + }); + res.end(); + return; + } - // Valid session cookie — serve the page - const nonce = randomBytes(16).toString("base64"); - res.writeHead(200, { - "Content-Type": "text/html; charset=utf-8", - "X-Frame-Options": "DENY", - "X-Content-Type-Options": "nosniff", - "Referrer-Policy": "no-referrer", - "Content-Security-Policy": - `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'`, - }); - res.end(buildHTML(nonce)); - } else { - res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); - res.end("Not found"); - } - }); + // Valid session cookie — serve the page + const nonce = randomBytes(16).toString("base64"); + res.writeHead(200, { + "Content-Type": "text/html; charset=utf-8", + "X-Frame-Options": "DENY", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", + "Content-Security-Policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'`, + }); + res.end(buildHTML(nonce)); + } else { + res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("Not found"); + } + }); - const wss = new WebSocketServer({ noServer: true }); + const wss = new WebSocketServer({ noServer: true }); - httpServer.on("error", (err: Error) => { - console.error("[remote-control] httpServer error:", err.message); - }); + httpServer.on("error", (err: Error) => { + console.error("[remote-control] httpServer error:", err.message); + }); - wss.on("error", (err: Error) => { - console.error("[remote-control] wss error:", err.message); - }); + wss.on("error", (err: Error) => { + console.error("[remote-control] wss error:", err.message); + }); - httpServer.on("upgrade", (request: any, socket: any, head: any) => { - const url = new URL(request.url, "http://localhost"); - if (url.pathname === "/ws") { - // Validate auth: session cookie or token query param - if (!isAuthenticated(request)) { - socket.write("HTTP/1.1 403 Forbidden\r\n\r\n"); - socket.destroy(); - return; - } - wss.handleUpgrade(request, socket, head, (ws: any) => { - wss.emit("connection", ws, request); - }); - } else { - socket.destroy(); - } - }); + httpServer.on( + "upgrade", + (request: IncomingMessage, socket: Socket, head: Buffer) => { + const url = new URL(request.url, "http://localhost"); + if (url.pathname === "/ws") { + // Validate auth: session cookie or token query param + if (!isAuthenticated(request)) { + socket.write("HTTP/1.1 403 Forbidden\r\n\r\n"); + socket.destroy(); + return; + } + wss.handleUpgrade(request, socket, head, (ws: WsClient) => { + wss.emit("connection", ws, request); + }); + } else { + socket.destroy(); + } + }, + ); - wss.on("connection", (ws: any) => { - clients.add(ws); - for (const cb of clientChangeListeners) cb(); + wss.on("connection", (ws: WsClient) => { + clients.add(ws); + for (const cb of clientChangeListeners) cb(); - // Send full state snapshot to the new client - try { - ws.send(JSON.stringify(buildSyncMessage(ctx))); - } catch { - /* client disconnected before first send */ - } + // Send full state snapshot to the new client + try { + ws.send(JSON.stringify(buildSyncMessage(ctx))); + } catch { + /* client disconnected before first send */ + } - // Per-connection rate limiting: max 30 prompts per 60 seconds - const RATE_WINDOW_MS = 60_000; - const RATE_MAX = 30; - const MAX_MSG_BYTES = 64 * 1024; - const recentPrompts: number[] = []; + // Per-connection rate limiting: max 30 prompts per 60 seconds + const RATE_WINDOW_MS = 60_000; + const RATE_MAX = 30; + const MAX_MSG_BYTES = 64 * 1024; + const recentPrompts: number[] = []; - ws.on("message", (data: any) => { - if (data.length > MAX_MSG_BYTES) return; - let msg: any; - try { - msg = JSON.parse(data.toString()); - } catch { - return; - } - if (msg.type === "stop") { - if (!ctx.isIdle()) { - ctx.abort(); - } - return; - } - if (msg.type === "prompt" && typeof msg.text === "string" && msg.text.trim()) { - const text = msg.text.trim(); - // Sliding-window rate limit - const now = Date.now(); - const cutoff = now - RATE_WINDOW_MS; - 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" }); - } - } - }); + ws.on("message", (data: Buffer) => { + if (data.length > MAX_MSG_BYTES) return; + let msg: { type?: string; text?: string }; + try { + const parsed: unknown = JSON.parse(data.toString()); + if (typeof parsed !== "object" || parsed === null) return; + msg = parsed as { type?: string; text?: string }; + } catch { + return; + } + if (msg.type === "stop") { + if (!ctx.isIdle()) { + ctx.abort(); + } + return; + } + if ( + msg.type === "prompt" && + typeof msg.text === "string" && + msg.text.trim() + ) { + const text = msg.text.trim(); + // Sliding-window rate limit + const now = Date.now(); + const cutoff = now - RATE_WINDOW_MS; + 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 = () => { - clients.delete(ws); - broadcast({ type: "status", clientCount: clients.size }); - for (const cb of clientChangeListeners) cb(); - }; - ws.on("close", onClose); - ws.on("error", onClose); - }); + const onClose = () => { + clients.delete(ws); + broadcast({ type: "status", clientCount: clients.size }); + for (const cb of clientChangeListeners) cb(); + }; + ws.on("close", onClose); + ws.on("error", onClose); + }); - return new Promise((resolve) => { - httpServer.listen(0, "127.0.0.1", () => { - resolve({ - broadcast, - sync, - stop: () => - new Promise((res) => { - // Forcefully kill all WebSocket clients — terminate() sends no - // close frame and doesn't wait for the remote end to acknowledge, - // so it can't hang on an unresponsive client. - for (const client of clients) { - try { - client.terminate(); - } catch { - /* ignore */ - } - } - clients.clear(); + return new Promise((resolve) => { + httpServer.listen(0, "127.0.0.1", () => { + resolve({ + broadcast, + sync, + stop: () => + new Promise((res) => { + // Forcefully kill all WebSocket clients — terminate() sends no + // close frame and doesn't wait for the remote end to acknowledge, + // so it can't hang on an unresponsive client. + for (const client of clients) { + try { + client.terminate(); + } catch { + /* ignore */ + } + } + clients.clear(); - // Safety timeout — if wss/http shutdown callbacks never fire - // (e.g. lingering keep-alive sockets), resolve anyway so the - // session_shutdown handler doesn't block pi from exiting. - const timeout = setTimeout(() => { - httpServer.close(() => {}); - httpServer.closeAllConnections?.(); - res(); - }, 2000); + // Safety timeout — if wss/http shutdown callbacks never fire + // (e.g. lingering keep-alive sockets), resolve anyway so the + // session_shutdown handler doesn't block pi from exiting. + const timeout = setTimeout(() => { + httpServer.close(() => {}); + httpServer.closeAllConnections?.(); + res(); + }, 2000); - wss.close(() => - httpServer.close(() => { - clearTimeout(timeout); - res(); - }), - ); - }), - clientCount: () => clients.size, - onClientChange: (cb: () => void) => { clientChangeListeners.push(cb); }, - get port() { - return (httpServer.address() as any)?.port ?? 0; - }, - get token() { - return token; - }, - }); - }); - }); + wss.close(() => + httpServer.close(() => { + clearTimeout(timeout); + res(); + }), + ); + }), + clientCount: () => clients.size, + onClientChange: (cb: () => void) => { + clientChangeListeners.push(cb); + }, + get port() { + return (httpServer.address() as AddressInfo | null)?.port ?? 0; + }, + get token() { + return token; + }, + }); + }); + }); } diff --git a/package-lock.json b/package-lock.json index fd8bd5e..92ce480 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "ws": "^8.0.0" }, "devDependencies": { + "@biomejs/biome": "^2.4.12", "@types/qrcode": "^1.5.6" }, "peerDependencies": { @@ -785,6 +786,169 @@ "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": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", diff --git a/package.json b/package.json index 846772c..4d0dd72 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,11 @@ "@mariozechner/pi-coding-agent": "*", "@mariozechner/pi-tui": "*" }, + "scripts": { + "lint": "biome check --write ." + }, "devDependencies": { + "@biomejs/biome": "^2.4.12", "@types/qrcode": "^1.5.6" } }