@firstpick/pi-package-webui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts ADDED
@@ -0,0 +1,271 @@
1
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+ const packageRoot = __dirname;
8
+ const webuiBin = path.join(packageRoot, "bin", "pi-webui.mjs");
9
+
10
+ const DEFAULT_HOST = "127.0.0.1";
11
+ const DEFAULT_PORT = 31415;
12
+ const START_TIMEOUT_MS = 12_000;
13
+
14
+ type StartWebuiOptions = {
15
+ host: string;
16
+ port: number;
17
+ open: boolean;
18
+ noSession: boolean;
19
+ name?: string;
20
+ piArgs: string[];
21
+ };
22
+
23
+ function tokenizeArgs(input: string): string[] {
24
+ const tokens: string[] = [];
25
+ let current = "";
26
+ let quote: '"' | "'" | undefined;
27
+ let escaped = false;
28
+
29
+ for (const char of input) {
30
+ if (escaped) {
31
+ current += char;
32
+ escaped = false;
33
+ continue;
34
+ }
35
+ if (char === "\\") {
36
+ escaped = true;
37
+ continue;
38
+ }
39
+ if (quote) {
40
+ if (char === quote) quote = undefined;
41
+ else current += char;
42
+ continue;
43
+ }
44
+ if (char === '"' || char === "'") {
45
+ quote = char;
46
+ continue;
47
+ }
48
+ if (/\s/.test(char)) {
49
+ if (current) {
50
+ tokens.push(current);
51
+ current = "";
52
+ }
53
+ continue;
54
+ }
55
+ current += char;
56
+ }
57
+
58
+ if (escaped) current += "\\";
59
+ if (quote) throw new Error(`Unclosed ${quote} quote`);
60
+ if (current) tokens.push(current);
61
+ return tokens;
62
+ }
63
+
64
+ function takeValue(tokens: string[], index: number, flag: string): string {
65
+ const value = tokens[index + 1];
66
+ if (!value || value.startsWith("--")) throw new Error(`${flag} requires a value`);
67
+ return value;
68
+ }
69
+
70
+ function parseStartWebuiArgs(args: string): StartWebuiOptions {
71
+ const options: StartWebuiOptions = {
72
+ host: DEFAULT_HOST,
73
+ port: DEFAULT_PORT,
74
+ open: true,
75
+ noSession: false,
76
+ piArgs: [],
77
+ };
78
+ const tokens = tokenizeArgs(args || "");
79
+
80
+ for (let i = 0; i < tokens.length; i++) {
81
+ const token = tokens[i];
82
+ if (token === "--") {
83
+ options.piArgs.push(...tokens.slice(i + 1));
84
+ break;
85
+ }
86
+ if (token === "--no-open") {
87
+ options.open = false;
88
+ continue;
89
+ }
90
+ if (token === "--no-session") {
91
+ options.noSession = true;
92
+ continue;
93
+ }
94
+ if (token === "--host") {
95
+ options.host = takeValue(tokens, i, token);
96
+ i++;
97
+ continue;
98
+ }
99
+ if (token === "--port") {
100
+ const port = Number.parseInt(takeValue(tokens, i, token), 10);
101
+ if (!Number.isFinite(port) || port <= 0 || port > 65535) throw new Error("--port must be between 1 and 65535");
102
+ options.port = port;
103
+ i++;
104
+ continue;
105
+ }
106
+ if (token === "--name") {
107
+ options.name = takeValue(tokens, i, token);
108
+ i++;
109
+ continue;
110
+ }
111
+ if (/^\d+$/.test(token)) {
112
+ const port = Number.parseInt(token, 10);
113
+ if (!Number.isFinite(port) || port <= 0 || port > 65535) throw new Error("port must be between 1 and 65535");
114
+ options.port = port;
115
+ continue;
116
+ }
117
+ throw new Error(`Unknown option: ${token}`);
118
+ }
119
+
120
+ return options;
121
+ }
122
+
123
+ function urlFor(options: StartWebuiOptions): string {
124
+ const host = options.host.includes(":") && !options.host.startsWith("[") ? `[${options.host}]` : options.host;
125
+ return `http://${host}:${options.port}/`;
126
+ }
127
+
128
+ async function probeExistingWebui(url: string): Promise<boolean> {
129
+ const controller = new AbortController();
130
+ const timeout = setTimeout(() => controller.abort(), 900);
131
+ try {
132
+ const response = await fetch(`${url.replace(/\/$/, "")}/api/health`, { signal: controller.signal });
133
+ const body = await response.json().catch(() => undefined);
134
+ return response.ok && body?.ok === true && typeof body.webuiVersion === "string";
135
+ } catch {
136
+ return false;
137
+ } finally {
138
+ clearTimeout(timeout);
139
+ }
140
+ }
141
+
142
+ function openDefaultBrowser(url: string): void {
143
+ let command: string;
144
+ let args: string[];
145
+
146
+ if (process.platform === "win32") {
147
+ command = "cmd";
148
+ args = ["/c", "start", "", url];
149
+ } else if (process.platform === "darwin") {
150
+ command = "open";
151
+ args = [url];
152
+ } else {
153
+ command = "xdg-open";
154
+ args = [url];
155
+ }
156
+
157
+ const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true });
158
+ child.unref();
159
+ }
160
+
161
+ function releaseStartedChild(child: ChildProcessWithoutNullStreams): void {
162
+ child.stdout.removeAllListeners("data");
163
+ child.stderr.removeAllListeners("data");
164
+ child.stdout.unref?.();
165
+ child.stderr.unref?.();
166
+ child.unref();
167
+ }
168
+
169
+ function terminateFailedChild(child: ChildProcessWithoutNullStreams): void {
170
+ if (child.exitCode === null) child.kill("SIGTERM");
171
+ setTimeout(() => {
172
+ if (child.exitCode === null) child.kill("SIGKILL");
173
+ }, 2000).unref?.();
174
+ child.stdout.destroy();
175
+ child.stderr.destroy();
176
+ }
177
+
178
+ function waitForWebuiUrl(child: ChildProcessWithoutNullStreams): Promise<string> {
179
+ return new Promise((resolve, reject) => {
180
+ let settled = false;
181
+ let output = "";
182
+ const finish = (error: Error | null, url?: string) => {
183
+ if (settled) return;
184
+ settled = true;
185
+ clearTimeout(timeout);
186
+ if (url) releaseStartedChild(child);
187
+ if (error) {
188
+ terminateFailedChild(child);
189
+ reject(error);
190
+ } else resolve(url!);
191
+ };
192
+
193
+ const inspect = (chunk: Buffer | string) => {
194
+ output += String(chunk);
195
+ if (output.length > 20_000) output = output.slice(-20_000);
196
+ const match = output.match(/Pi Web UI:\s+(https?:\/\/\S+)/);
197
+ if (match?.[1]) finish(null, match[1]);
198
+ };
199
+
200
+ const timeout = setTimeout(() => {
201
+ finish(new Error(`Timed out waiting for Pi Web UI to start. Output:\n${output.trim() || "(no output)"}`));
202
+ }, START_TIMEOUT_MS);
203
+
204
+ child.stdout.on("data", inspect);
205
+ child.stderr.on("data", inspect);
206
+ child.on("error", (error) => finish(error));
207
+ child.on("exit", (code, signal) => {
208
+ if (!settled) finish(new Error(`Pi Web UI exited before startup (${code ?? signal ?? "unknown"}). Output:\n${output.trim() || "(no output)"}`));
209
+ });
210
+ });
211
+ }
212
+
213
+ async function startWebui(options: StartWebuiOptions, ctx: ExtensionCommandContext): Promise<string> {
214
+ const args = [webuiBin, "--host", options.host, "--port", String(options.port), "--cwd", ctx.cwd];
215
+ if (options.noSession) args.push("--no-session");
216
+ if (options.name) args.push("--name", options.name);
217
+ if (options.piArgs.length > 0) args.push("--", ...options.piArgs);
218
+
219
+ const child = spawn(process.execPath, args, {
220
+ cwd: ctx.cwd,
221
+ env: process.env,
222
+ detached: true,
223
+ stdio: ["ignore", "pipe", "pipe"],
224
+ windowsHide: true,
225
+ });
226
+
227
+ return waitForWebuiUrl(child);
228
+ }
229
+
230
+ function usage(): string {
231
+ return [
232
+ "Usage: /start-webui [port] [--port N] [--no-open] [--no-session] [--name NAME] [-- --model provider/model]",
233
+ "Starts the Pi Web UI companion server for the current cwd, prints the localhost URL, and opens it in your default browser.",
234
+ ].join("\n");
235
+ }
236
+
237
+ export default function (pi: ExtensionAPI) {
238
+ pi.registerCommand("start-webui", {
239
+ description: "Start the local Pi browser Web UI and open it",
240
+ handler: async (args, ctx) => {
241
+ let options: StartWebuiOptions;
242
+ try {
243
+ options = parseStartWebuiArgs(args);
244
+ } catch (error) {
245
+ ctx.ui.notify(`${error instanceof Error ? error.message : String(error)}\n${usage()}`, "error");
246
+ return;
247
+ }
248
+
249
+ const url = urlFor(options);
250
+ ctx.ui.setStatus("pi-webui", "starting webui…");
251
+ try {
252
+ if (await probeExistingWebui(url)) {
253
+ if (options.open) openDefaultBrowser(url);
254
+ ctx.ui.notify(`Pi Web UI is already running:\n${url}`, "info");
255
+ ctx.ui.setStatus("pi-webui", url);
256
+ setTimeout(() => ctx.ui.setStatus("pi-webui", ""), 20_000).unref?.();
257
+ return;
258
+ }
259
+
260
+ const startedUrl = await startWebui(options, ctx);
261
+ if (options.open) openDefaultBrowser(startedUrl);
262
+ ctx.ui.notify(`Pi Web UI started:\n${startedUrl}`, "info");
263
+ ctx.ui.setStatus("pi-webui", startedUrl);
264
+ setTimeout(() => ctx.ui.setStatus("pi-webui", ""), 20_000).unref?.();
265
+ } catch (error) {
266
+ ctx.ui.setStatus("pi-webui", "");
267
+ ctx.ui.notify(`Failed to start Pi Web UI:\n${error instanceof Error ? error.message : String(error)}\n${usage()}`, "error");
268
+ }
269
+ },
270
+ });
271
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@firstpick/pi-package-webui",
3
+ "version": "0.1.0",
4
+ "description": "Pi Web UI companion package with a local browser UI CLI and /start-webui command.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "keywords": [
8
+ "pi-package",
9
+ "pi",
10
+ "pi-coding-agent",
11
+ "coding-agent",
12
+ "webui",
13
+ "rpc",
14
+ "extension"
15
+ ],
16
+ "pi": {
17
+ "extensions": [
18
+ "./index.ts"
19
+ ]
20
+ },
21
+ "bin": {
22
+ "pi-webui": "./bin/pi-webui.mjs"
23
+ },
24
+ "dependencies": {
25
+ "@earendil-works/pi-coding-agent": "^0.78.0"
26
+ },
27
+ "files": [
28
+ "index.ts",
29
+ "bin",
30
+ "public",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "engines": {
35
+ "node": ">=22.19.0"
36
+ }
37
+ }