From 8b41a2ac1227784caed3220b02b61a0153f88be5 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 9 May 2026 20:30:40 +0200 Subject: [PATCH] Add Pi package extension --- README.md | 18 +++ eslint.config.js | 2 +- extensions/pi-web.ts | 144 ++++++++++++++++++ package-lock.json | 2 + package.json | 20 ++- src/cli.ts | 18 ++- src/client/src/chatTranscript.ts | 2 +- .../src/controllers/sessionController.ts | 2 +- tsconfig.json | 17 ++- 9 files changed, 211 insertions(+), 14 deletions(-) create mode 100644 extensions/pi-web.ts diff --git a/README.md b/README.md index dca28c8..7b92432 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,24 @@ One-line install is also available for users who prefer it: curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/install.sh | sh ``` +Pi Web is also published as a Pi package. Installing it through Pi exposes a `/pi-web` command inside Pi: + +```bash +pi install npm:@jmfederico/pi-web +``` + +Then in Pi: + +```text +/pi-web install +/pi-web status +/pi-web logs +/pi-web restart +/pi-web doctor +``` + +The Pi command is a convenience wrapper around the same service installer. `/pi-web logs` shows the last 100 journal lines; use `pi-web logs` in a shell when you want to follow logs continuously. + Advanced users may run the binaries however they prefer: ```bash diff --git a/eslint.config.js b/eslint.config.js index 6fe4e89..75e5487 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -8,7 +8,7 @@ export default defineConfig([ ignores: ["dist/**", "node_modules/**"], }, { - files: ["src/**/*.ts", "vite.config.ts", "vitest.config.ts"], + files: ["src/**/*.ts", "extensions/**/*.ts", "vite.config.ts", "vitest.config.ts"], extends: [ js.configs.recommended, tseslint.configs.strictTypeChecked, diff --git a/extensions/pi-web.ts b/extensions/pi-web.ts new file mode 100644 index 0000000..b41b704 --- /dev/null +++ b/extensions/pi-web.ts @@ -0,0 +1,144 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +const packageRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const cliPath = join(packageRoot, "dist", "cli.js"); +const serverPath = join(packageRoot, "dist", "server", "index.js"); +const sessiondPath = join(packageRoot, "dist", "server", "sessiond.js"); +const serviceNames = ["pi-web-sessiond.service", "pi-web.service"]; + +const subcommands = [ + "install", + "status", + "logs", + "restart", + "start", + "stop", + "doctor", + "uninstall", + "open", + "help", +] as const; + +type Subcommand = (typeof subcommands)[number]; + +function shellSingleQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function nodeCommand(scriptPath: string): string { + return `${shellSingleQuote(process.execPath)} ${shellSingleQuote(scriptPath)}`; +} + +function parseArgs(args: string): string[] { + return args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g)?.map((part) => { + if ((part.startsWith('"') && part.endsWith('"')) || (part.startsWith("'") && part.endsWith("'"))) { + return part.slice(1, -1); + } + return part; + }) ?? []; +} + +function truncateOutput(output: string): string { + const trimmed = output.trim(); + if (trimmed.length <= 3_500) return trimmed; + return `${trimmed.slice(0, 3_500)}\n… output truncated`; +} + +function run(command: string, args: string[], env: NodeJS.ProcessEnv = {}): Promise<{ code: number; output: string }> { + return new Promise((resolve) => { + const child = spawn(command, args, { + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"], + }); + + let output = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { output += chunk; }); + child.stderr.on("data", (chunk: string) => { output += chunk; }); + child.on("error", (error) => { + resolve({ code: 1, output: error.message }); + }); + child.on("close", (code) => { + resolve({ code: code ?? 1, output }); + }); + }); +} + +async function runPiWeb(args: string[], env: NodeJS.ProcessEnv = {}): Promise<{ code: number; output: string }> { + if (existsSync(cliPath)) { + return run(process.execPath, [cliPath, ...args], env); + } + return run("pi-web", args, env); +} + +function showResult(ctx: { ui: { notify(message: string, type?: "info" | "warning" | "error" | "success"): void } }, title: string, result: { code: number; output: string }): void { + const body = truncateOutput(result.output) || (result.code === 0 ? "Done." : `Command failed with exit code ${String(result.code)}.`); + ctx.ui.notify(`${title}\n\n${body}`, result.code === 0 ? "info" : "error"); +} + +function isSubcommand(value: string): value is Subcommand { + return subcommands.some((command) => command === value); +} + +function installEnv(): NodeJS.ProcessEnv { + if (!existsSync(serverPath) || !existsSync(sessiondPath)) return {}; + return { + PI_WEB_SERVER_EXEC: nodeCommand(serverPath), + PI_WEB_SESSIOND_EXEC: nodeCommand(sessiondPath), + }; +} + +async function boundedLogs(): Promise<{ code: number; output: string }> { + return run("journalctl", ["--user", "-u", serviceNames[0] ?? "", "-u", serviceNames[1] ?? "", "-n", "100", "--no-pager"]); +} + +export default function piWebExtension(pi: ExtensionAPI): void { + pi.registerCommand("pi-web", { + description: "Manage Pi Web services: install, status, logs, restart, start, stop, doctor, open", + getArgumentCompletions(prefix: string): { value: string; label: string }[] | null { + const [first = ""] = parseArgs(prefix); + const items = subcommands + .filter((command) => command.startsWith(first)) + .map((command) => ({ value: command, label: command })); + return items.length > 0 ? items : null; + }, + async handler(args, ctx) { + const parsedArgs = parseArgs(args); + const subcommand = parsedArgs[0] ?? "help"; + const rest = parsedArgs.slice(1); + + if (subcommand === "help") { + ctx.ui.notify(`Pi Web commands:\n\n${subcommands.map((command) => `/pi-web ${command}`).join("\n")}\n\nLogs are bounded to the last 100 journal lines in the Pi command. Use \`pi-web logs\` in a shell to follow logs.`, "info"); + return; + } + + if (subcommand === "open") { + ctx.ui.notify("Pi Web default URL: http://127.0.0.1:8504", "info"); + return; + } + + if (!isSubcommand(subcommand)) { + ctx.ui.notify(`Unknown pi-web command: ${subcommand}. Try /pi-web help.`, "error"); + return; + } + + if (subcommand === "stop" || subcommand === "uninstall") { + const ok = await ctx.ui.confirm(`pi-web ${subcommand}`, `Run pi-web ${subcommand}?`); + if (!ok) return; + } + + if (subcommand === "logs") { + showResult(ctx, "pi-web logs", await boundedLogs()); + return; + } + + const env = subcommand === "install" ? installEnv() : {}; + showResult(ctx, `pi-web ${subcommand}`, await runPiWeb([subcommand, ...rest], env)); + }, + }); +} diff --git a/package-lock.json b/package-lock.json index 92a8cc1..5d192b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "pi-web-sessiond": "dist/server/sessiond.js" }, "devDependencies": { + "@earendil-works/pi-ai": "^0.74.0", "@earendil-works/pi-coding-agent": "^0.74.0", "@eslint/js": "^10.0.1", "@types/node": "^24.10.1", @@ -51,6 +52,7 @@ "node": ">=22" }, "peerDependencies": { + "@earendil-works/pi-ai": ">=0.74.0 <1", "@earendil-works/pi-coding-agent": ">=0.74.0 <1" } }, diff --git a/package.json b/package.json index e164e17..83ef7b2 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "dist", "install.sh", "README.md", - "LICENSE" + "LICENSE", + "extensions" ], "scripts": { "dev": "bash -c 'trap \"kill 0\" EXIT; npm run dev:sessiond & npm run dev:web & npm run dev:client & wait'", @@ -22,7 +23,7 @@ "dev:client": "vite --host 0.0.0.0", "build": "tsc -p tsconfig.build.json && vite build", "typecheck": "tsc --noEmit", - "lint": "eslint \"src/**/*.ts\" vite.config.ts vitest.config.ts", + "lint": "eslint \"src/**/*.ts\" \"extensions/**/*.ts\" vite.config.ts vitest.config.ts", "test": "vitest run --config vitest.config.ts", "verify": "npm run typecheck && npm run lint && npm test", "start": "tsx src/server/index.ts", @@ -65,7 +66,8 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.59.2", "vite": "^7.2.4", - "vitest": "^4.1.5" + "vitest": "^4.1.5", + "@earendil-works/pi-ai": "^0.74.0" }, "publishConfig": { "access": "public" @@ -83,6 +85,16 @@ "homepage": "https://github.com/jmfederico/pi-web#readme", "packageManager": "npm@11.11.0", "peerDependencies": { - "@earendil-works/pi-coding-agent": ">=0.74.0 <1" + "@earendil-works/pi-coding-agent": ">=0.74.0 <1", + "@earendil-works/pi-ai": ">=0.74.0 <1" + }, + "keywords": [ + "pi-package" + ], + "pi": { + "extensions": [ + "./extensions" + ], + "image": "https://raw.githubusercontent.com/jmfederico/pi-web/main/docs/assets/pi-web-banner.png" } } diff --git a/src/cli.ts b/src/cli.ts index 3b60611..87297c9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -85,13 +85,23 @@ function systemdEscape(value: string): string { return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); } +function sessiondExec(): string { + const configured = process.env["PI_WEB_SESSIOND_EXEC"]?.trim(); + return configured === undefined || configured === "" ? "pi-web-sessiond" : configured; +} + +function webExec(): string { + const configured = process.env["PI_WEB_SERVER_EXEC"]?.trim(); + return configured === undefined || configured === "" ? "pi-web-server" : configured; +} + function sessiondUnit(): string { return `[Unit] Description=Pi Web session daemon [Service] Type=simple -ExecStart=/usr/bin/env bash -lc ${shellSingleQuote("exec pi-web-sessiond")} +ExecStart=/usr/bin/env bash -lc ${shellSingleQuote(`exec ${sessiondExec()}`)} Restart=on-failure RestartSec=2 @@ -109,7 +119,7 @@ Wants=${sessiondServiceName} [Service] Type=simple -${configEnvironment}ExecStart=/usr/bin/env bash -lc ${shellSingleQuote("exec pi-web-server")} +${configEnvironment}ExecStart=/usr/bin/env bash -lc ${shellSingleQuote(`exec ${webExec()}`)} Restart=on-failure RestartSec=2 @@ -130,8 +140,8 @@ async function writeInitialConfig(options: InstallOptions): Promise { async function install(args: string[]): Promise { const options = parseInstallOptions(args); if (!hasCommand("systemctl")) throw new Error("systemctl was not found in a bash login shell"); - if (!hasCommand("pi-web-server")) throw new Error("pi-web-server was not found in a bash login shell. Is pi-web installed globally?"); - if (!hasCommand("pi-web-sessiond")) throw new Error("pi-web-sessiond was not found in a bash login shell. Is pi-web installed globally?"); + if (process.env["PI_WEB_SERVER_EXEC"] === undefined && !hasCommand("pi-web-server")) throw new Error("pi-web-server was not found in a bash login shell. Is pi-web installed globally?"); + if (process.env["PI_WEB_SESSIOND_EXEC"] === undefined && !hasCommand("pi-web-sessiond")) throw new Error("pi-web-sessiond was not found in a bash login shell. Is pi-web installed globally?"); const configPath = await writeInitialConfig(options); diff --git a/src/client/src/chatTranscript.ts b/src/client/src/chatTranscript.ts index 7d5980f..6cdcdb2 100644 --- a/src/client/src/chatTranscript.ts +++ b/src/client/src/chatTranscript.ts @@ -19,7 +19,7 @@ export function applyTranscriptEvent(messages: ChatLine[], event: SessionUiEvent function applyMessageEndMeta(messages: ChatLine[], rawMessage: unknown): ChatLine[] | undefined { const ended = normalizeMessage(rawMessage)[0]; - if (ended === undefined || ended.meta === undefined) return undefined; + if (ended?.meta === undefined) return undefined; const index = findLastMatchingRole(messages, ended.role); if (index < 0) return undefined; return messages.map((message, i) => i === index ? withMessageMeta(message, rawMessage) : message); diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index bac06e5..3c90bae 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -77,7 +77,7 @@ export class SessionController { const [page, status] = await Promise.all([api.messages(session.id, { limit: MESSAGE_PAGE_SIZE }), api.status(session.id)]); if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return; const history = this.mergeAndCacheHistory(session.id, page); - const isReceivingPartialStream = status.isStreaming === true; + const isReceivingPartialStream = status.isStreaming; this.catchupStreamSessionId = isReceivingPartialStream ? session.id : undefined; this.setState({ messages: normalizeMessages(history.messages), messagePageStart: history.start, messagePageTotal: history.total, isLoadingEarlierMessages: false, isReceivingPartialStream, status, activity: this.getState().sessionActivities[session.id] }); this.applyStatus(status); diff --git a/tsconfig.json b/tsconfig.json index 3dd19ca..750b19a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,8 +17,19 @@ "skipLibCheck": true, "experimentalDecorators": true, "useDefineForClassFields": false, - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "types": ["node"] + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "types": [ + "node" + ] }, - "include": ["src/**/*.ts", "vite.config.ts", "vitest.config.ts"] + "include": [ + "src/**/*.ts", + "vite.config.ts", + "vitest.config.ts", + "extensions/**/*.ts" + ] }