Add Pi package extension

This commit is contained in:
Federico Jaramillo Martinez
2026-05-09 20:30:40 +02:00
parent fc24e06324
commit 8b41a2ac12
9 changed files with 211 additions and 14 deletions
+18
View File
@@ -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 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: Advanced users may run the binaries however they prefer:
```bash ```bash
+1 -1
View File
@@ -8,7 +8,7 @@ export default defineConfig([
ignores: ["dist/**", "node_modules/**"], 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: [ extends: [
js.configs.recommended, js.configs.recommended,
tseslint.configs.strictTypeChecked, tseslint.configs.strictTypeChecked,
+144
View File
@@ -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));
},
});
}
+2
View File
@@ -35,6 +35,7 @@
"pi-web-sessiond": "dist/server/sessiond.js" "pi-web-sessiond": "dist/server/sessiond.js"
}, },
"devDependencies": { "devDependencies": {
"@earendil-works/pi-ai": "^0.74.0",
"@earendil-works/pi-coding-agent": "^0.74.0", "@earendil-works/pi-coding-agent": "^0.74.0",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
@@ -51,6 +52,7 @@
"node": ">=22" "node": ">=22"
}, },
"peerDependencies": { "peerDependencies": {
"@earendil-works/pi-ai": ">=0.74.0 <1",
"@earendil-works/pi-coding-agent": ">=0.74.0 <1" "@earendil-works/pi-coding-agent": ">=0.74.0 <1"
} }
}, },
+16 -4
View File
@@ -12,7 +12,8 @@
"dist", "dist",
"install.sh", "install.sh",
"README.md", "README.md",
"LICENSE" "LICENSE",
"extensions"
], ],
"scripts": { "scripts": {
"dev": "bash -c 'trap \"kill 0\" EXIT; npm run dev:sessiond & npm run dev:web & npm run dev:client & wait'", "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", "dev:client": "vite --host 0.0.0.0",
"build": "tsc -p tsconfig.build.json && vite build", "build": "tsc -p tsconfig.build.json && vite build",
"typecheck": "tsc --noEmit", "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", "test": "vitest run --config vitest.config.ts",
"verify": "npm run typecheck && npm run lint && npm test", "verify": "npm run typecheck && npm run lint && npm test",
"start": "tsx src/server/index.ts", "start": "tsx src/server/index.ts",
@@ -65,7 +66,8 @@
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.59.2", "typescript-eslint": "^8.59.2",
"vite": "^7.2.4", "vite": "^7.2.4",
"vitest": "^4.1.5" "vitest": "^4.1.5",
"@earendil-works/pi-ai": "^0.74.0"
}, },
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
@@ -83,6 +85,16 @@
"homepage": "https://github.com/jmfederico/pi-web#readme", "homepage": "https://github.com/jmfederico/pi-web#readme",
"packageManager": "[email protected]", "packageManager": "[email protected]",
"peerDependencies": { "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"
} }
} }
+14 -4
View File
@@ -85,13 +85,23 @@ function systemdEscape(value: string): string {
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); 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 { function sessiondUnit(): string {
return `[Unit] return `[Unit]
Description=Pi Web session daemon Description=Pi Web session daemon
[Service] [Service]
Type=simple 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 Restart=on-failure
RestartSec=2 RestartSec=2
@@ -109,7 +119,7 @@ Wants=${sessiondServiceName}
[Service] [Service]
Type=simple 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 Restart=on-failure
RestartSec=2 RestartSec=2
@@ -130,8 +140,8 @@ async function writeInitialConfig(options: InstallOptions): Promise<string> {
async function install(args: string[]): Promise<void> { async function install(args: string[]): Promise<void> {
const options = parseInstallOptions(args); const options = parseInstallOptions(args);
if (!hasCommand("systemctl")) throw new Error("systemctl was not found in a bash login shell"); 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 (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 (!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_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); const configPath = await writeInitialConfig(options);
+1 -1
View File
@@ -19,7 +19,7 @@ export function applyTranscriptEvent(messages: ChatLine[], event: SessionUiEvent
function applyMessageEndMeta(messages: ChatLine[], rawMessage: unknown): ChatLine[] | undefined { function applyMessageEndMeta(messages: ChatLine[], rawMessage: unknown): ChatLine[] | undefined {
const ended = normalizeMessage(rawMessage)[0]; 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); const index = findLastMatchingRole(messages, ended.role);
if (index < 0) return undefined; if (index < 0) return undefined;
return messages.map((message, i) => i === index ? withMessageMeta(message, rawMessage) : message); return messages.map((message, i) => i === index ? withMessageMeta(message, rawMessage) : message);
@@ -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)]); 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; if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return;
const history = this.mergeAndCacheHistory(session.id, page); const history = this.mergeAndCacheHistory(session.id, page);
const isReceivingPartialStream = status.isStreaming === true; const isReceivingPartialStream = status.isStreaming;
this.catchupStreamSessionId = isReceivingPartialStream ? session.id : undefined; 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.setState({ messages: normalizeMessages(history.messages), messagePageStart: history.start, messagePageTotal: history.total, isLoadingEarlierMessages: false, isReceivingPartialStream, status, activity: this.getState().sessionActivities[session.id] });
this.applyStatus(status); this.applyStatus(status);
+14 -3
View File
@@ -17,8 +17,19 @@
"skipLibCheck": true, "skipLibCheck": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"useDefineForClassFields": false, "useDefineForClassFields": false,
"lib": ["ES2022", "DOM", "DOM.Iterable"], "lib": [
"types": ["node"] "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"
]
} }