@firstpick/pi-package-webui 0.4.7 → 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
@@ -3155,40 +3155,43 @@ async function runGitReadCommand(root, args, { timeoutMs = GIT_CHANGES_COMMAND_T
3155
3155
  throw new Error(String(message).trim());
3156
3156
  }
3157
3157
 
3158
- function gitBranchFromStatus(statusText) {
3159
- const branchLine = String(statusText || "").split(/\r?\n/).find((line) => line.startsWith("## ")) || "";
3160
- 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";
3161
3165
  }
3162
3166
 
3163
- function gitDivergenceFromBranchStatus(line) {
3164
- const details = String(line || "").match(/\[(.+)\]\s*$/)?.[1] || "";
3165
- const ahead = Number.parseInt(details.match(/ahead\s+(\d+)/i)?.[1] || "0", 10) || 0;
3166
- const behind = Number.parseInt(details.match(/behind\s+(\d+)/i)?.[1] || "0", 10) || 0;
3167
- 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;
3168
3172
  }
3169
3173
 
3170
- function summarizeGitShortStatus(statusText) {
3174
+ function summarizeGitPorcelainStatus(statusText) {
3171
3175
  const summary = { staged: 0, unstaged: 0, untracked: 0, conflicted: 0, ahead: 0, behind: 0 };
3172
3176
  for (const line of String(statusText || "").split(/\r?\n/)) {
3173
3177
  if (!line) continue;
3174
- if (line.startsWith("## ")) {
3175
- const divergence = gitDivergenceFromBranchStatus(line);
3176
- summary.ahead = divergence.ahead;
3177
- 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
+ }
3178
3184
  continue;
3179
3185
  }
3180
- const x = line[0] || " ";
3181
- const y = line[1] || " ";
3182
- if (x === "?" && y === "?") {
3183
- summary.untracked += 1;
3186
+ if (line.startsWith("1 ") || line.startsWith("2 ")) {
3187
+ addGitPorcelainTrackedSummary(summary, line.split(" ")[1] || "..");
3184
3188
  continue;
3185
3189
  }
3186
- if (x === "U" || y === "U" || (x === "A" && y === "A") || (x === "D" && y === "D")) {
3190
+ if (line.startsWith("u ")) {
3187
3191
  summary.conflicted += 1;
3188
3192
  continue;
3189
3193
  }
3190
- if (x && x !== " ") summary.staged += 1;
3191
- if (y && y !== " ") summary.unstaged += 1;
3194
+ if (line.startsWith("? ")) summary.untracked += 1;
3192
3195
  }
3193
3196
  return summary;
3194
3197
  }
@@ -3293,20 +3296,21 @@ async function pullGitChanges(cwd) {
3293
3296
  async function readGitChanges(cwd) {
3294
3297
  const root = await getGitRoot(cwd);
3295
3298
  const diffArgs = ["diff", "--no-ext-diff", "--no-color", "--find-renames", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/"];
3296
- const [statusText, unstagedDiff, stagedDiff, untrackedText] = await Promise.all([
3299
+ const [statusText, porcelainStatusText, unstagedDiff, stagedDiff, untrackedText] = await Promise.all([
3297
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 }),
3298
3302
  runGitReadCommand(root, diffArgs),
3299
3303
  runGitReadCommand(root, ["diff", "--cached", "--no-ext-diff", "--no-color", "--find-renames", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/"]),
3300
3304
  runGitReadCommand(root, ["ls-files", "--others", "--exclude-standard"], { maxOutputLength: 120_000 }),
3301
3305
  ]);
3302
- const summary = summarizeGitShortStatus(statusText);
3306
+ const summary = summarizeGitPorcelainStatus(porcelainStatusText);
3303
3307
  const incoming = await readGitIncomingChanges(root, summary);
3304
3308
  const untrackedFiles = untrackedText.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
3305
3309
  const untracked = await readGitUntrackedEntries(root, untrackedFiles);
3306
3310
  return {
3307
3311
  cwd,
3308
3312
  root,
3309
- branch: gitBranchFromStatus(statusText),
3313
+ branch: gitBranchFromPorcelainStatus(porcelainStatusText),
3310
3314
  generatedAt: new Date().toISOString(),
3311
3315
  summary,
3312
3316
  remote: incoming.remote,
@@ -4276,6 +4280,9 @@ try {
4276
4280
  process.exit(2);
4277
4281
  }
4278
4282
 
4283
+ process.env.PI_WEBUI_HOST = options.host;
4284
+ process.env.PI_WEBUI_PORT = String(options.port);
4285
+
4279
4286
  const startupDelayMs = Number.parseInt(process.env.PI_WEBUI_START_DELAY_MS || "", 10);
4280
4287
  delete process.env.PI_WEBUI_START_DELAY_MS;
4281
4288
  if (Number.isFinite(startupDelayMs) && startupDelayMs > 0) {
@@ -5198,32 +5205,35 @@ async function getUpdateStatus({ force = false } = {}) {
5198
5205
  checkedAt: new Date(now).toISOString(),
5199
5206
  updateAvailable,
5200
5207
  restartRequired: true,
5201
- command: "pi update + Web UI/Pi package-manager updates",
5208
+ command: "pi update",
5209
+ allCommand: "pi update --all",
5202
5210
  webuiDev: webuiDevServer,
5203
5211
  pi: piStatus,
5204
5212
  webui: webuiStatus,
5205
5213
  packages: {
5206
5214
  checked: false,
5207
- 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."
5208
5216
  },
5209
5217
  };
5210
5218
  updateStatusCacheAt = now;
5211
5219
  return updateStatusCache;
5212
5220
  }
5213
5221
 
5214
- 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";
5215
5225
  if (options.piBinExplicit) {
5216
- const command = await resolvePiCommand(["update"]);
5217
- 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 };
5218
5228
  }
5219
5229
 
5220
5230
  const pathPi = await runCommand(options.piBin, ["--version"], { timeoutMs: 3000, maxOutputLength: 4000 });
5221
5231
  if (pathPi.exitCode === 0 && !pathPi.timedOut && !pathPi.error) {
5222
- 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 };
5223
5233
  }
5224
5234
 
5225
- const fallback = await resolvePiCommand(["update"]);
5226
- 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 };
5227
5237
  }
5228
5238
 
5229
5239
  function packageNodeModulesPath(nodeModulesRoot, packageName) {
@@ -5398,13 +5408,9 @@ function uniqueUpdateTasks(tasks) {
5398
5408
  return unique;
5399
5409
  }
5400
5410
 
5401
- async function resolveUpdateTasks() {
5411
+ async function resolveUpdateTasks({ all = false } = {}) {
5402
5412
  return uniqueUpdateTasks([
5403
- await resolvePiUpdateCommand(),
5404
- await currentWebuiPackageUpdateTask(),
5405
- await agentPackageRootUpdateTask(),
5406
- await npmGlobalPackageRootUpdateTask(),
5407
- await bunGlobalPackageRootUpdateTask(),
5413
+ await resolvePiUpdateCommand({ all }),
5408
5414
  ]);
5409
5415
  }
5410
5416
 
@@ -5445,16 +5451,17 @@ function combinedUpdateOutput(results, field) {
5445
5451
  .join("\n\n");
5446
5452
  }
5447
5453
 
5448
- async function runPiUpdateAndPrepareRestart() {
5454
+ async function runPiUpdateAndPrepareRestart({ all = false } = {}) {
5449
5455
  if (piUpdateInProgress) throw makeHttpError(409, "A Pi update is already running.");
5450
5456
  piUpdateInProgress = true;
5451
5457
  let restartPrepared = false;
5452
5458
  try {
5453
5459
  const restorableTabs = await restorableTabsForRestart();
5454
- const updateTasks = await resolveUpdateTasks();
5455
- 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.");
5456
5462
  const command = updateTasks.map(updateTaskDisplay).join(" && ");
5457
- 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 });
5458
5465
  const results = [];
5459
5466
  for (const task of updateTasks) results.push(await runUpdateTask(task));
5460
5467
 
@@ -5462,9 +5469,9 @@ async function runPiUpdateAndPrepareRestart() {
5462
5469
  updateStatusCacheAt = 0;
5463
5470
  const child = spawnRestartServer(restorableTabs);
5464
5471
  restartPrepared = true;
5465
- 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 });
5466
5473
  return {
5467
- message: "Pi/Web UI package updates completed. Pi Web UI is restarting.",
5474
+ message: `${updateLabel} completed. Pi Web UI is restarting.`,
5468
5475
  command,
5469
5476
  commands: results.map((result) => ({ label: result.label, command: result.command })),
5470
5477
  stdout: combinedUpdateOutput(results, "stdout"),
@@ -7354,7 +7361,10 @@ const server = createServer(async (req, res) => {
7354
7361
 
7355
7362
  if (url.pathname === "/api/update" && req.method === "POST") {
7356
7363
  requireLocalhostRoute(req, url.pathname);
7357
- 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 });
7358
7368
  sendJson(res, 200, { ok: true, data });
7359
7369
  setTimeout(() => shutdown("api update"), 20).unref();
7360
7370
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstpick/pi-package-webui",
3
- "version": "0.4.7",
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",
package/public/app.js CHANGED
@@ -24,6 +24,7 @@ const elements = {
24
24
  updateNotificationMessage: $("#updateNotificationMessage"),
25
25
  updateNotificationDetail: $("#updateNotificationDetail"),
26
26
  updateNotificationUpdateButton: $("#updateNotificationUpdateButton"),
27
+ updateNotificationUpdateAllButton: $("#updateNotificationUpdateAllButton"),
27
28
  updateNotificationDismissButton: $("#updateNotificationDismissButton"),
28
29
  serverOfflineCommand: $("#serverOfflineCommand"),
29
30
  serverOfflineSlashCommand: $("#serverOfflineSlashCommand"),
@@ -129,6 +130,7 @@ const elements = {
129
130
  backgroundChooseButton: $("#backgroundChooseButton"),
130
131
  backgroundClearButton: $("#backgroundClearButton"),
131
132
  backgroundStatus: $("#backgroundStatus"),
133
+ networkControlField: $("#networkControlField"),
132
134
  networkStatus: $("#networkStatus"),
133
135
  remoteAuthToggle: $("#remoteAuthToggle"),
134
136
  remoteAuthStatus: $("#remoteAuthStatus"),
@@ -436,6 +438,9 @@ const GIT_INIT_STACK_STORAGE_KEY = "pi-webui-git-init-stack";
436
438
  const STATS_WEBUI_STATUS_KEY = "stats-webui";
437
439
  const STATS_WEBUI_PAYLOAD_TYPE = "firstpick.pi-extension-stats.overlay";
438
440
  const STATS_WEBUI_PAYLOAD_VERSION = 1;
441
+ const REMOTE_WEBUI_CONTROLS_STATUS_KEY = "pi-remote-webui:controls";
442
+ const REMOTE_WEBUI_CONTROLS_PAYLOAD_TYPE = "firstpick.pi-package-remote-webui.controls";
443
+ const REMOTE_WEBUI_CONTROLS_PAYLOAD_VERSION = 1;
439
444
  const BTW_WEBUI_STATUS_KEY = "btw-webui";
440
445
  const BTW_OUTPUT_WIDGET_KEY = "btw:output";
441
446
  const BTW_FOOTER_WIDGET_KEY = "btw:footer";
@@ -2825,22 +2830,34 @@ function renderUpdateNotification(status = latestUpdateStatus, { force = false }
2825
2830
  }
2826
2831
 
2827
2832
  const canRunUpdate = latestUpdateStatus.canRunUpdate !== false;
2833
+ const hasPiUpdate = !!latestUpdateStatus.pi?.updateAvailable;
2834
+ const hasPackageUpdate = !!latestUpdateStatus.webui?.updateAvailable;
2828
2835
  if (elements.updateNotificationTitle) elements.updateNotificationTitle.textContent = items.length === 1 ? `${items[0]} available` : "Pi updates available";
2829
2836
  if (elements.updateNotificationMessage) {
2830
- elements.updateNotificationMessage.textContent = canRunUpdate
2831
- ? "Run Pi and Web UI package updates now, then restart this Web UI server automatically."
2832
- : "Updates are available. Direct Web UI updates are only enabled from localhost on the host machine.";
2837
+ let message = "Updates are available. Direct Web UI updates are only enabled from localhost on the host machine.";
2838
+ if (canRunUpdate) {
2839
+ if (hasPiUpdate && hasPackageUpdate) message = "Run pi update for Pi only, or pi update --all to include Web UI/package updates, then restart this Web UI server automatically.";
2840
+ else if (hasPackageUpdate) message = "Run pi update --all to update Web UI/package entries, then restart this Web UI server automatically.";
2841
+ else message = "Run pi update for Pi only, then restart this Web UI server automatically.";
2842
+ }
2843
+ elements.updateNotificationMessage.textContent = message;
2833
2844
  }
2834
2845
  const details = [
2835
2846
  items.join(" · "),
2836
- latestUpdateStatus.webuiDev && latestUpdateStatus.webui?.updateAvailable ? "The current Web UI is a dev checkout; update also refreshes this checkout's Web UI/Pi package dependencies when possible." : "",
2847
+ latestUpdateStatus.webuiDev && latestUpdateStatus.webui?.updateAvailable ? "The current Web UI is a dev checkout; pi update --all refreshes configured package dependencies when possible." : "",
2837
2848
  latestUpdateStatus.packages?.note || "",
2838
2849
  ].filter(Boolean).join(" ");
2839
2850
  if (elements.updateNotificationDetail) elements.updateNotificationDetail.textContent = details;
2840
2851
  if (elements.updateNotificationUpdateButton) {
2841
- elements.updateNotificationUpdateButton.hidden = !canRunUpdate;
2852
+ elements.updateNotificationUpdateButton.hidden = !canRunUpdate || !hasPiUpdate;
2842
2853
  elements.updateNotificationUpdateButton.disabled = updateRequestInProgress || latestUpdateStatus.updateInProgress;
2843
- elements.updateNotificationUpdateButton.textContent = latestUpdateStatus.updateInProgress ? "Updating…" : "Update & restart";
2854
+ elements.updateNotificationUpdateButton.textContent = latestUpdateStatus.updateInProgress ? "Updating…" : "Update Pi & restart";
2855
+ }
2856
+ if (elements.updateNotificationUpdateAllButton) {
2857
+ elements.updateNotificationUpdateAllButton.hidden = !canRunUpdate || !hasPackageUpdate;
2858
+ elements.updateNotificationUpdateAllButton.disabled = updateRequestInProgress || latestUpdateStatus.updateInProgress;
2859
+ elements.updateNotificationUpdateAllButton.classList.toggle("primary", !hasPiUpdate);
2860
+ elements.updateNotificationUpdateAllButton.textContent = latestUpdateStatus.updateInProgress ? "Updating…" : "Update all & restart";
2844
2861
  }
2845
2862
  clearTimeout(updateNotificationHideTimer);
2846
2863
  panel.hidden = false;
@@ -2871,30 +2888,33 @@ function initializeUpdateNotifications() {
2871
2888
  }, UPDATE_STATUS_INITIAL_DELAY_MS);
2872
2889
  }
2873
2890
 
2874
- function piUpdateConfirmationText() {
2891
+ function piUpdateConfirmationText({ all = false } = {}) {
2875
2892
  const items = updateNotificationItems();
2876
2893
  const workingWarning = hasWorkingTab() ? "\n\nOne or more Pi tabs look busy or blocked. Finish or abort in-flight work before updating if you need to preserve it." : "";
2877
2894
  const versionText = items.length ? `\n\nDetected update: ${items.join(" · ")}.` : "";
2878
- return `Run Pi/Web UI package updates now?${versionText}\n\nThis will run \"pi update\" plus detected local and global Web UI/Pi package-manager updates on the Web UI host. After it finishes, Pi Web UI will restart itself. Browser clients will briefly disconnect, and managed Pi tabs/RPC processes will be restarted from saved session state when possible.${workingWarning}`;
2895
+ const command = all ? "pi update --all" : "pi update";
2896
+ const scope = all ? "Pi and configured package updates" : "Pi only";
2897
+ return `Run ${scope} now?${versionText}\n\nThis will run \"${command}\" on the Web UI host. After it finishes, Pi Web UI will restart itself. Browser clients will briefly disconnect, and managed Pi tabs/RPC processes will be restarted from saved session state when possible.${workingWarning}`;
2879
2898
  }
2880
2899
 
2881
- async function runPiUpdateAndRestart() {
2900
+ async function runPiUpdateAndRestart({ all = false } = {}) {
2882
2901
  if (updateRequestInProgress) return;
2883
2902
  if (latestUpdateStatus?.canRunUpdate === false) {
2884
- addEvent("Pi/Web UI package updates can only be started from localhost on the Web UI host", "warn");
2903
+ addEvent("Pi updates can only be started from localhost on the Web UI host", "warn");
2885
2904
  renderUpdateNotification(latestUpdateStatus, { force: true });
2886
2905
  return;
2887
2906
  }
2888
- if (!confirm(piUpdateConfirmationText())) return;
2907
+ if (!confirm(piUpdateConfirmationText({ all }))) return;
2889
2908
 
2909
+ const updateLabel = all ? "Pi and package updates" : "Pi update";
2890
2910
  updateRequestInProgress = true;
2891
2911
  hideUpdateNotification();
2892
2912
  setServerActionBusy("Updating…");
2893
- setServerActionStatus("Running Pi/Web UI package updates. The server will restart after the update completes…", "warn");
2894
- setServerRestartOverlay(true, "Running Pi/Web UI package updates. The server will restart after the update completes…");
2913
+ setServerActionStatus(`Running ${updateLabel}. The server will restart after the update completes…`, "warn");
2914
+ setServerRestartOverlay(true, `Running ${updateLabel}. The server will restart after the update completes…`);
2895
2915
  try {
2896
- await api("/api/update", { method: "POST", scoped: false });
2897
- addEvent("Pi/Web UI package updates completed; Pi Web UI server restart requested", "warn");
2916
+ await api(all ? "/api/update?all=1" : "/api/update", { method: "POST", scoped: false });
2917
+ addEvent(`${updateLabel} completed; Pi Web UI server restart requested`, "warn");
2898
2918
  } catch (error) {
2899
2919
  if (!error?.backendOffline) {
2900
2920
  updateRequestInProgress = false;
@@ -3811,6 +3831,11 @@ function setOptionalFeatureDisabled(featureId, disabled) {
3811
3831
  btwWidgetComposerOpen = false;
3812
3832
  btwWidgetInputDraft = "";
3813
3833
  }
3834
+ if (featureId === "remoteWebui") {
3835
+ statusEntries.delete(REMOTE_WEBUI_CONTROLS_STATUS_KEY);
3836
+ statusEntries.delete("pi-remote-webui");
3837
+ widgets.delete("pi-remote-webui");
3838
+ }
3814
3839
  storeDisabledOptionalFeatures();
3815
3840
  renderOptionalFeatureDependentDisplays();
3816
3841
  const tabContext = activeTabContext();
@@ -5978,6 +6003,57 @@ function parseGitFooterWebuiPayloadRaw(raw) {
5978
6003
  }
5979
6004
  }
5980
6005
 
6006
+ function parseRemoteWebuiControlsPayloadRaw(raw) {
6007
+ if (!raw) return null;
6008
+ try {
6009
+ const parsed = JSON.parse(raw);
6010
+ if (!parsed || parsed.type !== REMOTE_WEBUI_CONTROLS_PAYLOAD_TYPE || parsed.version !== REMOTE_WEBUI_CONTROLS_PAYLOAD_VERSION) return null;
6011
+ if (parsed.featureId !== "remoteWebui") return null;
6012
+ const commands = parsed.commands && typeof parsed.commands === "object" ? parsed.commands : {};
6013
+ return {
6014
+ title: cleanFooterPayloadText(parsed.title, "Remote WebUI", 80),
6015
+ description: cleanFooterPayloadText(parsed.description, "Trusted-LAN browser access controlled by the Remote WebUI package.", 240),
6016
+ commands: {
6017
+ open: typeof commands.open === "string" ? commands.open : "/remote",
6018
+ close: typeof commands.close === "string" ? commands.close : "/remote close",
6019
+ refresh: typeof commands.refresh === "string" ? commands.refresh : "/remote refresh",
6020
+ status: typeof commands.status === "string" ? commands.status : "/remote status",
6021
+ authOn: typeof commands.authOn === "string" ? commands.authOn : "/remote auth on",
6022
+ authOff: typeof commands.authOff === "string" ? commands.authOff : "/remote auth off",
6023
+ },
6024
+ };
6025
+ } catch {
6026
+ return null;
6027
+ }
6028
+ }
6029
+
6030
+ function remoteWebuiControlsPayload() {
6031
+ if (isOptionalFeatureDisabled("remoteWebui")) return null;
6032
+ return parseRemoteWebuiControlsPayloadRaw(statusEntries.get(REMOTE_WEBUI_CONTROLS_STATUS_KEY));
6033
+ }
6034
+
6035
+ function remoteWebuiDefaultPortArg() {
6036
+ const port = Number.parseInt(String(latestNetwork?.port || DEFAULT_WEBUI_PORT), 10);
6037
+ return Number.isFinite(port) && port > 0 && port <= 65535 && String(port) !== DEFAULT_WEBUI_PORT ? ` --port ${port}` : "";
6038
+ }
6039
+
6040
+ function remoteWebuiFallbackCommand(name, fallback) {
6041
+ const portArg = remoteWebuiDefaultPortArg();
6042
+ const commands = {
6043
+ open: `/remote${portArg}`,
6044
+ close: `/remote close${portArg}`,
6045
+ refresh: `/remote refresh${portArg}`,
6046
+ status: `/remote status${portArg}`,
6047
+ authOn: `/remote auth on${portArg}`,
6048
+ authOff: `/remote auth off${portArg}`,
6049
+ };
6050
+ return commands[name] || fallback;
6051
+ }
6052
+
6053
+ function remoteWebuiCommand(name, fallback) {
6054
+ return remoteWebuiControlsPayload()?.commands?.[name] || remoteWebuiFallbackCommand(name, fallback);
6055
+ }
6056
+
5981
6057
  function readCachedGitFooterWebuiPayloadRaw() {
5982
6058
  try {
5983
6059
  const cached = JSON.parse(localStorage.getItem(GIT_FOOTER_WEBUI_PAYLOAD_CACHE_KEY) || "null");
@@ -14475,7 +14551,7 @@ function updateOptionalFeatureAvailability() {
14475
14551
  optionalFeatureAvailability.tuiSkillsCommand = hasLoadedRpcCommand("skills");
14476
14552
  optionalFeatureAvailability.todoProgressWidget = hasAvailableCommand("todo-progress-status") || optionalFeatureAvailability.todoProgressWidget || widgets.has("todo-progress");
14477
14553
  optionalFeatureAvailability.tuiToolsCommand = hasLoadedRpcCommand("tools");
14478
- optionalFeatureAvailability.remoteWebui = hasAvailableCommand("remote") || optionalFeatureAvailability.remoteWebui || statusEntries.has("pi-remote-webui") || widgets.has("pi-remote-webui");
14554
+ optionalFeatureAvailability.remoteWebui = hasAvailableCommand("remote") || optionalFeatureAvailability.remoteWebui || statusEntries.has("pi-remote-webui") || statusEntries.has(REMOTE_WEBUI_CONTROLS_STATUS_KEY) || widgets.has("pi-remote-webui");
14479
14555
  optionalFeatureAvailability.themeBundle = availableThemes.length > 0;
14480
14556
  requestGitFooterWebuiPayload();
14481
14557
  renderOptionalFeatureControls();
@@ -14633,6 +14709,14 @@ function renderOptionalFeatureControls() {
14633
14709
  optionalFeatureUnavailableMessage("remoteWebui"),
14634
14710
  );
14635
14711
  }
14712
+ if (elements.networkControlField) {
14713
+ elements.networkControlField.hidden = !hasRemoteWebuiCommand;
14714
+ elements.networkControlField.classList.toggle("feature-unavailable", !hasRemoteWebuiCommand);
14715
+ const label = elements.networkControlField.querySelector("label");
14716
+ const payload = remoteWebuiControlsPayload();
14717
+ if (label) label.textContent = payload?.title || "Remote WebUI";
14718
+ elements.networkControlField.title = hasRemoteWebuiCommand ? payload?.description || "Remote WebUI controls are provided by @firstpick/pi-package-remote-webui." : optionalFeatureUnavailableMessage("remoteWebui");
14719
+ }
14636
14720
 
14637
14721
  renderOptionalFeaturePanel();
14638
14722
  }
@@ -16064,21 +16148,25 @@ async function refreshNetworkStatus() {
16064
16148
  renderNetworkStatus();
16065
16149
  }
16066
16150
 
16067
- async function toggleRemoteAuth() {
16068
- const enable = !latestNetwork?.auth?.enabled;
16069
- const message = enable
16070
- ? "Enable remote PIN authentication?\n\nA random 4-digit PIN will be required for non-local browser clients. The PIN is shown in Controls."
16071
- : "Disable remote PIN authentication?\n\nNon-local browser clients will no longer need a PIN while the network listener is open.";
16072
- if (!confirm(message)) {
16073
- renderNetworkStatus();
16074
- return;
16151
+ async function runRemoteWebuiCommand(command) {
16152
+ const commandName = String(command || "").replace(/^\//, "").split(/\s+/, 1)[0] || "remote";
16153
+ if (!isOptionalFeatureEnabled("remoteWebui") || !hasAvailableCommand(commandName)) {
16154
+ const message = commandUnavailableMessage(commandName);
16155
+ addEvent(message, "warn");
16156
+ refreshCommands(activeTabContext()).catch((error) => addEvent(error.message || String(error), "error"));
16157
+ return false;
16075
16158
  }
16159
+ await runNativeCommandMenu(command);
16160
+ return true;
16161
+ }
16076
16162
 
16163
+ async function toggleRemoteAuth() {
16164
+ const enable = !latestNetwork?.auth?.enabled;
16077
16165
  elements.remoteAuthToggle.disabled = true;
16078
16166
  try {
16079
- const response = await api("/api/remote-auth/settings", { method: "POST", body: { enabled: enable }, scoped: false });
16080
- latestNetwork = response.data?.network || { ...(latestNetwork || {}), auth: response.data?.auth };
16081
- addEvent(enable ? "remote PIN auth enabled" : "remote PIN auth disabled", enable ? "warn" : "info");
16167
+ await runRemoteWebuiCommand(remoteWebuiCommand(enable ? "authOn" : "authOff", enable ? "/remote auth on" : "/remote auth off"));
16168
+ await delay(250);
16169
+ await refreshNetworkStatus();
16082
16170
  } catch (error) {
16083
16171
  addEvent(error.message || String(error), "error");
16084
16172
  } finally {
@@ -16896,35 +16984,15 @@ function scheduleForegroundReconcile(reason = "resume", delay = FOREGROUND_RECON
16896
16984
  }
16897
16985
 
16898
16986
  async function openToNetwork() {
16899
- if (latestNetwork?.open) {
16900
- await closeNetworkAccess();
16901
- return;
16902
- }
16903
- if (!confirm(`Open Pi Web UI to your local network?\n\nRemote PIN auth is ${latestNetwork?.auth?.enabled ? "ON" : "OFF"}. The Web UI can control Pi/tools, so only do this on a trusted LAN.`)) return;
16904
-
16987
+ const open = !!latestNetwork?.open;
16905
16988
  elements.openNetworkButton.disabled = true;
16906
- elements.openNetworkButton.textContent = "Opening…";
16989
+ elements.openNetworkButton.textContent = open ? "Closing…" : "Opening…";
16907
16990
  try {
16908
- await api("/api/network/open", { method: "POST", scoped: false });
16909
- latestNetwork = { ...(latestNetwork || {}), opening: true, closing: false };
16910
- renderNetworkStatus();
16911
- addEvent("opening webui to local network", "warn");
16912
- for (let attempt = 0; attempt < 20; attempt++) {
16913
- await delay(350);
16914
- try {
16915
- await refreshNetworkStatus();
16916
- if (latestNetwork?.open && !latestNetwork?.opening) {
16917
- const url = latestNetwork.networkUrls?.[0];
16918
- addEvent(`webui open to local network${url ? `: ${url}` : ""}`, "warn");
16919
- return;
16920
- }
16921
- } catch {
16922
- // The listener briefly drops while rebinding; retry.
16923
- }
16924
- }
16991
+ await runRemoteWebuiCommand(remoteWebuiCommand(open ? "close" : "open", open ? "/remote close" : "/remote"));
16992
+ await delay(350);
16925
16993
  await refreshNetworkStatus();
16926
16994
  } catch (error) {
16927
- addEvent(error.message, "error");
16995
+ addEvent(error.message || String(error), "error");
16928
16996
  } finally {
16929
16997
  renderNetworkStatus();
16930
16998
  }
@@ -16932,41 +17000,7 @@ async function openToNetwork() {
16932
17000
 
16933
17001
  async function closeNetworkAccess() {
16934
17002
  if (!latestNetwork?.open) return;
16935
- if (!confirm("Close Pi Web UI network access?\n\nThe local browser can keep using the UI, but LAN clients will disconnect.")) return;
16936
-
16937
- elements.openNetworkButton.disabled = true;
16938
- elements.openNetworkButton.textContent = "Closing…";
16939
- try {
16940
- await api("/api/network/close", { method: "POST", scoped: false });
16941
- latestNetwork = { ...(latestNetwork || {}), opening: false, closing: true };
16942
- renderNetworkStatus();
16943
- addEvent("closing webui network access", "warn");
16944
- let refreshFailed = false;
16945
- for (let attempt = 0; attempt < 20; attempt++) {
16946
- await delay(350);
16947
- try {
16948
- await refreshNetworkStatus();
16949
- if (!latestNetwork?.open && !latestNetwork?.closing) {
16950
- addEvent("webui closed to local-only access", "warn");
16951
- return;
16952
- }
16953
- } catch {
16954
- refreshFailed = true;
16955
- // Remote tabs will lose access after the listener returns to localhost.
16956
- }
16957
- }
16958
- if (refreshFailed) {
16959
- latestNetwork = { ...(latestNetwork || {}), open: false, opening: false, closing: false, networkUrls: [] };
16960
- renderNetworkStatus();
16961
- addEvent("webui network access closed; reconnect from this machine if this tab loses access", "warn");
16962
- return;
16963
- }
16964
- addEvent("network close requested, but the server still reports network access open", "warn");
16965
- } catch (error) {
16966
- addEvent(error.message, "error");
16967
- } finally {
16968
- renderNetworkStatus();
16969
- }
17003
+ await openToNetwork();
16970
17004
  }
16971
17005
 
16972
17006
  function setServerActionStatus(message = "", level = "info") {
@@ -16982,10 +17016,11 @@ function updateServerActionButton() {
16982
17016
  const button = elements.runServerActionButton;
16983
17017
  if (!button) return;
16984
17018
  button.disabled = !action;
16985
- button.textContent = action === "restart" ? "Restart" : action === "update" ? "Update" : action === "stop" ? "Stop" : "Run";
17019
+ button.textContent = action === "restart" ? "Restart" : action === "update" || action === "update-all" ? "Update" : action === "stop" ? "Stop" : "Run";
16986
17020
  button.classList.toggle("danger", action === "stop");
16987
17021
  if (action === "restart") setServerActionStatus("Ready to restart the Web UI server.", "info");
16988
- else if (action === "update") setServerActionStatus("Ready to run pi update, then restart the Web UI server.", "info");
17022
+ else if (action === "update") setServerActionStatus("Ready to run pi update for Pi only, then restart the Web UI server.", "info");
17023
+ else if (action === "update-all") setServerActionStatus("Ready to run pi update --all for Pi and configured packages, then restart the Web UI server.", "info");
16989
17024
  else if (action === "stop") setServerActionStatus("Ready to stop the Web UI server.", "info");
16990
17025
  else setServerActionStatus();
16991
17026
  }
@@ -17093,6 +17128,7 @@ async function runSelectedServerAction() {
17093
17128
  const action = elements.serverActionSelect?.value || "";
17094
17129
  if (action === "restart") await restartServer();
17095
17130
  else if (action === "update") await runPiUpdateAndRestart();
17131
+ else if (action === "update-all") await runPiUpdateAndRestart({ all: true });
17096
17132
  else if (action === "stop") await stopServer();
17097
17133
  }
17098
17134
 
@@ -18477,6 +18513,7 @@ elements.openNetworkButton.addEventListener("click", openToNetwork);
18477
18513
  elements.serverActionSelect.addEventListener("change", updateServerActionButton);
18478
18514
  elements.runServerActionButton.addEventListener("click", () => runSelectedServerAction().catch((error) => addEvent(error.message || String(error), "error")));
18479
18515
  elements.updateNotificationUpdateButton?.addEventListener("click", () => runPiUpdateAndRestart().catch((error) => addEvent(error.message || String(error), "error")));
18516
+ elements.updateNotificationUpdateAllButton?.addEventListener("click", () => runPiUpdateAndRestart({ all: true }).catch((error) => addEvent(error.message || String(error), "error")));
18480
18517
  elements.updateNotificationDismissButton?.addEventListener("click", () => hideUpdateNotification({ remember: true }));
18481
18518
  updateServerActionButton();
18482
18519
  elements.agentDoneNotificationsToggle.addEventListener("change", () => {
package/public/index.html CHANGED
@@ -52,7 +52,8 @@
52
52
  <p id="updateNotificationMessage">A newer Pi version is available.</p>
53
53
  <p id="updateNotificationDetail" class="update-notification-detail muted"></p>
54
54
  <div class="update-notification-actions">
55
- <button id="updateNotificationUpdateButton" class="primary" type="button">Update &amp; restart</button>
55
+ <button id="updateNotificationUpdateButton" class="primary" type="button">Update Pi &amp; restart</button>
56
+ <button id="updateNotificationUpdateAllButton" type="button">Update all &amp; restart</button>
56
57
  <button id="updateNotificationDismissButton" type="button">Later</button>
57
58
  </div>
58
59
  </div>
@@ -379,8 +380,8 @@
379
380
  </div>
380
381
  <div id="backgroundStatus" class="background-status muted">Theme default</div>
381
382
  </div>
382
- <div class="control-field network-control-field">
383
- <label>Network</label>
383
+ <div id="networkControlField" class="control-field network-control-field" hidden>
384
+ <label>Remote WebUI</label>
384
385
  <div id="networkStatus" class="network-status closed">Local only</div>
385
386
  <label class="toggle-control remote-auth-toggle" for="remoteAuthToggle">
386
387
  <input id="remoteAuthToggle" type="checkbox" />
@@ -398,6 +399,7 @@
398
399
  <option value="" selected>Choose action…</option>
399
400
  <option value="restart">Restart Server</option>
400
401
  <option value="update">Update Pi &amp; Restart</option>
402
+ <option value="update-all">Update Pi + Packages &amp; Restart</option>
401
403
  <option value="stop">Stop Server</option>
402
404
  </select>
403
405
  <button id="runServerActionButton" type="button" disabled>Run</button>
@@ -37,6 +37,22 @@ async function request(host, pathname, { method = "GET", body, timeoutMs = 5_000
37
37
  return { status: response.status, body: payload };
38
38
  }
39
39
 
40
+ function runGitFixture(args, cwd, message) {
41
+ const result = spawnSync("git", args, {
42
+ cwd,
43
+ encoding: "utf8",
44
+ env: {
45
+ ...process.env,
46
+ GIT_AUTHOR_NAME: "Pi WebUI Test",
47
+ GIT_AUTHOR_EMAIL: "pi-webui-test@example.invalid",
48
+ GIT_COMMITTER_NAME: "Pi WebUI Test",
49
+ GIT_COMMITTER_EMAIL: "pi-webui-test@example.invalid",
50
+ },
51
+ });
52
+ assert.equal(result.status, 0, `${message}\n$ git ${args.join(" ")}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
53
+ return result.stdout.trim();
54
+ }
55
+
40
56
  const cwd = await mkdtemp(path.join(tmpdir(), "pi-webui-http-harness-"));
41
57
  const settingsFile = path.join(cwd, "webui-settings.json");
42
58
  await chmod(fakePi, 0o755);
@@ -152,6 +168,47 @@ try {
152
168
  assert.equal(gitMain.status, 200);
153
169
  assert.equal(gitMain.body?.ok, true, "main branch endpoint should rename the branch");
154
170
 
171
+ const remoteFixtureRoot = await mkdtemp(path.join(tmpdir(), "pi-webui-git-remote-"));
172
+ const remoteBare = path.join(remoteFixtureRoot, "origin.git");
173
+ const localRepo = path.join(remoteFixtureRoot, "local");
174
+ const remoteWork = path.join(remoteFixtureRoot, "remote-work");
175
+ runGitFixture(["init", "--bare", remoteBare], remoteFixtureRoot, "remote fixture should initialize a bare origin");
176
+ runGitFixture(["init", localRepo], remoteFixtureRoot, "remote fixture should initialize a local repo");
177
+ runGitFixture(["config", "user.name", "Pi WebUI Test"], localRepo, "local repo should set a user name");
178
+ runGitFixture(["config", "user.email", "pi-webui-test@example.invalid"], localRepo, "local repo should set a user email");
179
+ await writeFile(path.join(localRepo, "incoming.txt"), "base\n");
180
+ runGitFixture(["add", "incoming.txt"], localRepo, "local repo should stage base content");
181
+ runGitFixture(["commit", "-m", "base"], localRepo, "local repo should commit base content");
182
+ runGitFixture(["branch", "-M", "main"], localRepo, "local repo should rename main branch");
183
+ runGitFixture(["remote", "add", "origin", remoteBare], localRepo, "local repo should add bare origin");
184
+ runGitFixture(["push", "-u", "origin", "main"], localRepo, "local repo should push main to bare origin");
185
+ runGitFixture(["symbolic-ref", "HEAD", "refs/heads/main"], remoteBare, "bare origin should advertise main as HEAD");
186
+ runGitFixture(["clone", remoteBare, remoteWork], remoteFixtureRoot, "remote worktree should clone bare origin");
187
+ runGitFixture(["config", "user.name", "Pi WebUI Test"], remoteWork, "remote worktree should set a user name");
188
+ runGitFixture(["config", "user.email", "pi-webui-test@example.invalid"], remoteWork, "remote worktree should set a user email");
189
+ await writeFile(path.join(remoteWork, "incoming.txt"), "base\nremote one\n");
190
+ runGitFixture(["commit", "-am", "remote one"], remoteWork, "remote worktree should commit first incoming change");
191
+ await writeFile(path.join(remoteWork, "incoming.txt"), "base\nremote one\nremote two\n");
192
+ runGitFixture(["commit", "-am", "remote two"], remoteWork, "remote worktree should commit second incoming change");
193
+ runGitFixture(["push", "origin", "main"], remoteWork, "remote worktree should push incoming commits");
194
+ runGitFixture(["fetch", "origin"], localRepo, "local repo should fetch incoming commits");
195
+
196
+ const remoteTab = await request("127.0.0.1", "/api/tabs", { method: "POST", body: { cwd: localRepo, title: "remote-behind-fixture" } });
197
+ assert.equal(remoteTab.status, 201);
198
+ const remoteTabId = remoteTab.body?.data?.tab?.id;
199
+ assert.ok(remoteTabId, "remote fixture tab should have an id");
200
+ const incomingChanges = await request("127.0.0.1", `/api/git-changes?tab=${encodeURIComponent(remoteTabId)}`);
201
+ assert.equal(incomingChanges.status, 200);
202
+ assert.equal(incomingChanges.body?.ok, true, "git changes endpoint should load a fetched-behind repo");
203
+ assert.equal(incomingChanges.body?.data?.summary?.behind, 2, "git changes endpoint should report two fetched commits behind");
204
+ assert.equal(incomingChanges.body?.data?.remote?.canPull, true, "git changes endpoint should mark fetched commits as pullable");
205
+ assert.ok(incomingChanges.body?.data?.sections?.some((section) => section.key === "incoming"), "git changes endpoint should include an incoming diff section");
206
+
207
+ const pullIncoming = await request("127.0.0.1", "/api/git-changes/pull", { method: "POST", body: { tab: remoteTabId }, timeoutMs: 20_000 });
208
+ assert.equal(pullIncoming.status, 200);
209
+ assert.equal(pullIncoming.body?.ok, true, "pull endpoint should fast-forward fetched incoming commits");
210
+ assert.equal(pullIncoming.body?.data?.changes?.summary?.behind, 0, "pull endpoint should refresh changes with no remote commits left behind");
211
+
155
212
  const gitRemote = await request("127.0.0.1", "/api/git-workflow/remote", { method: "POST", body: { username: "Firstp1ck", repoName: "pi-webui-http-harness", tab: tabId } });
156
213
  assert.equal(gitRemote.status, 200);
157
214
  assert.equal(gitRemote.body?.ok, true, "remote endpoint should add origin without pushing");
@@ -357,6 +357,7 @@ assert.match(app, /function updateGitChangesCurrentFileHeader\(\)[\s\S]*?querySe
357
357
  assert.match(server, /async function readGitUntrackedEntry\(root, file\)[\s\S]*?content: binary \? "" : buffer\.toString\("utf8"\)/, "server should include complete text contents for untracked files");
358
358
  assert.match(server, /url\.pathname === "\/api\/git-changes\/untracked-file" && req\.method === "GET"/, "server should expose a focused untracked-file content endpoint for stale path-only payload fallbacks");
359
359
  assert.match(server, /async function readGitChanges\(cwd\)[\s\S]*?const diffArgs = \["diff", "--no-ext-diff"[\s\S]*?"--unified=0"[\s\S]*?\["diff", "--cached"/, "server should collect compact staged and unstaged git diffs for the changes modal");
360
+ assert.match(server, /\["status", "--porcelain=2", "--branch", "--untracked-files=all"\][\s\S]*?summarizeGitPorcelainStatus\(porcelainStatusText\)/, "server should derive behind/ahead from locale-independent porcelain status so the Pull button activates after fetch");
360
361
  assert.match(server, /async function readGitIncomingChanges\(root, summary\)[\s\S]*?"HEAD\.\.@\{upstream\}"/, "server should collect incoming upstream diffs when remote commits are behind");
361
362
  assert.match(server, /url\.pathname === "\/api\/git-changes" && req\.method === "GET"/, "server should expose GET /api/git-changes for the changes modal");
362
363
  assert.match(server, /url\.pathname === "\/api\/git-changes\/pull" && req\.method === "POST"[\s\S]*?pullGitChanges\(tab\.cwd\)/, "server should expose POST /api/git-changes/pull for the changes modal Pull button");
@@ -432,8 +433,9 @@ assert.match(app, /initializeCustomBackground\(\)\.catch/, "startup should resto
432
433
  assert.match(app, /Restart Web UI to load themes/, "frontend should explain when a stale server cannot serve the themes endpoint");
433
434
  assert.match(app, /themeSelect\.addEventListener\("change"/, "side-panel theme selector should switch themes immediately");
434
435
  assert.match(app, /open \? "Close for network" : "Open to network"/, "network button should toggle from open to close action");
435
- assert.match(app, /remoteAuthToggle: \$\("#remoteAuthToggle"\)/, "Controls should expose the remote PIN auth toggle");
436
- assert.match(app, /api\("\/api\/remote-auth\/settings", \{ method: "POST"/, "remote PIN auth toggle should call the settings endpoint");
436
+ assert.match(app, /remoteAuthToggle: \$\("#remoteAuthToggle"\)/, "Remote WebUI controls should bind the remote PIN auth toggle");
437
+ assert.match(html, /id="networkControlField"[^>]*hidden/, "Remote WebUI browser controls should be hidden until the optional package is loaded and enabled");
438
+ assert.match(app, /remoteWebuiCommand\(enable \? "authOn" : "authOff"/, "remote PIN auth toggle should dispatch through the Remote WebUI package command");
437
439
  assert.match(server, /function webuiSettingsFile\(\)[\s\S]*pi-webui[\s\S]*settings\.json/, "server should persist Web UI settings under a pi-webui settings file");
438
440
  assert.match(server, /let persistedRemoteAuthEnabled = await readPersistedRemoteAuthEnabled\(\)/, "server should load the saved Remote PIN auth preference before startup");
439
441
  assert.match(server, /if \(remoteAuthStartupEnabled\(\)\) enableRemoteAuth\(remoteAuthStartupReason\(\)\)/, "saved Remote PIN auth preference should enable auth on startup");
@@ -441,7 +443,7 @@ assert.match(server, /await saveRemoteAuthPreference\(true\)/, "enabling Remote
441
443
  assert.match(server, /await saveRemoteAuthPreference\(false\)/, "disabling Remote PIN auth should persist the off preference");
442
444
  assert.match(server, /function pinFromHash\(\)[\s\S]*new URLSearchParams\(String\(window\.location\.hash \|\| ""\)\.replace\(\/\^#\/, ""\)\)/, "remote auth page should read QR-provided PINs from the URL fragment");
443
445
  assert.match(server, /window\.history\.replaceState\(null, "", window\.location\.pathname \+ \(window\.location\.search \|\| ""\)\)/, "remote auth page should scrub fragment PINs from the address bar before authenticating");
444
- assert.match(app, /api\("\/api\/network\/close", \{ method: "POST"/, "network close action should call the close endpoint");
446
+ assert.match(app, /remoteWebuiCommand\(open \? "close" : "open"/, "network open\/close button should dispatch through the Remote WebUI package command");
445
447
  assert.match(app, /webuiVersionBadge: \$\("#webuiVersionBadge"\)/, "frontend should bind the Control Deck version badge");
446
448
  assert.match(app, /webuiDevBadge: \$\("#webuiDevBadge"\)/, "frontend should bind the Control Deck dev badge");
447
449
  assert.match(app, /function refreshWebuiVersion\(\)[\s\S]*api\("\/api\/health", \{ scoped: false \}\)[\s\S]*setWebuiVersion\(health\.webuiVersion\)[\s\S]*setWebuiDevServer\(isWebuiDevMetadata\(health\)\)/, "frontend should load Web UI version and dev mode from health metadata");
@@ -456,7 +458,7 @@ assert.match(server, /cwdExplicit: false/, "server should track whether startup
456
458
  assert.match(server, /return options\.cwdExplicit \? \[await createTab\(\)\] : \[\]/, "server should wait for UI cwd selection when no --cwd is supplied");
457
459
  assert.match(server, /async function resolvedPiCliScript\(\)[\s\S]*require\.resolve\.paths\(PI_CODING_AGENT_PACKAGE\)[\s\S]*nodeModulesRoot[\s\S]*dist[\s\S]*cli\.js/, "server should resolve the bundled Pi CLI through Node resolution roots so hoisted global installs can spawn RPC tabs");
458
460
  assert.match(server, /const bundledCli = await resolvedPiCliScript\(\)/, "standalone server should prefer the resolved Pi CLI script before falling back to PATH pi");
459
- assert.match(server, /if \(options\.piBinExplicit\) \{\n\s+const command = await resolvePiCommand\(\["update"\]\)/, "explicit --pi JavaScript launchers should also work for update commands");
461
+ assert.match(server, /if \(options\.piBinExplicit\) \{\n\s+const command = await resolvePiCommand\(updateArgs\)/, "explicit --pi JavaScript launchers should also work for update commands");
460
462
  assert.match(app, /serverActionSelect\.addEventListener\("change", updateServerActionButton\)/, "Server action dropdown should control the guarded run button");
461
463
  assert.match(app, /runServerActionButton\.addEventListener\("click"[\s\S]*runSelectedServerAction/, "Server action run button should execute the selected action");
462
464
  assert.match(app, /api\("\/api\/restart", \{ method: "POST", scoped: false \}\)/, "Restart Server action should call the unscoped restart endpoint");
@@ -466,10 +468,12 @@ assert.match(app, /api\("\/api\/shutdown", \{ method: "POST", scoped: false \}\)
466
468
  assert.match(server, /url\.pathname === "\/api\/restart" && req\.method === "POST"/, "server should expose restart endpoint");
467
469
  assert.match(server, /PI_WEBUI_RESTORE_TABS: JSON\.stringify\(restorableTabs \|\| \[\]\)/, "server restart should preserve restorable tab metadata");
468
470
  assert.match(server, /if \(webuiDevServer\) env\.PI_WEBUI_DEV = "1";/, "server restart should explicitly preserve dev mode");
469
- assert.match(server, /async function resolveUpdateTasks\(\)[\s\S]*currentWebuiPackageUpdateTask\(\)[\s\S]*agentPackageRootUpdateTask\(\)[\s\S]*npmGlobalPackageRootUpdateTask\(\)[\s\S]*bunGlobalPackageRootUpdateTask\(\)/, "server update should include current, Pi-agent, npm-global, and Bun-global Web UI/Pi package roots");
470
- assert.match(server, /function packageInstallSpecs\(packageNames\)[\s\S]*`\$\{packageName\}@latest`/, "server package update tasks should force latest Web UI/Pi package specs instead of staying inside stale semver ranges");
471
- assert.match(app, /Run Pi\/Web UI package updates now\?/, "frontend update confirmation should describe the broader package update set");
472
- assert.match(readme, /detected local\/global Web UI and Pi package-manager updates/, "README should document that update refreshes local and global Web UI\/Pi package roots");
471
+ assert.match(server, /const updateArgs = all \? \["update", "--all"\] : \["update"\]/, "server update should use pi update by default and pi update --all for package-inclusive updates");
472
+ assert.match(server, /async function resolveUpdateTasks\(\{ all = false \} = \{\}\)[\s\S]*await resolvePiUpdateCommand\(\{ all \}\)/, "server update should resolve a single Pi update command with the selected all mode");
473
+ assert.match(app, /const command = all \? "pi update --all" : "pi update"/, "frontend update confirmation should describe self-only and all update commands");
474
+ assert.match(app, /api\(all \? "\/api\/update\?all=1" : "\/api\/update"/, "frontend all update should call the explicit all-mode endpoint");
475
+ assert.match(html, /<option value="update-all">Update Pi \+ Packages &amp; Restart<\/option>/, "side panel should expose pi update --all as a separate server action");
476
+ assert.match(readme, /`pi update` for Pi-only updates[\s\S]*`pi update --all` for Pi plus configured packages/, "README should document self-only and all update modes");
473
477
  assert.match(server, /async function closeNetworkAccess\(\)/, "server should expose a local-only rebind helper for closing network access");
474
478
  assert.match(server, /url\.pathname === "\/api\/network\/close" && req\.method === "POST"/, "server should route network close requests");
475
479
  assert.match(server, /server\.closeAllConnections\?\.\(\)/, "network rebind should force-close long-lived clients so close-to-localhost can complete");
@@ -626,8 +630,9 @@ assert.match(app, /id: "tuiSkillsCommand"[\s\S]*?@firstpick\/pi-extension-setup-
626
630
  assert.match(app, /id: "tuiToolsCommand"[\s\S]*?@firstpick\/pi-extension-tools/, "optional features should include the TUI tools command companion");
627
631
  assert.match(app, /id: "remoteWebui"[\s\S]*?@firstpick\/pi-package-remote-webui/, "optional features should include the Remote WebUI companion");
628
632
  assert.match(app, /function updateOptionalFeatureAvailability\(\)[\s\S]*hasAvailableCommand\("git-staged-msg"\)[\s\S]*hasAvailableCommand\("release-npm"\)[\s\S]*hasAvailableCommand\("release-aur"\)[\s\S]*hasAvailableCommand\("safety-guard"\)[\s\S]*hasLoadedRpcCommand\("skills"\)[\s\S]*hasAvailableCommand\("todo-progress-status"\)[\s\S]*hasLoadedRpcCommand\("tools"\)[\s\S]*hasAvailableCommand\("remote"\)/, "optional feature detection should call RPC-visible commands directly and distinguish native resource selectors from TUI companions");
629
- assert.match(app, /hasRemoteWebuiCommand = isOptionalFeatureEnabled\("remoteWebui"\) && hasAvailableCommand\("remote"\)[\s\S]*optionsRemoteButton\.hidden = !hasRemoteWebuiCommand/, "Options menu should show Open Remote only when /remote is loaded and enabled");
633
+ assert.match(app, /hasRemoteWebuiCommand = isOptionalFeatureEnabled\("remoteWebui"\) && hasAvailableCommand\("remote"\)[\s\S]*optionsRemoteButton\.hidden = !hasRemoteWebuiCommand[\s\S]*networkControlField\.hidden = !hasRemoteWebuiCommand/, "Options menu and browser controls should show Remote WebUI only when /remote is loaded and enabled");
630
634
  assert.match(app, /if \(key === "pi-remote-webui"\) return "remoteWebui"/, "optional feature handling should recognize Remote WebUI widget events without rendering them as overlays");
635
+ assert.match(app, /REMOTE_WEBUI_CONTROLS_PAYLOAD_TYPE = "firstpick\.pi-package-remote-webui\.controls"/, "Remote WebUI package should announce browser controls through a package-owned status payload");
631
636
  assert.match(app, /function combineIdenticalDuplicateCommands\(commands\)[\s\S]*duplicateGroups[\s\S]*duplicateCount: group\.length/, "identical duplicate RPC commands should be combined into one visible command entry");
632
637
  assert.match(app, /if \(kind === "prompt" && attachments\.length === 0\) message = resolveRpcSlashCommandMessage\(message\)/, "manual slash prompts should resolve combined duplicate command aliases before reaching Pi RPC");
633
638
  assert.match(app, /if \(!isOptionalFeatureEnabled\("todoProgressWidget"\)\) return String\(text \|\| ""\)/, "todo progress line stripping should only run when the todo feature is detected and enabled");