@firstpick/pi-package-webui 0.4.6 → 0.4.8

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/README.md CHANGED
@@ -6,7 +6,7 @@ Local browser UI for [Pi coding agent](https://www.npmjs.com/package/@earendil-w
6
6
 
7
7
  Pi Web UI gives you a local browser companion for Pi: multi-tab chat, streaming output, model controls, uploads, slash-command helpers, workspace navigation, and optional extension widgets.
8
8
 
9
- > **Security:** Pi Web UI can control the spawned Pi session and run anything that session is allowed to run. It binds to `127.0.0.1` by default. Remote PIN authentication is off by default on first use; enabling it in **Controls → Network → Remote PIN auth** persists that preference for later Web UI starts.
9
+ > **Security:** Pi Web UI can control the spawned Pi session and run anything that session is allowed to run. It binds to `127.0.0.1` by default. Trusted-LAN opening/closing and Remote PIN auth controls are owned by the optional `@firstpick/pi-package-remote-webui` companion; when enabled, Remote PIN auth persists for later Web UI starts.
10
10
 
11
11
  ## Requirements
12
12
 
@@ -134,13 +134,13 @@ Environment variables:
134
134
  - Streaming chat transcript with Markdown, thinking output, tool/bash cards, queue and compaction events, edit-and-retry from user prompts, and guarded abort controls that require holding Esc or the Abort button for 3 seconds.
135
135
  - Prompt composer with uploads, drag/drop/paste, inline image support, slash-command autocomplete, and `@` file/path references with live suggestions.
136
136
  - Browser dialogs for common Pi selectors such as `/model`, `/settings`, `/theme`, `/fork`, `/clone`, `/resume`, `/tree`, `/scoped-models`, `/tools`, and `/skills`.
137
- - Model, thinking, session, workspace, theme, optional-feature, Codex usage, network, update/restart, event, and notification controls in the side panel.
137
+ - Model, thinking, session, workspace, theme, optional-feature, Codex usage, optional Remote WebUI, update/restart, event, and notification controls in the side panel.
138
138
  - Persistent context-window meter with manual compact and auto-compaction controls near the composer.
139
139
  - Side-panel theme picker backed by optional `@firstpick/pi-themes-bundle` themes when loaded.
140
140
  - Per-tab cwd changes, a clickable footer cwd picker, saved path fast picks, server-persisted fast picks, and restart-safe restoration of open tabs.
141
141
  - Detected app runner dropdown for the active tab cwd, including Cargo, Bun, npm/npx/pnpm, Python/uv, Go/Golang, Zig, C/C++, Docker Compose, root/dev/scripts shell scripts, and other common project runners with live output pinned at the top of the terminal. Projects can add browseable custom runners in `.pi-webui-runners.json` with a command (default `./`) plus a relative path to the file to run.
142
142
  - Browser support for Pi extension UI prompts, widgets, status updates, `/btw` side-question output widgets with optional context transfer/live steering, browser notifications when a tab needs an extension UI response, and an optional side-panel toggle for agent-done notifications.
143
- - Localhost-only Pi/Web UI update checks with a top-right update notification and a confirmed **Update & restart** action that runs `pi update` plus all detected local/global Web UI and Pi package-manager updates, then restarts the Web UI server.
143
+ - Localhost-only Pi/Web UI update checks with a top-right update notification and confirmed restart actions: **Update Pi & restart** runs `pi update` for Pi-only updates, while **Update Pi + Packages & Restart** runs `pi update --all` for Pi plus configured packages.
144
144
  - Feedback reactions (`👍`, `👎`, `?`) on final assistant output plus tool/bash action cards, which can ask Pi to create or update a LEARNING.
145
145
  - Mobile-friendly layout and PWA install support where the browser allows it.
146
146
 
@@ -150,7 +150,7 @@ Useful browser endpoints exposed by the local server include:
150
150
  - `POST /api/action-feedback?tab=<tabId>` for feedback on final assistant output and action cards.
151
151
  - `GET /api/optional-features` for optional companion package install/update status.
152
152
  - `POST /api/optional-feature-install` for installing or updating known optional companion packages from the side panel.
153
- - `GET /api/update-status` and localhost-only `POST /api/update` for checking Pi/Web UI updates and running `pi update` plus all detected local/global Web UI and Pi package-manager updates followed by a Web UI server restart.
153
+ - `GET /api/update-status` and localhost-only `POST /api/update` for checking Pi/Web UI updates and running `pi update` followed by a Web UI server restart. Use `POST /api/update?all=1` to run `pi update --all` for Pi plus configured packages.
154
154
  - `GET /api/remote-auth`, `POST /api/remote-auth`, and localhost-only `POST /api/remote-auth/settings` for optional 4-digit PIN authentication when serving non-local browser clients.
155
155
 
156
156
  For local development, run the checkout helper directly, for example:
@@ -178,7 +178,7 @@ Optional companions:
178
178
  - `@firstpick/pi-extension-setup-skills` — TUI `/skills` setup command alongside WebUI-native skill toggles.
179
179
  - `@firstpick/pi-extension-todo-progress` — todo-progress rendering.
180
180
  - `@firstpick/pi-extension-tools` — TUI `/tools` active-tool manager alongside WebUI-native tool toggles.
181
- - `@firstpick/pi-package-remote-webui` — `/remote` trusted-LAN QR helper for connecting mobile browsers to Web UI.
181
+ - `@firstpick/pi-package-remote-webui` — `/remote` trusted-LAN QR helper plus the optional browser controls for opening/closing LAN access and Remote PIN auth.
182
182
  - `@firstpick/pi-extension-git-footer-status` — richer extension-owned git/footer status, including the structured Web UI footer payload.
183
183
  - `@firstpick/pi-extension-stats` — stats commands and status data.
184
184
  - `@firstpick/pi-themes-bundle` — Web UI and Pi theme resources.
@@ -208,9 +208,9 @@ This requires `/git-staged-msg` and `/pr` from `@firstpick/pi-prompts-git-pr`; b
208
208
  ## Network safety
209
209
 
210
210
  - Default bind is localhost-only: `127.0.0.1:31415`.
211
- - The side-panel **Open to network** button rebinds the server to `0.0.0.0`, shows LAN URLs when available, and toggles to "Close for network".
212
- - The side-panel **Remote PIN auth** toggle is off by default on first use. When enabled, the server saves that preference, generates a fresh random 4-digit PIN for each server start, shows it in Controls and `/webui-status`, and requires it from non-local browser clients.
213
- - Localhost clients stay frictionless and can toggle Remote PIN auth; changing the toggle persists the preference and disconnects existing event streams so remote clients must re-authenticate after enablement.
211
+ - When `@firstpick/pi-package-remote-webui` is loaded and enabled, the side-panel **Remote WebUI** controls dispatch through `/remote`: opening rebinds the server to `0.0.0.0`, shows LAN URLs when available, and toggles to "Close for network".
212
+ - The optional **Remote PIN auth** toggle is off by default on first use. When enabled through `/remote auth on` or the Remote WebUI controls, the server saves that preference, generates a fresh random 4-digit PIN for each server start, shows it in the Remote WebUI controls and `/webui-status`, and requires it from non-local browser clients.
213
+ - Localhost clients stay frictionless and can toggle Remote PIN auth through the remote companion; changing the toggle persists the preference and disconnects existing event streams so remote clients must re-authenticate after enablement.
214
214
  - `--host 0.0.0.0` also exposes the Web UI to the local network; pass `--remote-auth` to start with PIN auth already enabled.
215
215
  - Any connected browser client with access (and the PIN, if enabled) can control Pi and run Web UI bash actions as the Web UI process user.
216
216
  - Remote PIN auth is a simple trusted-LAN HTTP gate, not hardened multi-user authentication; do not expose it to untrusted networks.
@@ -222,5 +222,5 @@ This requires `/git-staged-msg` and `/pr` from `@firstpick/pi-prompts-git-pr`; b
222
222
  - **`/webui-start` is missing:** restart Pi after installing the package.
223
223
  - **Wrong port or existing server:** use `/webui-status detailed`, or start on another port with `/webui-start --port 31500`.
224
224
  - **Optional feature is disabled or missing:** check the side panel, install the companion package if needed, then run `/reload` in the active Pi tab.
225
- - **Remote browser asks for a PIN:** read it from **Controls Network Remote PIN auth**, `/webui-status`, or the local Web UI server log. Disable the toggle from localhost to remove the PIN gate.
225
+ - **Remote browser asks for a PIN:** read it from the optional **Remote WebUI** side-panel controls, `/webui-status`, `/remote status`, or the local Web UI server log. Disable the toggle from localhost to remove the PIN gate.
226
226
  - **PWA install or notifications are unavailable:** use `localhost` or HTTPS; browser support varies on LAN HTTP URLs.
package/bin/pi-webui.mjs CHANGED
@@ -640,7 +640,8 @@ function makeHttpError(statusCode, message) {
640
640
  }
641
641
 
642
642
  function sendError(res, statusCode, error) {
643
- sendJson(res, statusCode, { ok: false, error: sanitizeError(error) });
643
+ const message = statusCode >= 500 ? sanitizeError(error) : formatCliError(error);
644
+ sendJson(res, statusCode, { ok: false, error: message });
644
645
  }
645
646
 
646
647
  function formatBytes(bytes) {
@@ -1627,22 +1628,85 @@ function normalizeCustomRunnerDefinition(raw, projectRoot, { strict = false } =
1627
1628
  return { id, label, command, path: filePath, args };
1628
1629
  }
1629
1630
 
1630
- async function readAppRunnerConfig(projectRoot) {
1631
+ function customAppRunnerDiagnostic(severity, message, runner = {}) {
1632
+ const source = runner && typeof runner === "object" ? runner : {};
1633
+ return {
1634
+ severity,
1635
+ message,
1636
+ runnerId: source.id || "",
1637
+ runnerLabel: source.label || "",
1638
+ path: source.path || source.projectFile || "",
1639
+ };
1640
+ }
1641
+
1642
+ function directCustomRunnerUnavailableReason(filePath, stats) {
1643
+ if (process.platform !== "win32" && stats && (stats.mode & 0o111) === 0) {
1644
+ return `Path is not executable: ${filePath}. Run chmod +x ${filePath} or set Command to bash, python3, node, etc.`;
1645
+ }
1646
+ return "";
1647
+ }
1648
+
1649
+ async function customAppRunnerUnavailableReason(projectRoot, runner) {
1650
+ const filePath = runner.path;
1651
+ let stats;
1652
+ try {
1653
+ stats = await fileStatsIfExists(resolveProjectRelativePath(projectRoot, filePath));
1654
+ } catch (error) {
1655
+ return `Cannot access path ${filePath}: ${formatCliError(error)}`;
1656
+ }
1657
+ if (!stats?.isFile()) return `Path to file does not exist: ${filePath}`;
1658
+ const command = cleanCustomRunnerCommand(runner.command);
1659
+ const directReason = command === "./" ? directCustomRunnerUnavailableReason(filePath, stats) : "";
1660
+ if (directReason) return directReason;
1661
+ const commandParts = customRunnerCommandParts(command);
1662
+ if (command !== "./" && !await appRunnerCommandAvailable(commandParts[0], projectRoot)) return `Command is not available: ${commandParts[0]}`;
1663
+ return "";
1664
+ }
1665
+
1666
+ async function readAppRunnerConfig(projectRoot, { strictRead = false } = {}) {
1631
1667
  const configPath = path.join(projectRoot, APP_RUNNER_CONFIG_FILE);
1632
- const parsed = await readJsonFileIfExists(configPath);
1633
- const source = parsed && typeof parsed === "object" ? parsed : {};
1668
+ let source = {};
1669
+ const diagnostics = [];
1670
+ try {
1671
+ const parsed = JSON.parse(await readFile(configPath, "utf8"));
1672
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
1673
+ source = parsed;
1674
+ } else {
1675
+ const message = `${APP_RUNNER_CONFIG_FILE} must contain a JSON object`;
1676
+ if (strictRead) throw makeHttpError(400, message);
1677
+ diagnostics.push(customAppRunnerDiagnostic("error", message));
1678
+ }
1679
+ } catch (error) {
1680
+ if (error?.code !== "ENOENT") {
1681
+ const message = `Cannot read ${APP_RUNNER_CONFIG_FILE}: ${formatCliError(error)}`;
1682
+ if (strictRead) throw makeHttpError(400, message);
1683
+ diagnostics.push(customAppRunnerDiagnostic("error", message));
1684
+ console.warn(`failed to read custom app runner config ${configPath}: ${sanitizeError(error)}`);
1685
+ }
1686
+ }
1687
+ if (source.runners !== undefined && !Array.isArray(source.runners)) {
1688
+ const message = `${APP_RUNNER_CONFIG_FILE} runners must be an array`;
1689
+ if (strictRead) throw makeHttpError(400, message);
1690
+ diagnostics.push(customAppRunnerDiagnostic("error", message));
1691
+ }
1634
1692
  const rawRunners = Array.isArray(source.runners) ? source.runners : [];
1635
1693
  const runners = [];
1636
1694
  for (const raw of rawRunners) {
1637
1695
  try {
1638
1696
  const runner = normalizeCustomRunnerDefinition(raw, projectRoot);
1639
- if (!runners.some((item) => item.id === runner.id)) runners.push(runner);
1697
+ if (runners.some((item) => item.id === runner.id)) {
1698
+ diagnostics.push(customAppRunnerDiagnostic("warning", `Duplicate custom runner ignored: ${runner.label || runner.path || runner.id}`, runner));
1699
+ } else {
1700
+ runners.push(runner);
1701
+ }
1640
1702
  } catch (error) {
1703
+ const message = `Invalid custom runner ignored: ${formatCliError(error)}`;
1704
+ diagnostics.push(customAppRunnerDiagnostic("error", message, raw));
1641
1705
  console.warn(`skipping invalid custom app runner in ${configPath}: ${sanitizeError(error)}`);
1642
1706
  }
1643
1707
  if (runners.length >= APP_RUNNER_CUSTOM_LIMIT) break;
1644
1708
  }
1645
- return { projectRoot, configPath, runners };
1709
+ return { projectRoot, configPath, runners, diagnostics };
1646
1710
  }
1647
1711
 
1648
1712
  async function writeAppRunnerConfig(projectRoot, runners) {
@@ -1660,15 +1724,12 @@ async function writeAppRunnerConfig(projectRoot, runners) {
1660
1724
 
1661
1725
  async function customAppRunnerCandidate(projectRoot, configPath, runner) {
1662
1726
  const filePath = runner.path;
1663
- const absolutePath = resolveProjectRelativePath(projectRoot, filePath);
1664
- const stats = await fileStatsIfExists(absolutePath);
1665
- if (!stats?.isFile()) return null;
1727
+ if (await customAppRunnerUnavailableReason(projectRoot, runner)) return null;
1666
1728
  const command = cleanCustomRunnerCommand(runner.command);
1667
1729
  const args = parseCustomRunnerArgs(runner.args);
1668
1730
  const commandParts = customRunnerCommandParts(command);
1669
1731
  const effectiveCommand = command === "./" ? `./${filePath}` : commandParts[0];
1670
1732
  const effectiveArgs = command === "./" ? args : [...commandParts.slice(1), filePath, ...args];
1671
- if (command !== "./" && !await appRunnerCommandAvailable(commandParts[0], projectRoot)) return null;
1672
1733
  return appRunnerCandidate({
1673
1734
  id: appRunnerId("custom", runner.id),
1674
1735
  label: runner.label || path.basename(filePath),
@@ -1696,24 +1757,32 @@ async function addCustomAppRunners(runners, cwd) {
1696
1757
  async function getCustomAppRunnerConfigData(tab) {
1697
1758
  const projectRoot = await findAppRunnerProjectRoot(tab?.cwd || options.cwd);
1698
1759
  const config = await readAppRunnerConfig(projectRoot);
1760
+ const runners = [];
1761
+ for (const runner of config.runners) {
1762
+ const unavailableReason = await customAppRunnerUnavailableReason(projectRoot, runner);
1763
+ runners.push({
1764
+ ...publicCustomRunnerDefinition(runner),
1765
+ available: !unavailableReason,
1766
+ unavailableReason,
1767
+ });
1768
+ }
1699
1769
  return {
1700
1770
  projectRoot,
1701
1771
  displayProjectRoot: displayPath(projectRoot),
1702
1772
  configFile: config.configPath,
1703
1773
  displayConfigFile: displayPath(config.configPath),
1704
1774
  relativeConfigFile: APP_RUNNER_CONFIG_FILE,
1705
- runners: config.runners.map(publicCustomRunnerDefinition),
1775
+ runners,
1776
+ diagnostics: config.diagnostics,
1706
1777
  };
1707
1778
  }
1708
1779
 
1709
1780
  async function saveCustomAppRunner(tab, rawRunner) {
1710
1781
  const projectRoot = await findAppRunnerProjectRoot(tab?.cwd || options.cwd);
1711
- const config = await readAppRunnerConfig(projectRoot);
1782
+ const config = await readAppRunnerConfig(projectRoot, { strictRead: true });
1712
1783
  const normalized = normalizeCustomRunnerDefinition(rawRunner, projectRoot, { strict: true });
1713
- const stats = await fileStatsIfExists(resolveProjectRelativePath(projectRoot, normalized.path));
1714
- if (!stats?.isFile()) throw makeHttpError(400, `Path to file does not exist: ${normalized.path}`);
1715
- const commandParts = customRunnerCommandParts(normalized.command);
1716
- if (normalized.command !== "./" && !await appRunnerCommandAvailable(commandParts[0], projectRoot)) throw makeHttpError(400, `Command is not available: ${commandParts[0]}`);
1784
+ const unavailableReason = await customAppRunnerUnavailableReason(projectRoot, normalized);
1785
+ if (unavailableReason) throw makeHttpError(400, unavailableReason);
1717
1786
  const runners = config.runners.filter((runner) => runner.id !== normalized.id);
1718
1787
  if (runners.length >= APP_RUNNER_CUSTOM_LIMIT) throw makeHttpError(400, `Custom runner limit reached (${APP_RUNNER_CUSTOM_LIMIT})`);
1719
1788
  runners.push(normalized);
@@ -3066,6 +3135,7 @@ async function getWorkspaceInfo(cwd, startedAt) {
3066
3135
  let activeGitWorkflowProcess = null;
3067
3136
  const GIT_CHANGES_COMMAND_TIMEOUT_MS = 5000;
3068
3137
  const GIT_CHANGES_DIFF_MAX_OUTPUT = 500_000;
3138
+ const GIT_PULL_TIMEOUT_MS = 15 * 60 * 1000;
3069
3139
 
3070
3140
  async function getGitRoot(cwd) {
3071
3141
  const result = await runCommand("git", ["rev-parse", "--show-toplevel"], { cwd, timeoutMs: 2000 });
@@ -3085,40 +3155,43 @@ async function runGitReadCommand(root, args, { timeoutMs = GIT_CHANGES_COMMAND_T
3085
3155
  throw new Error(String(message).trim());
3086
3156
  }
3087
3157
 
3088
- function gitBranchFromStatus(statusText) {
3089
- const branchLine = String(statusText || "").split(/\r?\n/).find((line) => line.startsWith("## ")) || "";
3090
- return branchLine.slice(3).trim().replace(/\.\.\..*$/, "") || "detached";
3158
+ function gitBranchFromPorcelainStatus(statusText) {
3159
+ for (const line of String(statusText || "").split(/\r?\n/)) {
3160
+ if (!line.startsWith("# branch.head ")) continue;
3161
+ const branch = line.slice("# branch.head ".length).trim();
3162
+ return branch && branch !== "(detached)" ? branch : "detached";
3163
+ }
3164
+ return "detached";
3091
3165
  }
3092
3166
 
3093
- function gitDivergenceFromBranchStatus(line) {
3094
- const details = String(line || "").match(/\[(.+)\]\s*$/)?.[1] || "";
3095
- const ahead = Number.parseInt(details.match(/ahead\s+(\d+)/i)?.[1] || "0", 10) || 0;
3096
- const behind = Number.parseInt(details.match(/behind\s+(\d+)/i)?.[1] || "0", 10) || 0;
3097
- return { ahead, behind };
3167
+ function addGitPorcelainTrackedSummary(summary, xy) {
3168
+ const x = xy?.[0] || ".";
3169
+ const y = xy?.[1] || ".";
3170
+ if (x !== ".") summary.staged += 1;
3171
+ if (y !== ".") summary.unstaged += 1;
3098
3172
  }
3099
3173
 
3100
- function summarizeGitShortStatus(statusText) {
3174
+ function summarizeGitPorcelainStatus(statusText) {
3101
3175
  const summary = { staged: 0, unstaged: 0, untracked: 0, conflicted: 0, ahead: 0, behind: 0 };
3102
3176
  for (const line of String(statusText || "").split(/\r?\n/)) {
3103
3177
  if (!line) continue;
3104
- if (line.startsWith("## ")) {
3105
- const divergence = gitDivergenceFromBranchStatus(line);
3106
- summary.ahead = divergence.ahead;
3107
- summary.behind = divergence.behind;
3178
+ if (line.startsWith("# branch.ab ")) {
3179
+ const match = line.match(/\+(\d+)\s+-(\d+)/);
3180
+ if (match) {
3181
+ summary.ahead = Number.parseInt(match[1] || "0", 10) || 0;
3182
+ summary.behind = Number.parseInt(match[2] || "0", 10) || 0;
3183
+ }
3108
3184
  continue;
3109
3185
  }
3110
- const x = line[0] || " ";
3111
- const y = line[1] || " ";
3112
- if (x === "?" && y === "?") {
3113
- summary.untracked += 1;
3186
+ if (line.startsWith("1 ") || line.startsWith("2 ")) {
3187
+ addGitPorcelainTrackedSummary(summary, line.split(" ")[1] || "..");
3114
3188
  continue;
3115
3189
  }
3116
- if (x === "U" || y === "U" || (x === "A" && y === "A") || (x === "D" && y === "D")) {
3190
+ if (line.startsWith("u ")) {
3117
3191
  summary.conflicted += 1;
3118
3192
  continue;
3119
3193
  }
3120
- if (x && x !== " ") summary.staged += 1;
3121
- if (y && y !== " ") summary.unstaged += 1;
3194
+ if (line.startsWith("? ")) summary.untracked += 1;
3122
3195
  }
3123
3196
  return summary;
3124
3197
  }
@@ -3176,28 +3249,77 @@ async function readGitUntrackedFile(cwd, requestedPath) {
3176
3249
  return readGitUntrackedEntry(root, normalized);
3177
3250
  }
3178
3251
 
3252
+ async function gitUpstreamRef(root) {
3253
+ try {
3254
+ const upstream = await runGitReadCommand(root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], { timeoutMs: 5000, maxOutputLength: 10_000 });
3255
+ return upstream.trim();
3256
+ } catch {
3257
+ return "";
3258
+ }
3259
+ }
3260
+
3261
+ async function readGitIncomingChanges(root, summary) {
3262
+ const upstream = await gitUpstreamRef(root);
3263
+ const remote = {
3264
+ upstream,
3265
+ behind: Number(summary?.behind || 0) || 0,
3266
+ canPull: !!upstream && (Number(summary?.behind || 0) || 0) > 0,
3267
+ };
3268
+ if (!remote.canPull) return { remote, section: null };
3269
+
3270
+ const diffArgs = ["diff", "--no-ext-diff", "--no-color", "--find-renames", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/", "HEAD..@{upstream}"];
3271
+ try {
3272
+ const diff = await runGitReadCommand(root, diffArgs);
3273
+ return {
3274
+ remote,
3275
+ section: {
3276
+ key: "incoming",
3277
+ label: `Incoming from ${upstream}`,
3278
+ command: `git diff --unified=0 HEAD..${upstream}`,
3279
+ diff: diff.trimEnd(),
3280
+ },
3281
+ };
3282
+ } catch (error) {
3283
+ return { remote: { ...remote, error: sanitizeError(error) }, section: null };
3284
+ }
3285
+ }
3286
+
3287
+ async function pullGitChanges(cwd) {
3288
+ const root = await getGitRoot(cwd);
3289
+ const payload = gitWorkflowCommandPayload(await runGitWorkflowCommand(["pull", "--ff-only"], { cwd: root, timeoutMs: GIT_PULL_TIMEOUT_MS }));
3290
+ if (payload.data) payload.data.root = root;
3291
+ if (payload.ok) payload.data.changes = await readGitChanges(root);
3292
+ else payload.error = (payload.data?.stderr || payload.data?.stdout || payload.error || "git pull --ff-only failed").trim();
3293
+ return payload;
3294
+ }
3295
+
3179
3296
  async function readGitChanges(cwd) {
3180
3297
  const root = await getGitRoot(cwd);
3181
- const diffArgs = ["diff", "--no-ext-diff", "--no-color", "--find-renames", "--src-prefix=a/", "--dst-prefix=b/"];
3182
- const [statusText, unstagedDiff, stagedDiff, untrackedText] = await Promise.all([
3298
+ const diffArgs = ["diff", "--no-ext-diff", "--no-color", "--find-renames", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/"];
3299
+ const [statusText, porcelainStatusText, unstagedDiff, stagedDiff, untrackedText] = await Promise.all([
3183
3300
  runGitReadCommand(root, ["status", "--short", "--branch", "--untracked-files=all"], { maxOutputLength: 120_000 }),
3301
+ runGitReadCommand(root, ["status", "--porcelain=2", "--branch", "--untracked-files=all"], { maxOutputLength: 120_000 }),
3184
3302
  runGitReadCommand(root, diffArgs),
3185
- runGitReadCommand(root, ["diff", "--cached", "--no-ext-diff", "--no-color", "--find-renames", "--src-prefix=a/", "--dst-prefix=b/"]),
3303
+ runGitReadCommand(root, ["diff", "--cached", "--no-ext-diff", "--no-color", "--find-renames", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/"]),
3186
3304
  runGitReadCommand(root, ["ls-files", "--others", "--exclude-standard"], { maxOutputLength: 120_000 }),
3187
3305
  ]);
3306
+ const summary = summarizeGitPorcelainStatus(porcelainStatusText);
3307
+ const incoming = await readGitIncomingChanges(root, summary);
3188
3308
  const untrackedFiles = untrackedText.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
3189
3309
  const untracked = await readGitUntrackedEntries(root, untrackedFiles);
3190
3310
  return {
3191
3311
  cwd,
3192
3312
  root,
3193
- branch: gitBranchFromStatus(statusText),
3313
+ branch: gitBranchFromPorcelainStatus(porcelainStatusText),
3194
3314
  generatedAt: new Date().toISOString(),
3195
- summary: summarizeGitShortStatus(statusText),
3315
+ summary,
3316
+ remote: incoming.remote,
3196
3317
  status: statusText.trimEnd(),
3197
3318
  sections: [
3198
- { key: "staged", label: "Staged", command: "git diff --cached", diff: stagedDiff.trimEnd() },
3199
- { key: "unstaged", label: "Unstaged", command: "git diff", diff: unstagedDiff.trimEnd() },
3200
- ],
3319
+ incoming.section,
3320
+ { key: "staged", label: "Staged", command: "git diff --cached --unified=0", diff: stagedDiff.trimEnd() },
3321
+ { key: "unstaged", label: "Unstaged", command: "git diff --unified=0", diff: unstagedDiff.trimEnd() },
3322
+ ].filter(Boolean),
3201
3323
  untracked,
3202
3324
  };
3203
3325
  }
@@ -4158,6 +4280,9 @@ try {
4158
4280
  process.exit(2);
4159
4281
  }
4160
4282
 
4283
+ process.env.PI_WEBUI_HOST = options.host;
4284
+ process.env.PI_WEBUI_PORT = String(options.port);
4285
+
4161
4286
  const startupDelayMs = Number.parseInt(process.env.PI_WEBUI_START_DELAY_MS || "", 10);
4162
4287
  delete process.env.PI_WEBUI_START_DELAY_MS;
4163
4288
  if (Number.isFinite(startupDelayMs) && startupDelayMs > 0) {
@@ -5080,32 +5205,35 @@ async function getUpdateStatus({ force = false } = {}) {
5080
5205
  checkedAt: new Date(now).toISOString(),
5081
5206
  updateAvailable,
5082
5207
  restartRequired: true,
5083
- command: "pi update + Web UI/Pi package-manager updates",
5208
+ command: "pi update",
5209
+ allCommand: "pi update --all",
5084
5210
  webuiDev: webuiDevServer,
5085
5211
  pi: piStatus,
5086
5212
  webui: webuiStatus,
5087
5213
  packages: {
5088
5214
  checked: false,
5089
- note: "Update runs pi update plus all detected local, Pi-agent, npm-global, and Bun-global Web UI/Pi package roots."
5215
+ note: "Default update runs pi update for Pi only. Use update all to run pi update --all for Pi and configured packages."
5090
5216
  },
5091
5217
  };
5092
5218
  updateStatusCacheAt = now;
5093
5219
  return updateStatusCache;
5094
5220
  }
5095
5221
 
5096
- async function resolvePiUpdateCommand() {
5222
+ async function resolvePiUpdateCommand({ all = false } = {}) {
5223
+ const updateArgs = all ? ["update", "--all"] : ["update"];
5224
+ const label = all ? "Pi CLI and configured packages" : "Pi CLI";
5097
5225
  if (options.piBinExplicit) {
5098
- const command = await resolvePiCommand(["update"]);
5099
- return { ...command, label: "Pi CLI and configured packages" };
5226
+ const command = await resolvePiCommand(updateArgs);
5227
+ return { ...command, label, timeoutMs: PI_UPDATE_TIMEOUT_MS, maxOutputLength: PI_UPDATE_OUTPUT_MAX_CHARS };
5100
5228
  }
5101
5229
 
5102
5230
  const pathPi = await runCommand(options.piBin, ["--version"], { timeoutMs: 3000, maxOutputLength: 4000 });
5103
5231
  if (pathPi.exitCode === 0 && !pathPi.timedOut && !pathPi.error) {
5104
- return { label: "Pi CLI and configured packages", command: options.piBin, args: ["update"], displayCommand: formatCommandForDisplay(options.piBin, ["update"]) };
5232
+ return { label, command: options.piBin, args: updateArgs, displayCommand: formatCommandForDisplay(options.piBin, updateArgs), timeoutMs: PI_UPDATE_TIMEOUT_MS, maxOutputLength: PI_UPDATE_OUTPUT_MAX_CHARS };
5105
5233
  }
5106
5234
 
5107
- const fallback = await resolvePiCommand(["update"]);
5108
- return { ...fallback, label: "bundled Pi CLI and configured packages" };
5235
+ const fallback = await resolvePiCommand(updateArgs);
5236
+ return { ...fallback, label: `bundled ${label}`, timeoutMs: PI_UPDATE_TIMEOUT_MS, maxOutputLength: PI_UPDATE_OUTPUT_MAX_CHARS };
5109
5237
  }
5110
5238
 
5111
5239
  function packageNodeModulesPath(nodeModulesRoot, packageName) {
@@ -5280,13 +5408,9 @@ function uniqueUpdateTasks(tasks) {
5280
5408
  return unique;
5281
5409
  }
5282
5410
 
5283
- async function resolveUpdateTasks() {
5411
+ async function resolveUpdateTasks({ all = false } = {}) {
5284
5412
  return uniqueUpdateTasks([
5285
- await resolvePiUpdateCommand(),
5286
- await currentWebuiPackageUpdateTask(),
5287
- await agentPackageRootUpdateTask(),
5288
- await npmGlobalPackageRootUpdateTask(),
5289
- await bunGlobalPackageRootUpdateTask(),
5413
+ await resolvePiUpdateCommand({ all }),
5290
5414
  ]);
5291
5415
  }
5292
5416
 
@@ -5327,16 +5451,17 @@ function combinedUpdateOutput(results, field) {
5327
5451
  .join("\n\n");
5328
5452
  }
5329
5453
 
5330
- async function runPiUpdateAndPrepareRestart() {
5454
+ async function runPiUpdateAndPrepareRestart({ all = false } = {}) {
5331
5455
  if (piUpdateInProgress) throw makeHttpError(409, "A Pi update is already running.");
5332
5456
  piUpdateInProgress = true;
5333
5457
  let restartPrepared = false;
5334
5458
  try {
5335
5459
  const restorableTabs = await restorableTabsForRestart();
5336
- const updateTasks = await resolveUpdateTasks();
5337
- if (!updateTasks.length) throw makeHttpError(500, "No Pi/Web UI update commands could be resolved.");
5460
+ const updateTasks = await resolveUpdateTasks({ all });
5461
+ if (!updateTasks.length) throw makeHttpError(500, "No Pi update command could be resolved.");
5338
5462
  const command = updateTasks.map(updateTaskDisplay).join(" && ");
5339
- recordEvent({ type: "webui_update_started", command, restorableTabCount: restorableTabs.length });
5463
+ const updateLabel = all ? "Pi and package updates" : "Pi update";
5464
+ recordEvent({ type: "webui_update_started", command, updateAll: all, restorableTabCount: restorableTabs.length });
5340
5465
  const results = [];
5341
5466
  for (const task of updateTasks) results.push(await runUpdateTask(task));
5342
5467
 
@@ -5344,9 +5469,9 @@ async function runPiUpdateAndPrepareRestart() {
5344
5469
  updateStatusCacheAt = 0;
5345
5470
  const child = spawnRestartServer(restorableTabs);
5346
5471
  restartPrepared = true;
5347
- recordEvent({ type: "webui_update_restarting", command, nextWebuiPid: child.pid, restorableTabCount: restorableTabs.length });
5472
+ recordEvent({ type: "webui_update_restarting", command, updateAll: all, nextWebuiPid: child.pid, restorableTabCount: restorableTabs.length });
5348
5473
  return {
5349
- message: "Pi/Web UI package updates completed. Pi Web UI is restarting.",
5474
+ message: `${updateLabel} completed. Pi Web UI is restarting.`,
5350
5475
  command,
5351
5476
  commands: results.map((result) => ({ label: result.label, command: result.command })),
5352
5477
  stdout: combinedUpdateOutput(results, "stdout"),
@@ -7236,7 +7361,10 @@ const server = createServer(async (req, res) => {
7236
7361
 
7237
7362
  if (url.pathname === "/api/update" && req.method === "POST") {
7238
7363
  requireLocalhostRoute(req, url.pathname);
7239
- const data = await runPiUpdateAndPrepareRestart();
7364
+ const body = await readJsonBody(req);
7365
+ const queryAll = ["1", "true", "yes", "all"].includes(String(url.searchParams.get("all") || "").toLowerCase());
7366
+ const bodyAll = body?.all === true || String(body?.mode || "").toLowerCase() === "all";
7367
+ const data = await runPiUpdateAndPrepareRestart({ all: queryAll || bodyAll });
7240
7368
  sendJson(res, 200, { ok: true, data });
7241
7369
  setTimeout(() => shutdown("api update"), 20).unref();
7242
7370
  return;
@@ -7572,6 +7700,17 @@ const server = createServer(async (req, res) => {
7572
7700
  return;
7573
7701
  }
7574
7702
 
7703
+ if (url.pathname === "/api/git-changes/pull" && req.method === "POST") {
7704
+ const body = await readJsonBody(req);
7705
+ const tab = getRequestedTab(req, url, body);
7706
+ try {
7707
+ sendJson(res, 200, await pullGitChanges(tab.cwd));
7708
+ } catch (error) {
7709
+ sendJson(res, 200, { ok: false, error: sanitizeError(error) });
7710
+ }
7711
+ return;
7712
+ }
7713
+
7575
7714
  if (url.pathname === "/api/git-branches" && req.method === "GET") {
7576
7715
  const tab = getRequestedTab(req, url);
7577
7716
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstpick/pi-package-webui",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
4
4
  "description": "Pi Web UI companion package with a local browser UI CLI plus /webui-start and /webui-status commands.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/Firstp1ck/npm-packages/tree/main/pi-package-webui#readme",
@@ -53,7 +53,7 @@
53
53
  "test": "node tests/run-all.mjs"
54
54
  },
55
55
  "dependencies": {
56
- "@earendil-works/pi-coding-agent": "^0.79.5"
56
+ "@earendil-works/pi-coding-agent": "^0.79.7"
57
57
  },
58
58
  "optionalDependencies": {
59
59
  "@firstpick/pi-extension-btw": "^0.1.0",
@@ -66,7 +66,7 @@
66
66
  "@firstpick/pi-extension-todo-progress": "^0.2.4",
67
67
  "@firstpick/pi-extension-tools": "^0.1.6",
68
68
  "@firstpick/pi-extension-workflows": "^0.1.0",
69
- "@firstpick/pi-package-remote-webui": "^0.1.0",
69
+ "@firstpick/pi-package-remote-webui": "^0.1.2",
70
70
  "@firstpick/pi-prompts-git-pr": "^0.1.2",
71
71
  "@firstpick/pi-themes-bundle": "^0.1.4"
72
72
  },
@@ -84,5 +84,12 @@
84
84
  ],
85
85
  "engines": {
86
86
  "node": ">=22.19.0"
87
+ },
88
+ "overrides": {
89
+ "@earendil-works/pi-coding-agent": {
90
+ "protobufjs": "7.6.3",
91
+ "undici": "8.5.0",
92
+ "ws": "8.21.0"
93
+ }
87
94
  }
88
95
  }