@firstpick/pi-package-webui 0.1.0 → 0.1.1

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 CHANGED
@@ -1,5 +1,6 @@
1
- import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
1
+ import { spawn, type ChildProcessByStdio } from "node:child_process";
2
2
  import path from "node:path";
3
+ import type { Readable } from "node:stream";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
5
6
 
@@ -20,6 +21,14 @@ type StartWebuiOptions = {
20
21
  piArgs: string[];
21
22
  };
22
23
 
24
+ type ExistingWebui = {
25
+ webuiVersion?: string;
26
+ webuiPid?: number;
27
+ piPid?: number;
28
+ };
29
+
30
+ type WebuiChild = ChildProcessByStdio<null, Readable, Readable>;
31
+
23
32
  function tokenizeArgs(input: string): string[] {
24
33
  const tokens: string[] = [];
25
34
  let current = "";
@@ -125,20 +134,145 @@ function urlFor(options: StartWebuiOptions): string {
125
134
  return `http://${host}:${options.port}/`;
126
135
  }
127
136
 
128
- async function probeExistingWebui(url: string): Promise<boolean> {
137
+ async function fetchJsonWithTimeout(url: string, init: RequestInit = {}, timeoutMs = 900): Promise<{ ok: boolean; status: number; body: any } | null> {
129
138
  const controller = new AbortController();
130
- const timeout = setTimeout(() => controller.abort(), 900);
139
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
131
140
  try {
132
- const response = await fetch(`${url.replace(/\/$/, "")}/api/health`, { signal: controller.signal });
141
+ const response = await fetch(url, { ...init, signal: controller.signal });
133
142
  const body = await response.json().catch(() => undefined);
134
- return response.ok && body?.ok === true && typeof body.webuiVersion === "string";
143
+ return { ok: response.ok, status: response.status, body };
135
144
  } catch {
136
- return false;
145
+ return null;
137
146
  } finally {
138
147
  clearTimeout(timeout);
139
148
  }
140
149
  }
141
150
 
151
+ async function probeExistingWebui(url: string): Promise<ExistingWebui | null> {
152
+ const result = await fetchJsonWithTimeout(`${url.replace(/\/$/, "")}/api/health`);
153
+ const body = result?.body;
154
+ if (!result?.ok || body?.ok !== true || typeof body.webuiVersion !== "string") return null;
155
+ return body;
156
+ }
157
+
158
+ function sleep(ms: number): Promise<void> {
159
+ return new Promise((resolve) => setTimeout(resolve, ms));
160
+ }
161
+
162
+ async function waitForWebuiToStop(url: string, timeoutMs = 7_000): Promise<boolean> {
163
+ const deadline = Date.now() + timeoutMs;
164
+ while (Date.now() < deadline) {
165
+ if (!(await probeExistingWebui(url))) return true;
166
+ await sleep(180);
167
+ }
168
+ return !(await probeExistingWebui(url));
169
+ }
170
+
171
+ async function requestWebuiShutdown(url: string): Promise<boolean> {
172
+ const result = await fetchJsonWithTimeout(`${url.replace(/\/$/, "")}/api/shutdown`, { method: "POST" }, 1_500);
173
+ return result?.ok === true && result.body?.ok === true;
174
+ }
175
+
176
+ function isProcessRunning(pid: number): boolean {
177
+ try {
178
+ process.kill(pid, 0);
179
+ return true;
180
+ } catch (error) {
181
+ return (error as NodeJS.ErrnoException)?.code === "EPERM";
182
+ }
183
+ }
184
+
185
+ async function terminatePid(pid: number): Promise<void> {
186
+ if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid || !isProcessRunning(pid)) return;
187
+ try {
188
+ process.kill(pid, "SIGTERM");
189
+ } catch {
190
+ return;
191
+ }
192
+
193
+ const deadline = Date.now() + 4_000;
194
+ while (Date.now() < deadline) {
195
+ if (!isProcessRunning(pid)) return;
196
+ await sleep(160);
197
+ }
198
+
199
+ try {
200
+ if (isProcessRunning(pid)) process.kill(pid, "SIGKILL");
201
+ } catch {
202
+ // Ignore kill races; the restart path verifies the port separately.
203
+ }
204
+ }
205
+
206
+ function runCommand(command: string, args: string[], timeoutMs = 1_500): Promise<{ exitCode?: number; stdout: string; stderr: string }> {
207
+ return new Promise((resolve) => {
208
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
209
+ let stdout = "";
210
+ let stderr = "";
211
+ let settled = false;
212
+ const finish = (result: { exitCode?: number; stdout: string; stderr: string }) => {
213
+ if (settled) return;
214
+ settled = true;
215
+ clearTimeout(timeout);
216
+ resolve(result);
217
+ };
218
+ const timeout = setTimeout(() => {
219
+ child.kill("SIGKILL");
220
+ finish({ stdout, stderr });
221
+ }, timeoutMs);
222
+ child.stdout.on("data", (chunk) => {
223
+ stdout += String(chunk);
224
+ if (stdout.length > 100_000) stdout = stdout.slice(-100_000);
225
+ });
226
+ child.stderr.on("data", (chunk) => {
227
+ stderr += String(chunk);
228
+ if (stderr.length > 20_000) stderr = stderr.slice(-20_000);
229
+ });
230
+ child.on("error", (error) => finish({ stdout, stderr: stderr || (error instanceof Error ? error.message : String(error)) }));
231
+ child.on("exit", (exitCode) => finish({ exitCode: exitCode ?? undefined, stdout, stderr }));
232
+ });
233
+ }
234
+
235
+ function commandLooksLikeWebui(command: string, options: StartWebuiOptions): boolean {
236
+ if (!command.includes("pi-webui.mjs")) return false;
237
+ const escapedPort = String(options.port).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
238
+ return new RegExp(`(?:^|\\s)--port\\s+${escapedPort}(?:\\s|$)`).test(command);
239
+ }
240
+
241
+ async function findWebuiPidsByCommand(options: StartWebuiOptions): Promise<number[]> {
242
+ if (process.platform === "win32") return [];
243
+ let result = await runCommand("ps", ["-Ao", "pid=,args="], 1_500);
244
+ if (result.exitCode !== 0) result = await runCommand("ps", ["-eo", "pid=,args="], 1_500);
245
+ if (result.exitCode !== 0) return [];
246
+
247
+ const pids: number[] = [];
248
+ for (const line of result.stdout.split(/\r?\n/)) {
249
+ const match = line.match(/^\s*(\d+)\s+(.+)$/);
250
+ if (!match) continue;
251
+ const pid = Number.parseInt(match[1], 10);
252
+ const command = match[2];
253
+ if (pid !== process.pid && commandLooksLikeWebui(command, options)) pids.push(pid);
254
+ }
255
+ return [...new Set(pids)];
256
+ }
257
+
258
+ async function stopExistingWebui(url: string, options: StartWebuiOptions, existing: ExistingWebui): Promise<void> {
259
+ if (await requestWebuiShutdown(url)) {
260
+ if (await waitForWebuiToStop(url)) return;
261
+ }
262
+
263
+ if (Number.isInteger(existing.webuiPid)) {
264
+ await terminatePid(existing.webuiPid!);
265
+ if (await waitForWebuiToStop(url)) return;
266
+ }
267
+
268
+ for (const pid of await findWebuiPidsByCommand(options)) {
269
+ await terminatePid(pid);
270
+ }
271
+ if (await waitForWebuiToStop(url)) return;
272
+
273
+ throw new Error(`Existing Pi Web UI is still running at ${url}. Stop it manually and retry.`);
274
+ }
275
+
142
276
  function openDefaultBrowser(url: string): void {
143
277
  let command: string;
144
278
  let args: string[];
@@ -158,15 +292,15 @@ function openDefaultBrowser(url: string): void {
158
292
  child.unref();
159
293
  }
160
294
 
161
- function releaseStartedChild(child: ChildProcessWithoutNullStreams): void {
295
+ function releaseStartedChild(child: WebuiChild): void {
162
296
  child.stdout.removeAllListeners("data");
163
297
  child.stderr.removeAllListeners("data");
164
- child.stdout.unref?.();
165
- child.stderr.unref?.();
298
+ (child.stdout as Readable & { unref?: () => void }).unref?.();
299
+ (child.stderr as Readable & { unref?: () => void }).unref?.();
166
300
  child.unref();
167
301
  }
168
302
 
169
- function terminateFailedChild(child: ChildProcessWithoutNullStreams): void {
303
+ function terminateFailedChild(child: WebuiChild): void {
170
304
  if (child.exitCode === null) child.kill("SIGTERM");
171
305
  setTimeout(() => {
172
306
  if (child.exitCode === null) child.kill("SIGKILL");
@@ -175,7 +309,7 @@ function terminateFailedChild(child: ChildProcessWithoutNullStreams): void {
175
309
  child.stderr.destroy();
176
310
  }
177
311
 
178
- function waitForWebuiUrl(child: ChildProcessWithoutNullStreams): Promise<string> {
312
+ function waitForWebuiUrl(child: WebuiChild): Promise<string> {
179
313
  return new Promise((resolve, reject) => {
180
314
  let settled = false;
181
315
  let output = "";
@@ -249,17 +383,15 @@ export default function (pi: ExtensionAPI) {
249
383
  const url = urlFor(options);
250
384
  ctx.ui.setStatus("pi-webui", "starting webui…");
251
385
  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;
386
+ const existing = await probeExistingWebui(url);
387
+ if (existing) {
388
+ ctx.ui.setStatus("pi-webui", "restarting existing webui…");
389
+ await stopExistingWebui(url, options, existing);
258
390
  }
259
391
 
260
392
  const startedUrl = await startWebui(options, ctx);
261
393
  if (options.open) openDefaultBrowser(startedUrl);
262
- ctx.ui.notify(`Pi Web UI started:\n${startedUrl}`, "info");
394
+ ctx.ui.notify(`${existing ? "Pi Web UI restarted" : "Pi Web UI started"}:\n${startedUrl}`, "info");
263
395
  ctx.ui.setStatus("pi-webui", startedUrl);
264
396
  setTimeout(() => ctx.ui.setStatus("pi-webui", ""), 20_000).unref?.();
265
397
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstpick/pi-package-webui",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Pi Web UI companion package with a local browser UI CLI and /start-webui command.",
5
5
  "license": "MIT",
6
6
  "type": "module",