chore(remote-control): add Biome and fix all lint warnings

This commit is contained in:
Yejun Su 2026-04-21 14:09:42 +08:00
parent 06fa1147f3
commit 82c463ec27
No known key found for this signature in database
GPG Key ID: AD03A563F321CA44
9 changed files with 913 additions and 590 deletions

34
biome.json Normal file
View File

@ -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"
}
}
}
}

View File

@ -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;
} }

View File

@ -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",
);
} }

View File

@ -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">

View File

@ -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);
}
},
});
} }

View File

@ -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(),
}, },
}; };
} }

View File

@ -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;
}); },
}); });
});
});
} }

164
package-lock.json generated
View File

@ -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",

View File

@ -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"
} }
} }