Archived
Add Pi package extension
This commit is contained in:
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
Generated
+2
@@ -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
@@ -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
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user