@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.
@@ -0,0 +1,808 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { randomUUID } from "node:crypto";
4
+ import { createServer } from "node:http";
5
+ import { access, readFile, stat } from "node:fs/promises";
6
+ import path from "node:path";
7
+ import { StringDecoder } from "node:string_decoder";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+ const packageRoot = path.resolve(__dirname, "..");
12
+ const publicDir = path.join(packageRoot, "public");
13
+ const packageJson = JSON.parse(await readFile(path.join(packageRoot, "package.json"), "utf8"));
14
+
15
+ const DEFAULT_HOST = "127.0.0.1";
16
+ const DEFAULT_PORT = 31415;
17
+ const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
18
+ const BODY_LIMIT_BYTES = 1024 * 1024;
19
+
20
+ const MIME_TYPES = new Map([
21
+ [".html", "text/html; charset=utf-8"],
22
+ [".js", "text/javascript; charset=utf-8"],
23
+ [".css", "text/css; charset=utf-8"],
24
+ [".svg", "image/svg+xml"],
25
+ ]);
26
+
27
+ function usage() {
28
+ console.log(`pi-webui ${packageJson.version}
29
+
30
+ Pi Web UI companion server for Pi coding agent RPC mode.
31
+
32
+ Usage:
33
+ pi-webui [options] [-- <pi args...>]
34
+
35
+ Options:
36
+ --host <host> HTTP bind host (default: ${DEFAULT_HOST})
37
+ --port <port> HTTP port (default: ${DEFAULT_PORT})
38
+ --cwd <path> Working directory for the Pi session (default: current dir)
39
+ --pi <command> Pi executable to spawn (default: bundled dependency, then "pi")
40
+ --no-session Start Pi RPC with --no-session
41
+ --name <name> Initial Pi session name
42
+ -h, --help Show this help
43
+ -v, --version Print version
44
+
45
+ Examples:
46
+ pi-webui --cwd ~/src/my-project
47
+ pi-webui --port 3000 -- --model anthropic/claude-sonnet-4-5:high
48
+ PI_WEBUI_PI_BIN=/path/to/pi pi-webui --no-session
49
+
50
+ Security:
51
+ The web UI has no authentication and can control Pi tools. It binds to
52
+ localhost by default. Do not expose it on untrusted networks.
53
+ `);
54
+ }
55
+
56
+ function takeValue(argv, index, flag) {
57
+ const value = argv[index + 1];
58
+ if (!value || value.startsWith("--")) {
59
+ throw new Error(`${flag} requires a value`);
60
+ }
61
+ return value;
62
+ }
63
+
64
+ function parseArgs(argv) {
65
+ const options = {
66
+ host: process.env.PI_WEBUI_HOST || DEFAULT_HOST,
67
+ port: Number.parseInt(process.env.PI_WEBUI_PORT || String(DEFAULT_PORT), 10),
68
+ cwd: process.cwd(),
69
+ piBin: process.env.PI_WEBUI_PI_BIN || "pi",
70
+ piBinExplicit: !!process.env.PI_WEBUI_PI_BIN,
71
+ noSession: false,
72
+ name: undefined,
73
+ piArgs: [],
74
+ help: false,
75
+ version: false,
76
+ };
77
+
78
+ for (let i = 0; i < argv.length; i++) {
79
+ const arg = argv[i];
80
+ if (arg === "--") {
81
+ options.piArgs.push(...argv.slice(i + 1));
82
+ break;
83
+ }
84
+ if (arg === "-h" || arg === "--help") {
85
+ options.help = true;
86
+ continue;
87
+ }
88
+ if (arg === "-v" || arg === "--version") {
89
+ options.version = true;
90
+ continue;
91
+ }
92
+ if (arg === "--host") {
93
+ options.host = takeValue(argv, i, arg);
94
+ i++;
95
+ continue;
96
+ }
97
+ if (arg === "--port") {
98
+ const value = Number.parseInt(takeValue(argv, i, arg), 10);
99
+ if (!Number.isFinite(value) || value <= 0 || value > 65535) {
100
+ throw new Error("--port must be a TCP port between 1 and 65535");
101
+ }
102
+ options.port = value;
103
+ i++;
104
+ continue;
105
+ }
106
+ if (arg === "--cwd") {
107
+ options.cwd = path.resolve(takeValue(argv, i, arg));
108
+ i++;
109
+ continue;
110
+ }
111
+ if (arg === "--pi") {
112
+ options.piBin = takeValue(argv, i, arg);
113
+ options.piBinExplicit = true;
114
+ i++;
115
+ continue;
116
+ }
117
+ if (arg === "--no-session") {
118
+ options.noSession = true;
119
+ continue;
120
+ }
121
+ if (arg === "--name") {
122
+ options.name = takeValue(argv, i, arg);
123
+ i++;
124
+ continue;
125
+ }
126
+ throw new Error(`Unknown option: ${arg}. Pass Pi CLI args after --.`);
127
+ }
128
+
129
+ if (!Number.isFinite(options.port) || options.port <= 0 || options.port > 65535) {
130
+ throw new Error("Invalid PI_WEBUI_PORT; expected a TCP port between 1 and 65535");
131
+ }
132
+
133
+ return options;
134
+ }
135
+
136
+ function isLocalHost(host) {
137
+ return host === "localhost" || host === "::1" || host === "[::1]" || host.startsWith("127.");
138
+ }
139
+
140
+ function sanitizeError(error) {
141
+ if (!error) return "Unknown error";
142
+ if (typeof error === "string") return error;
143
+ return error.stack || error.message || String(error);
144
+ }
145
+
146
+ class PiRpcProcess {
147
+ constructor({ command, args, displayCommand, cwd }) {
148
+ this.command = command;
149
+ this.args = args;
150
+ this.displayCommand = displayCommand;
151
+ this.cwd = cwd;
152
+ this.child = undefined;
153
+ this.pending = new Map();
154
+ this.listeners = new Set();
155
+ this.startedAt = new Date().toISOString();
156
+ }
157
+
158
+ start() {
159
+ this.child = spawn(this.command, this.args, {
160
+ cwd: this.cwd,
161
+ env: process.env,
162
+ stdio: ["pipe", "pipe", "pipe"],
163
+ windowsHide: true,
164
+ });
165
+
166
+ this.child.on("error", (error) => {
167
+ const message = sanitizeError(error);
168
+ this.emit({ type: "pi_process_error", error: message });
169
+ this.rejectAll(new Error(message));
170
+ });
171
+
172
+ this.child.on("exit", (code, signal) => {
173
+ this.emit({ type: "pi_process_exit", code, signal });
174
+ this.rejectAll(new Error(`Pi RPC process exited${code === null ? "" : ` with code ${code}`}${signal ? ` (${signal})` : ""}`));
175
+ });
176
+
177
+ this.attachJsonlReader(this.child.stdout, (line) => this.handleStdoutLine(line));
178
+ this.attachTextReader(this.child.stderr, (text) => {
179
+ if (text.length > 0) {
180
+ process.stderr.write(text);
181
+ this.emit({ type: "pi_stderr", text });
182
+ }
183
+ });
184
+
185
+ this.emit({ type: "pi_process_start", pid: this.child.pid, cwd: this.cwd, command: this.displayCommand, args: this.args });
186
+ }
187
+
188
+ onEvent(listener) {
189
+ this.listeners.add(listener);
190
+ return () => this.listeners.delete(listener);
191
+ }
192
+
193
+ emit(event) {
194
+ for (const listener of this.listeners) {
195
+ try {
196
+ listener(event);
197
+ } catch (error) {
198
+ console.error("webui listener failed:", error);
199
+ }
200
+ }
201
+ }
202
+
203
+ attachJsonlReader(stream, onLine) {
204
+ const decoder = new StringDecoder("utf8");
205
+ let buffer = "";
206
+
207
+ stream.on("data", (chunk) => {
208
+ buffer += typeof chunk === "string" ? chunk : decoder.write(chunk);
209
+ while (true) {
210
+ const newlineIndex = buffer.indexOf("\n");
211
+ if (newlineIndex === -1) break;
212
+ let line = buffer.slice(0, newlineIndex);
213
+ buffer = buffer.slice(newlineIndex + 1);
214
+ if (line.endsWith("\r")) line = line.slice(0, -1);
215
+ onLine(line);
216
+ }
217
+ });
218
+
219
+ stream.on("end", () => {
220
+ buffer += decoder.end();
221
+ if (buffer.length > 0) {
222
+ onLine(buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer);
223
+ }
224
+ });
225
+ }
226
+
227
+ attachTextReader(stream, onText) {
228
+ const decoder = new StringDecoder("utf8");
229
+ stream.on("data", (chunk) => onText(typeof chunk === "string" ? chunk : decoder.write(chunk)));
230
+ stream.on("end", () => {
231
+ const tail = decoder.end();
232
+ if (tail) onText(tail);
233
+ });
234
+ }
235
+
236
+ handleStdoutLine(line) {
237
+ if (!line.trim()) return;
238
+
239
+ let event;
240
+ try {
241
+ event = JSON.parse(line);
242
+ } catch (error) {
243
+ this.emit({ type: "pi_stdout_parse_error", line, error: sanitizeError(error) });
244
+ return;
245
+ }
246
+
247
+ if (event?.type === "response" && event.id && this.pending.has(event.id)) {
248
+ const pending = this.pending.get(event.id);
249
+ this.pending.delete(event.id);
250
+ clearTimeout(pending.timeout);
251
+ pending.resolve(event);
252
+ }
253
+
254
+ this.emit(event);
255
+ }
256
+
257
+ send(command, timeoutMs = REQUEST_TIMEOUT_MS) {
258
+ if (!this.child || !this.child.stdin || this.child.exitCode !== null) {
259
+ return Promise.reject(new Error("Pi RPC process is not running"));
260
+ }
261
+
262
+ const id = command.id || randomUUID();
263
+ const payload = { ...command, id };
264
+
265
+ return new Promise((resolve, reject) => {
266
+ const timeout = setTimeout(() => {
267
+ this.pending.delete(id);
268
+ reject(new Error(`Timed out waiting for RPC response to ${command.type}`));
269
+ }, timeoutMs);
270
+
271
+ this.pending.set(id, { resolve, reject, timeout });
272
+ this.writeRaw(payload).catch((error) => {
273
+ clearTimeout(timeout);
274
+ this.pending.delete(id);
275
+ reject(error);
276
+ });
277
+ });
278
+ }
279
+
280
+ async writeRaw(command) {
281
+ if (!this.child || !this.child.stdin || this.child.exitCode !== null) {
282
+ throw new Error("Pi RPC process is not running");
283
+ }
284
+
285
+ const line = `${JSON.stringify(command)}\n`;
286
+ if (!this.child.stdin.write(line)) {
287
+ await new Promise((resolve) => this.child.stdin.once("drain", resolve));
288
+ }
289
+ }
290
+
291
+ rejectAll(error) {
292
+ for (const [id, pending] of this.pending) {
293
+ clearTimeout(pending.timeout);
294
+ pending.reject(error);
295
+ this.pending.delete(id);
296
+ }
297
+ }
298
+
299
+ stop() {
300
+ if (!this.child || this.child.exitCode !== null) return;
301
+ this.child.kill("SIGTERM");
302
+ setTimeout(() => {
303
+ if (this.child && this.child.exitCode === null) this.child.kill("SIGKILL");
304
+ }, 3000).unref();
305
+ }
306
+ }
307
+
308
+ function sendJson(res, statusCode, payload) {
309
+ const body = JSON.stringify(payload, null, 2);
310
+ res.writeHead(statusCode, {
311
+ "content-type": "application/json; charset=utf-8",
312
+ "cache-control": "no-store",
313
+ "x-content-type-options": "nosniff",
314
+ });
315
+ res.end(body);
316
+ }
317
+
318
+ function sendError(res, statusCode, error) {
319
+ sendJson(res, statusCode, { ok: false, error: sanitizeError(error) });
320
+ }
321
+
322
+ async function readJsonBody(req) {
323
+ const chunks = [];
324
+ let size = 0;
325
+ for await (const chunk of req) {
326
+ size += chunk.length;
327
+ if (size > BODY_LIMIT_BYTES) throw new Error("Request body too large");
328
+ chunks.push(chunk);
329
+ }
330
+ if (chunks.length === 0) return {};
331
+ const text = Buffer.concat(chunks).toString("utf8");
332
+ if (!text.trim()) return {};
333
+ return JSON.parse(text);
334
+ }
335
+
336
+ function sendSse(res, event) {
337
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
338
+ }
339
+
340
+ function runCommand(command, args, { cwd, timeoutMs = 2000 } = {}) {
341
+ return new Promise((resolve) => {
342
+ const child = spawn(command, args, {
343
+ cwd,
344
+ stdio: ["ignore", "pipe", "pipe"],
345
+ windowsHide: true,
346
+ });
347
+ let stdout = "";
348
+ let stderr = "";
349
+ let settled = false;
350
+ const finish = (result) => {
351
+ if (settled) return;
352
+ settled = true;
353
+ clearTimeout(timeout);
354
+ resolve(result);
355
+ };
356
+ const timeout = setTimeout(() => {
357
+ child.kill("SIGKILL");
358
+ finish({ exitCode: undefined, stdout, stderr, timedOut: true });
359
+ }, timeoutMs);
360
+ child.stdout.on("data", (chunk) => {
361
+ stdout += String(chunk);
362
+ if (stdout.length > 20000) stdout = stdout.slice(-20000);
363
+ });
364
+ child.stderr.on("data", (chunk) => {
365
+ stderr += String(chunk);
366
+ if (stderr.length > 20000) stderr = stderr.slice(-20000);
367
+ });
368
+ child.on("error", (error) => finish({ exitCode: undefined, stdout, stderr: sanitizeError(error), error: sanitizeError(error) }));
369
+ child.on("exit", (exitCode) => finish({ exitCode, stdout, stderr, timedOut: false }));
370
+ });
371
+ }
372
+
373
+ function displayPath(cwd) {
374
+ const normalized = cwd.replace(/\\/g, "/");
375
+ const home = (process.env.USERPROFILE || process.env.HOME || "").replace(/\\/g, "/");
376
+ if (home && normalized.toLowerCase().startsWith(home.toLowerCase())) {
377
+ return `~${normalized.slice(home.length)}` || "~";
378
+ }
379
+ return normalized;
380
+ }
381
+
382
+ async function getWorkspaceInfo(cwd, startedAt) {
383
+ const info = {
384
+ cwd,
385
+ displayCwd: displayPath(cwd),
386
+ uptimeMs: Math.max(0, Date.now() - Date.parse(startedAt)),
387
+ git: { isRepo: false },
388
+ };
389
+
390
+ const inside = await runCommand("git", ["rev-parse", "--is-inside-work-tree"], { cwd, timeoutMs: 1200 });
391
+ if (inside.exitCode !== 0 || inside.stdout.trim() !== "true") return info;
392
+
393
+ const [branch, status] = await Promise.all([
394
+ runCommand("git", ["branch", "--show-current"], { cwd, timeoutMs: 1200 }),
395
+ runCommand("git", ["status", "--porcelain=v1", "--branch"], { cwd, timeoutMs: 1800 }),
396
+ ]);
397
+ const lines = status.stdout.split(/\r?\n/).filter(Boolean);
398
+ const branchLine = lines.find((line) => line.startsWith("## "));
399
+ const fileLines = lines.filter((line) => !line.startsWith("## "));
400
+ const untracked = fileLines.filter((line) => line.startsWith("??")).length;
401
+ const changed = fileLines.length - untracked;
402
+
403
+ info.git = {
404
+ isRepo: true,
405
+ branch: branch.stdout.trim() || branchLine?.replace(/^##\s+/, "").split("...")[0] || "detached",
406
+ changed,
407
+ untracked,
408
+ branchStatus: branchLine,
409
+ };
410
+ return info;
411
+ }
412
+
413
+ let activeGitWorkflowProcess = null;
414
+
415
+ async function getGitRoot(cwd) {
416
+ const result = await runCommand("git", ["rev-parse", "--show-toplevel"], { cwd, timeoutMs: 2000 });
417
+ if (result.exitCode !== 0) {
418
+ throw new Error((result.stderr || result.stdout || "Not inside a git repository").trim());
419
+ }
420
+ return path.resolve(result.stdout.trim());
421
+ }
422
+
423
+ function commitMessagePaths(root) {
424
+ return {
425
+ shortPath: path.join(root, "dev", "COMMIT", "staged-commit-short.txt"),
426
+ longPath: path.join(root, "dev", "COMMIT", "staged-commit-long.txt"),
427
+ };
428
+ }
429
+
430
+ async function readGitWorkflowMessages(cwd) {
431
+ const root = await getGitRoot(cwd);
432
+ const { shortPath, longPath } = commitMessagePaths(root);
433
+ try {
434
+ const [shortText, longText, shortStat, longStat] = await Promise.all([
435
+ readFile(shortPath, "utf8"),
436
+ readFile(longPath, "utf8"),
437
+ stat(shortPath),
438
+ stat(longPath),
439
+ ]);
440
+ return {
441
+ root,
442
+ shortPath,
443
+ longPath,
444
+ short: shortText.trimEnd(),
445
+ long: longText.trimEnd(),
446
+ shortMtimeMs: shortStat.mtimeMs,
447
+ longMtimeMs: longStat.mtimeMs,
448
+ };
449
+ } catch (error) {
450
+ throw new Error(`Missing generated commit message files in ${path.join(root, "dev", "COMMIT")}. Run /git-staged-msg first. ${sanitizeError(error)}`);
451
+ }
452
+ }
453
+
454
+ function formatGitCommand(args) {
455
+ return ["git", ...args.map((arg) => (/\s/.test(arg) ? JSON.stringify(arg) : arg))].join(" ");
456
+ }
457
+
458
+ function runGitWorkflowCommand(args, { cwd, label = formatGitCommand(args), timeoutMs = 10 * 60 * 1000 } = {}) {
459
+ if (activeGitWorkflowProcess) {
460
+ return Promise.reject(new Error(`A git workflow command is already running: ${activeGitWorkflowProcess.label}`));
461
+ }
462
+
463
+ return new Promise((resolve) => {
464
+ const child = spawn("git", args, {
465
+ cwd,
466
+ env: { ...process.env, GIT_TERMINAL_PROMPT: process.env.GIT_TERMINAL_PROMPT || "0" },
467
+ stdio: ["ignore", "pipe", "pipe"],
468
+ windowsHide: true,
469
+ });
470
+ let stdout = "";
471
+ let stderr = "";
472
+ let timedOut = false;
473
+ let cancelled = false;
474
+ let settled = false;
475
+
476
+ const finish = (result) => {
477
+ if (settled) return;
478
+ settled = true;
479
+ clearTimeout(timeout);
480
+ if (activeGitWorkflowProcess?.child === child) activeGitWorkflowProcess = null;
481
+ resolve({ command: label, stdout, stderr, timedOut, cancelled, ...result });
482
+ };
483
+
484
+ const terminate = (reason) => {
485
+ if (reason === "cancelled") cancelled = true;
486
+ if (child.exitCode === null) child.kill("SIGTERM");
487
+ setTimeout(() => {
488
+ if (child.exitCode === null) child.kill("SIGKILL");
489
+ }, 2000).unref();
490
+ };
491
+
492
+ activeGitWorkflowProcess = { child, label, cancel: () => terminate("cancelled") };
493
+ const timeout = setTimeout(() => {
494
+ timedOut = true;
495
+ terminate("timeout");
496
+ }, timeoutMs);
497
+
498
+ child.stdout.on("data", (chunk) => {
499
+ stdout += String(chunk);
500
+ if (stdout.length > 100000) stdout = stdout.slice(-100000);
501
+ });
502
+ child.stderr.on("data", (chunk) => {
503
+ stderr += String(chunk);
504
+ if (stderr.length > 100000) stderr = stderr.slice(-100000);
505
+ });
506
+ child.on("error", (error) => finish({ exitCode: undefined, stderr: stderr || sanitizeError(error), error: sanitizeError(error) }));
507
+ child.on("exit", (exitCode, signal) => finish({ exitCode, signal }));
508
+ });
509
+ }
510
+
511
+ function gitWorkflowCommandPayload(result) {
512
+ const ok = result.exitCode === 0 && !result.timedOut && !result.cancelled && !result.error;
513
+ return {
514
+ ok,
515
+ error: ok ? undefined : result.error || (result.cancelled ? "Cancelled" : result.timedOut ? "Command timed out" : `Command failed with exit code ${result.exitCode ?? result.signal ?? "unknown"}`),
516
+ data: result,
517
+ };
518
+ }
519
+
520
+ async function handleGitWorkflowRequest(pathname, body = {}) {
521
+ try {
522
+ switch (pathname) {
523
+ case "/api/git-workflow/message":
524
+ return { ok: true, data: await readGitWorkflowMessages(options.cwd) };
525
+ case "/api/git-workflow/add":
526
+ await getGitRoot(options.cwd);
527
+ return gitWorkflowCommandPayload(await runGitWorkflowCommand(["add", "."], { cwd: options.cwd }));
528
+ case "/api/git-workflow/commit": {
529
+ const variant = String(body.variant || "").trim();
530
+ if (!["short", "long"].includes(variant)) throw new Error("variant must be 'short' or 'long'");
531
+ const messages = await readGitWorkflowMessages(options.cwd);
532
+ if (variant === "short") {
533
+ const message = messages.short.trim();
534
+ if (!message) throw new Error(`${messages.shortPath} is empty`);
535
+ return gitWorkflowCommandPayload(await runGitWorkflowCommand(["commit", "-m", message], { cwd: messages.root, label: "git commit -m <dev/COMMIT/staged-commit-short.txt>" }));
536
+ }
537
+ if (!messages.long.trim()) throw new Error(`${messages.longPath} is empty`);
538
+ return gitWorkflowCommandPayload(await runGitWorkflowCommand(["commit", "-F", messages.longPath], { cwd: messages.root, label: "git commit -F dev/COMMIT/staged-commit-long.txt" }));
539
+ }
540
+ case "/api/git-workflow/push": {
541
+ const root = await getGitRoot(options.cwd);
542
+ return gitWorkflowCommandPayload(await runGitWorkflowCommand(["push"], { cwd: root, timeoutMs: 15 * 60 * 1000 }));
543
+ }
544
+ case "/api/git-workflow/cancel": {
545
+ const cancelled = !!activeGitWorkflowProcess;
546
+ if (activeGitWorkflowProcess) activeGitWorkflowProcess.cancel();
547
+ return { ok: true, data: { cancelled } };
548
+ }
549
+ default:
550
+ return undefined;
551
+ }
552
+ } catch (error) {
553
+ return { ok: false, error: sanitizeError(error) };
554
+ }
555
+ }
556
+
557
+ function normalizeStaticPath(urlPath) {
558
+ if (urlPath === "/") return "index.html";
559
+ const name = urlPath.startsWith("/") ? urlPath.slice(1) : urlPath;
560
+ if (!["index.html", "app.js", "styles.css"].includes(name)) return undefined;
561
+ return name;
562
+ }
563
+
564
+ async function serveStatic(req, res, url) {
565
+ if (req.method !== "GET") return false;
566
+ const staticName = normalizeStaticPath(url.pathname);
567
+ if (!staticName) return false;
568
+
569
+ const filePath = path.join(publicDir, staticName);
570
+ const ext = path.extname(filePath);
571
+ const content = await readFile(filePath);
572
+ res.writeHead(200, {
573
+ "content-type": MIME_TYPES.get(ext) || "application/octet-stream",
574
+ "cache-control": "no-store",
575
+ "x-content-type-options": "nosniff",
576
+ });
577
+ res.end(content);
578
+ return true;
579
+ }
580
+
581
+ function commandFromPost(pathname, body) {
582
+ switch (pathname) {
583
+ case "/api/prompt": {
584
+ const message = String(body.message || "").trim();
585
+ if (!message) throw new Error("message is required");
586
+ const command = { type: "prompt", message };
587
+ if (body.streamingBehavior === "steer" || body.streamingBehavior === "followUp") {
588
+ command.streamingBehavior = body.streamingBehavior;
589
+ }
590
+ return command;
591
+ }
592
+ case "/api/steer": {
593
+ const message = String(body.message || "").trim();
594
+ if (!message) throw new Error("message is required");
595
+ return { type: "steer", message };
596
+ }
597
+ case "/api/follow-up": {
598
+ const message = String(body.message || "").trim();
599
+ if (!message) throw new Error("message is required");
600
+ return { type: "follow_up", message };
601
+ }
602
+ case "/api/abort":
603
+ return { type: "abort" };
604
+ case "/api/new-session":
605
+ return body.parentSession ? { type: "new_session", parentSession: String(body.parentSession) } : { type: "new_session" };
606
+ case "/api/model": {
607
+ const provider = String(body.provider || "").trim();
608
+ const modelId = String(body.modelId || "").trim();
609
+ if (!provider || !modelId) throw new Error("provider and modelId are required");
610
+ return { type: "set_model", provider, modelId };
611
+ }
612
+ case "/api/thinking": {
613
+ const level = String(body.level || "").trim();
614
+ if (!["off", "minimal", "low", "medium", "high", "xhigh"].includes(level)) {
615
+ throw new Error("Invalid thinking level");
616
+ }
617
+ return { type: "set_thinking_level", level };
618
+ }
619
+ case "/api/compact":
620
+ return body.customInstructions ? { type: "compact", customInstructions: String(body.customInstructions) } : { type: "compact" };
621
+ default:
622
+ return undefined;
623
+ }
624
+ }
625
+
626
+ function commandFromGet(pathname) {
627
+ switch (pathname) {
628
+ case "/api/state":
629
+ return { type: "get_state" };
630
+ case "/api/messages":
631
+ return { type: "get_messages" };
632
+ case "/api/models":
633
+ return { type: "get_available_models" };
634
+ case "/api/commands":
635
+ return { type: "get_commands" };
636
+ case "/api/stats":
637
+ return { type: "get_session_stats" };
638
+ case "/api/last-assistant-text":
639
+ return { type: "get_last_assistant_text" };
640
+ default:
641
+ return undefined;
642
+ }
643
+ }
644
+
645
+ let options;
646
+ try {
647
+ options = parseArgs(process.argv.slice(2));
648
+ } catch (error) {
649
+ console.error(`Error: ${sanitizeError(error)}\n`);
650
+ usage();
651
+ process.exit(2);
652
+ }
653
+
654
+ if (options.help) {
655
+ usage();
656
+ process.exit(0);
657
+ }
658
+ if (options.version) {
659
+ console.log(packageJson.version);
660
+ process.exit(0);
661
+ }
662
+
663
+ const piArgs = ["--mode", "rpc"];
664
+ if (options.noSession) piArgs.push("--no-session");
665
+ if (options.name) piArgs.push("--name", options.name);
666
+ piArgs.push(...options.piArgs);
667
+
668
+ async function resolvePiCommand() {
669
+ if (options.piBinExplicit) {
670
+ return { command: options.piBin, args: piArgs, displayCommand: `${options.piBin} ${piArgs.join(" ")}` };
671
+ }
672
+
673
+ const bundledCli = path.join(packageRoot, "node_modules", "@earendil-works", "pi-coding-agent", "dist", "cli.js");
674
+ try {
675
+ await access(bundledCli);
676
+ return {
677
+ command: process.execPath,
678
+ args: [bundledCli, ...piArgs],
679
+ displayCommand: `${process.execPath} ${bundledCli} ${piArgs.join(" ")}`,
680
+ };
681
+ } catch {
682
+ return { command: options.piBin, args: piArgs, displayCommand: `${options.piBin} ${piArgs.join(" ")}` };
683
+ }
684
+ }
685
+
686
+ const piCommand = await resolvePiCommand();
687
+ const rpc = new PiRpcProcess({ ...piCommand, cwd: options.cwd });
688
+ const sseClients = new Set();
689
+ rpc.onEvent((event) => {
690
+ for (const client of sseClients) sendSse(client, event);
691
+ });
692
+ rpc.start();
693
+
694
+ const server = createServer(async (req, res) => {
695
+ try {
696
+ const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
697
+
698
+ if (url.pathname === "/api/events" && req.method === "GET") {
699
+ res.writeHead(200, {
700
+ "content-type": "text/event-stream; charset=utf-8",
701
+ "cache-control": "no-cache, no-transform",
702
+ connection: "keep-alive",
703
+ "x-content-type-options": "nosniff",
704
+ });
705
+ res.write(": connected\n\n");
706
+ sseClients.add(res);
707
+ sendSse(res, {
708
+ type: "webui_connected",
709
+ version: packageJson.version,
710
+ pid: rpc.child?.pid,
711
+ cwd: options.cwd,
712
+ startedAt: rpc.startedAt,
713
+ });
714
+ const keepAlive = setInterval(() => res.write(": keepalive\n\n"), 15000);
715
+ req.on("close", () => {
716
+ clearInterval(keepAlive);
717
+ sseClients.delete(res);
718
+ });
719
+ return;
720
+ }
721
+
722
+ if (url.pathname === "/api/health" && req.method === "GET") {
723
+ sendJson(res, 200, {
724
+ ok: true,
725
+ webuiVersion: packageJson.version,
726
+ piPid: rpc.child?.pid,
727
+ piRunning: !!rpc.child && rpc.child.exitCode === null,
728
+ cwd: options.cwd,
729
+ });
730
+ return;
731
+ }
732
+
733
+ if (url.pathname === "/api/workspace" && req.method === "GET") {
734
+ sendJson(res, 200, {
735
+ ok: true,
736
+ data: await getWorkspaceInfo(options.cwd, rpc.startedAt),
737
+ });
738
+ return;
739
+ }
740
+
741
+ if (url.pathname.startsWith("/api/git-workflow/")) {
742
+ const body = req.method === "POST" ? await readJsonBody(req) : {};
743
+ const response = await handleGitWorkflowRequest(url.pathname, body);
744
+ if (response) {
745
+ sendJson(res, 200, response);
746
+ return;
747
+ }
748
+ }
749
+
750
+ const getCommand = req.method === "GET" ? commandFromGet(url.pathname) : undefined;
751
+ if (getCommand) {
752
+ const response = await rpc.send(getCommand);
753
+ sendJson(res, response.success === false ? 400 : 200, response);
754
+ return;
755
+ }
756
+
757
+ if (req.method === "POST" && url.pathname === "/api/extension-ui-response") {
758
+ const body = await readJsonBody(req);
759
+ if (body.type !== "extension_ui_response") body.type = "extension_ui_response";
760
+ if (!body.id) throw new Error("id is required");
761
+ await rpc.writeRaw(body);
762
+ sendJson(res, 200, { ok: true });
763
+ return;
764
+ }
765
+
766
+ if (req.method === "POST") {
767
+ const body = await readJsonBody(req);
768
+ const command = commandFromPost(url.pathname, body);
769
+ if (command) {
770
+ const response = await rpc.send(command);
771
+ sendJson(res, response.success === false ? 400 : 200, response);
772
+ return;
773
+ }
774
+ }
775
+
776
+ if (await serveStatic(req, res, url)) return;
777
+
778
+ sendError(res, 404, "Not found");
779
+ } catch (error) {
780
+ sendError(res, 500, error);
781
+ }
782
+ });
783
+
784
+ server.on("error", (error) => {
785
+ console.error("Web UI server failed:", sanitizeError(error));
786
+ rpc.stop();
787
+ process.exit(1);
788
+ });
789
+
790
+ server.listen(options.port, options.host, () => {
791
+ const urlHost = options.host.includes(":") && !options.host.startsWith("[") ? `[${options.host}]` : options.host;
792
+ console.log(`Pi Web UI: http://${urlHost}:${options.port}/`);
793
+ console.log(`Working directory: ${options.cwd}`);
794
+ console.log(`Pi RPC: ${piCommand.displayCommand}`);
795
+ if (!isLocalHost(options.host)) {
796
+ console.warn("WARNING: Web UI has no authentication. Only expose it on trusted networks.");
797
+ }
798
+ });
799
+
800
+ function shutdown(signal) {
801
+ console.log(`\n${signal}: shutting down Pi Web UI...`);
802
+ server.close(() => process.exit(0));
803
+ rpc.stop();
804
+ setTimeout(() => process.exit(0), 4000).unref();
805
+ }
806
+
807
+ process.on("SIGINT", () => shutdown("SIGINT"));
808
+ process.on("SIGTERM", () => shutdown("SIGTERM"));