@firstpick/pi-package-webui 0.2.1 → 0.2.3

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
@@ -119,7 +119,7 @@ Environment variables:
119
119
  - Multi-tab Pi sessions with isolated processes, working directories, prompt drafts, and activity state.
120
120
  - Streaming chat transcript with Markdown, thinking output, tool/bash cards, queue and compaction events, and abort controls.
121
121
  - Prompt composer with uploads, drag/drop/paste, inline image support, slash-command autocomplete, and `@` file/path references.
122
- - Browser dialogs for common Pi selectors such as `/model`, `/settings`, `/theme`, `/fork`, `/clone`, `/resume`, `/tree`, and `/scoped-models`.
122
+ - Browser dialogs for common Pi selectors such as `/model`, `/settings`, `/theme`, `/fork`, `/clone`, `/resume`, `/tree`, `/scoped-models`, `/tools`, and `/skills`.
123
123
  - Model, thinking, session, workspace, theme, optional-feature, Codex usage, network, event, and notification controls in the side panel.
124
124
  - Per-tab cwd changes, a clickable footer cwd picker, saved path fast picks, and restart-safe restoration of open tabs.
125
125
  - Browser support for Pi extension UI prompts, widgets, status updates, and notifications.
@@ -135,7 +135,9 @@ Optional companions:
135
135
  - `@firstpick/pi-prompts-git-pr` — guided Git commit/push workflow.
136
136
  - `@firstpick/pi-extension-release-npm` — NPM publish menu and release widgets.
137
137
  - `@firstpick/pi-extension-release-aur` — AUR publish menu and release widgets.
138
+ - `@firstpick/pi-extension-setup-skills` — TUI `/skills` setup command alongside WebUI-native skill toggles.
138
139
  - `@firstpick/pi-extension-todo-progress` — todo-progress rendering.
140
+ - `@firstpick/pi-extension-tools` — TUI `/tools` active-tool manager alongside WebUI-native tool toggles.
139
141
  - `@firstpick/pi-extension-git-footer-status` — richer git/footer status.
140
142
  - `@firstpick/pi-extension-stats` — stats commands and status data.
141
143
  - `@firstpick/pi-themes-bundle` — Web UI and Pi theme resources.
@@ -161,7 +163,7 @@ This requires `/git-staged-msg` from `@firstpick/pi-prompts-git-pr`. Review the
161
163
  ## Network safety
162
164
 
163
165
  - Default bind is localhost-only: `127.0.0.1:31415`.
164
- - The side-panel **Open to network** button rebinds the server to `0.0.0.0` and shows LAN URLs when available.
166
+ - 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".
165
167
  - `--host 0.0.0.0` also exposes the Web UI to the local network.
166
168
  - Any connected browser client can control Pi and run Web UI bash actions as the Web UI process user.
167
169
  - Treat Pi Web UI as a local companion, not a hardened multi-user web service.
@@ -56,6 +56,32 @@
56
56
  "currentBehavior": "Informational dialog plus footer scoped picker.",
57
57
  "targetBehavior": "Searchable model table, provider toggles, ordered explicit provider/model IDs, advanced patterns, and safe global/project persistence."
58
58
  },
59
+ {
60
+ "id": "/tools",
61
+ "kind": "slash-command",
62
+ "category": "native-command",
63
+ "title": "Tool enable/disable selector",
64
+ "command": { "name": "tools", "description": "Enable/disable tools for the active tab" },
65
+ "webStatus": "implemented",
66
+ "priority": "P1",
67
+ "sensitive": false,
68
+ "guards": ["confirmation"],
69
+ "currentBehavior": "Browser-native selector uses a hidden Web UI RPC helper extension to read all tools, set active tools, and persist selection on the session branch.",
70
+ "targetBehavior": "Keep browser-native runtime tool toggles in parity with Pi's TUI tools extension while exposing provenance and branch-persistent state."
71
+ },
72
+ {
73
+ "id": "/skills",
74
+ "kind": "slash-command",
75
+ "category": "native-command",
76
+ "title": "Skill enable/disable selector",
77
+ "command": { "name": "skills", "description": "Enable/disable skills for automatic model invocation" },
78
+ "webStatus": "implemented",
79
+ "priority": "P1",
80
+ "sensitive": false,
81
+ "guards": ["confirmation"],
82
+ "currentBehavior": "Browser-native selector uses a hidden Web UI RPC helper extension to filter disabled skills out of the system prompt, block disabled /skill:name invocations, and persist selection on the session branch.",
83
+ "targetBehavior": "Keep browser-native skill toggles runtime-scoped with clear distinction from package-level pi config enable/disable."
84
+ },
59
85
  {
60
86
  "id": "/export",
61
87
  "kind": "slash-command",
package/bin/pi-webui.mjs CHANGED
@@ -8,19 +8,25 @@ import { access, copyFile, mkdir, readFile, readdir, rename, stat, writeFile } f
8
8
  import { homedir, networkInterfaces, tmpdir } from "node:os";
9
9
  import path from "node:path";
10
10
  import { StringDecoder } from "node:string_decoder";
11
- import { fileURLToPath } from "node:url";
12
- import { AuthStorage, SessionManager } from "@earendil-works/pi-coding-agent";
11
+ import { fileURLToPath, pathToFileURL } from "node:url";
12
+ import { AuthStorage, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
13
13
 
14
14
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
15
  const require = createRequire(import.meta.url);
16
16
  const packageRoot = path.resolve(__dirname, "..");
17
17
  const publicDir = path.join(packageRoot, "public");
18
+ const webuiHelperExtensionPath = path.join(packageRoot, "webui-rpc-helper.mjs");
19
+ const agentDir = process.env.PI_CODING_AGENT_DIR || path.join(homedir(), ".pi", "agent");
18
20
  const packageJson = JSON.parse(await readFile(path.join(packageRoot, "package.json"), "utf8"));
19
21
  const nativeParityMatrix = JSON.parse(await readFile(path.join(packageRoot, "WEBUI_TUI_NATIVE_PARITY.json"), "utf8"));
22
+ const webuiDevServer = isTruthyEnv(process.env.PI_WEBUI_DEV) || isSourceCheckout(packageRoot);
20
23
 
21
24
  const DEFAULT_HOST = "127.0.0.1";
22
25
  const DEFAULT_PORT = 31415;
23
26
  const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
27
+ const WEBUI_HELPER_TIMEOUT_MS = 8 * 1000;
28
+ const WEBUI_HELPER_COMMAND = "webui-helper";
29
+ const WEBUI_HELPER_RESPONSE_PREFIX = "__PI_WEBUI_HELPER_RESPONSE__:";
24
30
  const CODEX_USAGE_TIMEOUT_MS = 15 * 1000;
25
31
  const CODEX_TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1000;
26
32
  const OPENAI_CODEX_PROVIDER_ID = "openai-codex";
@@ -100,6 +106,15 @@ const MIME_TYPES = new Map([
100
106
  [".webmanifest", "application/manifest+json; charset=utf-8"],
101
107
  ]);
102
108
 
109
+ function isTruthyEnv(value) {
110
+ return ["1", "true", "yes", "dev"].includes(String(value || "").trim().toLowerCase());
111
+ }
112
+
113
+ function isSourceCheckout(root) {
114
+ const normalized = String(root || "").replace(/\\/g, "/");
115
+ return normalized.includes("/npm-packages/") && !normalized.includes("/node_modules/");
116
+ }
117
+
103
118
  function nativeParitySurfaces(matrix = nativeParityMatrix) {
104
119
  return Array.isArray(matrix?.surfaces) ? matrix.surfaces : [];
105
120
  }
@@ -131,7 +146,9 @@ const OPTIONAL_FEATURE_PACKAGES = new Map([
131
146
  ["gitWorkflow", "@firstpick/pi-prompts-git-pr"],
132
147
  ["releaseNpm", "@firstpick/pi-extension-release-npm"],
133
148
  ["releaseAur", "@firstpick/pi-extension-release-aur"],
149
+ ["tuiSkillsCommand", "@firstpick/pi-extension-setup-skills"],
134
150
  ["todoProgressWidget", "@firstpick/pi-extension-todo-progress"],
151
+ ["tuiToolsCommand", "@firstpick/pi-extension-tools"],
135
152
  ["gitFooterStatus", "@firstpick/pi-extension-git-footer-status"],
136
153
  ["statsCommand", "@firstpick/pi-extension-stats"],
137
154
  ["themeBundle", "@firstpick/pi-themes-bundle"],
@@ -2004,6 +2021,12 @@ if (options.version) {
2004
2021
  process.exit(0);
2005
2022
  }
2006
2023
 
2024
+ const startupDelayMs = Number.parseInt(process.env.PI_WEBUI_START_DELAY_MS || "", 10);
2025
+ delete process.env.PI_WEBUI_START_DELAY_MS;
2026
+ if (Number.isFinite(startupDelayMs) && startupDelayMs > 0) {
2027
+ await delay(Math.min(startupDelayMs, 10_000));
2028
+ }
2029
+
2007
2030
  const restoreTabs = readRestoreTabsFromEnv();
2008
2031
 
2009
2032
  function normalizedRestoreString(value, maxLength) {
@@ -2051,6 +2074,11 @@ function buildPiArgsForTab(tabIndex, title) {
2051
2074
  const args = ["--mode", "rpc"];
2052
2075
  if (options.noSession) args.push("--no-session");
2053
2076
 
2077
+ // Load a browser-safe RPC helper into every Web UI tab. It exposes hidden
2078
+ // extension commands for Web UI-native /tools and /skills selectors without
2079
+ // depending on TUI-only extension UIs.
2080
+ args.push("--extension", webuiHelperExtensionPath);
2081
+
2054
2082
  // Keep tab naming inside Web UI metadata. Some bundled Pi CLI versions do not
2055
2083
  // support --name, and passing Web UI-generated tab titles through to child
2056
2084
  // RPC processes makes every tab after the first exit immediately.
@@ -2418,6 +2446,7 @@ function attachRpcToTab(tab, rpc) {
2418
2446
  tab.rpcUnsubscribe?.();
2419
2447
  tab.rpc = rpc;
2420
2448
  tab.rpcUnsubscribe = rpc.onEvent((event) => {
2449
+ if (resolveWebuiHelperResponse(tab, event) || resolveWebuiHelperRpcResponse(tab, event)) return;
2421
2450
  updateTabActivityFromEvent(tab, event);
2422
2451
  let scopedEvent = { ...event, tabId: tab.id, tabTitle: tab.title, tabActivity: tabActivitySnapshot(tab) };
2423
2452
  if (event?.type === "pi_process_exit" || event?.type === "pi_process_error") clearPendingExtensionUiRequests(tab);
@@ -2454,6 +2483,8 @@ async function createTab({ id: requestedId, index, title, titleSource, conversat
2454
2483
  lastState: null,
2455
2484
  activity: createTabActivity(createdAt),
2456
2485
  pendingExtensionUiRequests: new Map(),
2486
+ webuiHelperRequests: new Map(),
2487
+ webuiHelperResponseIds: new Set(),
2457
2488
  bashQueue: [],
2458
2489
  bashQueueDraining: false,
2459
2490
  rpc: undefined,
@@ -2547,6 +2578,33 @@ function mergeRestorableTabDescriptors(...sources) {
2547
2578
  .slice(0, RESTORE_TAB_LIMIT);
2548
2579
  }
2549
2580
 
2581
+ async function restorableTabsForRestart() {
2582
+ const liveDescriptors = await Promise.all([...tabs.values()].map(async (tab) => {
2583
+ const state = await currentSessionState(tab).catch(() => tab.lastState || null);
2584
+ return restorableTabDescriptor(tab, state);
2585
+ }));
2586
+ return mergeRestorableTabDescriptors(liveDescriptors, closedRestorableTabs);
2587
+ }
2588
+
2589
+ function spawnRestartServer(restorableTabs) {
2590
+ const env = {
2591
+ ...process.env,
2592
+ PI_WEBUI_RESTORE_TABS: JSON.stringify(restorableTabs || []),
2593
+ PI_WEBUI_START_DELAY_MS: "1200",
2594
+ };
2595
+ if (webuiDevServer) env.PI_WEBUI_DEV = "1";
2596
+ else delete env.PI_WEBUI_DEV;
2597
+ const child = spawn(process.execPath, process.argv.slice(1), {
2598
+ cwd: process.cwd(),
2599
+ env,
2600
+ detached: true,
2601
+ stdio: "ignore",
2602
+ windowsHide: true,
2603
+ });
2604
+ child.unref();
2605
+ return child;
2606
+ }
2607
+
2550
2608
  function rememberClosedRestorableTab(tab, state = null) {
2551
2609
  const descriptor = restorableTabDescriptor(tab, state);
2552
2610
  if (!descriptor) return;
@@ -2717,11 +2775,271 @@ async function safeRpcResponse(tab, command, timeoutMs = REQUEST_TIMEOUT_MS) {
2717
2775
  }
2718
2776
  }
2719
2777
 
2778
+ function parseWebuiHelperResponseEvent(event) {
2779
+ if (event?.type !== "extension_ui_request" || event.method !== "notify") return undefined;
2780
+ const message = String(event.message || "");
2781
+ if (!message.startsWith(WEBUI_HELPER_RESPONSE_PREFIX)) return undefined;
2782
+ try {
2783
+ return JSON.parse(message.slice(WEBUI_HELPER_RESPONSE_PREFIX.length));
2784
+ } catch (error) {
2785
+ return { ok: false, error: `Invalid Web UI helper response: ${sanitizeError(error)}` };
2786
+ }
2787
+ }
2788
+
2789
+ function resolveWebuiHelperResponse(tab, event) {
2790
+ const payload = parseWebuiHelperResponseEvent(event);
2791
+ if (!payload) return false;
2792
+ const requestId = String(payload.requestId || "");
2793
+ const pending = tab?.webuiHelperRequests?.get(requestId);
2794
+ if (pending) {
2795
+ tab.webuiHelperRequests.delete(requestId);
2796
+ clearTimeout(pending.timeout);
2797
+ if (payload.ok === false) pending.reject(makeHttpError(400, payload.error || "Web UI helper command failed"));
2798
+ else pending.resolve(payload.data || {});
2799
+ }
2800
+ return true;
2801
+ }
2802
+
2803
+ function resolveWebuiHelperRpcResponse(tab, event) {
2804
+ if (event?.type !== "response" || event.command !== "prompt" || !event.id) return false;
2805
+ return tab?.webuiHelperResponseIds?.delete(String(event.id)) === true;
2806
+ }
2807
+
2808
+ function webuiHelperRequestMap(tab) {
2809
+ if (!tab.webuiHelperRequests) tab.webuiHelperRequests = new Map();
2810
+ return tab.webuiHelperRequests;
2811
+ }
2812
+
2813
+ async function sendWebuiHelperCommand(tab, action, payload = {}, timeoutMs = WEBUI_HELPER_TIMEOUT_MS) {
2814
+ const requestId = randomUUID();
2815
+ const pending = new Promise((resolve, reject) => {
2816
+ const timeout = setTimeout(() => {
2817
+ webuiHelperRequestMap(tab).delete(requestId);
2818
+ tab.webuiHelperResponseIds?.delete(requestId);
2819
+ reject(makeHttpError(504, `Timed out waiting for Web UI helper action: ${action}. Try /reload in this tab, then retry.`));
2820
+ }, timeoutMs);
2821
+ webuiHelperRequestMap(tab).set(requestId, { resolve, reject, timeout });
2822
+ });
2823
+ pending.catch(() => {});
2824
+
2825
+ try {
2826
+ tab.webuiHelperResponseIds?.add(requestId);
2827
+ const response = await tab.rpc.send({
2828
+ id: requestId,
2829
+ type: "prompt",
2830
+ message: `/${WEBUI_HELPER_COMMAND} ${JSON.stringify({ requestId, action, payload })}`,
2831
+ }, timeoutMs);
2832
+ if (response.success === false) throw makeHttpError(400, response.error || `Web UI helper action failed: ${action}`);
2833
+ return await pending;
2834
+ } catch (error) {
2835
+ tab.webuiHelperResponseIds?.delete(requestId);
2836
+ const request = webuiHelperRequestMap(tab).get(requestId);
2837
+ if (request) {
2838
+ clearTimeout(request.timeout);
2839
+ webuiHelperRequestMap(tab).delete(requestId);
2840
+ }
2841
+ throw error;
2842
+ }
2843
+ }
2844
+
2845
+ async function getToolConfigData(tab) {
2846
+ return sendWebuiHelperCommand(tab, "tools-state");
2847
+ }
2848
+
2849
+ let packageManagerModulePromise;
2850
+ async function loadPackageManagerModule() {
2851
+ if (!packageManagerModulePromise) {
2852
+ const packageMain = fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"));
2853
+ const codingAgentRoot = path.dirname(path.dirname(packageMain));
2854
+ packageManagerModulePromise = import(pathToFileURL(path.join(codingAgentRoot, "dist", "core", "package-manager.js")).href);
2855
+ }
2856
+ return packageManagerModulePromise;
2857
+ }
2858
+
2859
+ function parseSkillFrontmatter(text, filePath) {
2860
+ const frontmatter = String(text || "").match(/^---\s*\n([\s\S]*?)\n---/);
2861
+ const fields = {};
2862
+ if (frontmatter) {
2863
+ for (const line of frontmatter[1].split(/\r?\n/)) {
2864
+ const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
2865
+ if (match) fields[match[1]] = match[2].replace(/^['"]|['"]$/g, "").trim();
2866
+ }
2867
+ }
2868
+ const parent = path.basename(path.dirname(filePath));
2869
+ const base = path.basename(filePath, path.extname(filePath));
2870
+ return {
2871
+ name: fields.name || (path.basename(filePath) === "SKILL.md" ? parent : base),
2872
+ description: fields.description || "",
2873
+ };
2874
+ }
2875
+
2876
+ function sourceInfoFromResolvedResource(resource) {
2877
+ const metadata = resource?.metadata || {};
2878
+ return {
2879
+ path: resource?.path,
2880
+ source: metadata.source,
2881
+ scope: metadata.scope,
2882
+ origin: metadata.origin,
2883
+ baseDir: metadata.baseDir,
2884
+ };
2885
+ }
2886
+
2887
+ async function resolveSkillResources(tab) {
2888
+ const { DefaultPackageManager } = await loadPackageManagerModule();
2889
+ const settingsManager = SettingsManager.create(tab?.cwd || options.cwd, agentDir);
2890
+ const packageManager = new DefaultPackageManager({ cwd: tab?.cwd || options.cwd, agentDir, settingsManager });
2891
+ const resolved = await packageManager.resolve();
2892
+ const skills = [];
2893
+ for (const resource of resolved.skills || []) {
2894
+ try {
2895
+ const metadata = parseSkillFrontmatter(await readFile(resource.path, "utf8"), resource.path);
2896
+ skills.push({
2897
+ ...metadata,
2898
+ filePath: resource.path,
2899
+ enabled: resource.enabled === true,
2900
+ configEnabled: resource.enabled === true,
2901
+ configManaged: true,
2902
+ sourceInfo: sourceInfoFromResolvedResource(resource),
2903
+ });
2904
+ } catch {
2905
+ // Ignore unreadable skill candidates; Pi will also skip invalid resources.
2906
+ }
2907
+ }
2908
+ return { skills, settingsManager };
2909
+ }
2910
+
2911
+ function skillResourceKey(skill) {
2912
+ return skill.filePath || skill.name;
2913
+ }
2914
+
2915
+ function mergeRuntimeAndResolvedSkills(runtimeSkills, resolvedSkills) {
2916
+ const byName = new Map();
2917
+ for (const skill of resolvedSkills) byName.set(skill.name, { ...skill });
2918
+ for (const skill of runtimeSkills || []) {
2919
+ const existing = byName.get(skill.name);
2920
+ byName.set(skill.name, existing ? { ...existing, ...skill, configManaged: existing.configManaged, configEnabled: existing.configEnabled, filePath: existing.filePath || skill.filePath, sourceInfo: existing.sourceInfo || skill.sourceInfo } : { ...skill, configManaged: false, configEnabled: true });
2921
+ }
2922
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
2923
+ }
2924
+
2925
+ async function getMergedSkillConfigData(tab) {
2926
+ const [runtime, resolved] = await Promise.all([
2927
+ getSkillConfigDataFromRuntime(tab).catch(() => ({ skills: [] })),
2928
+ resolveSkillResources(tab).catch((error) => {
2929
+ console.warn(`failed to resolve configured skills: ${sanitizeError(error)}`);
2930
+ return { skills: [] };
2931
+ }),
2932
+ ]);
2933
+ return { skills: mergeRuntimeAndResolvedSkills(runtime.skills || [], resolved.skills || []) };
2934
+ }
2935
+
2936
+ function getResourcePatternForSkill(tab, skill) {
2937
+ const info = skill.sourceInfo || {};
2938
+ const baseDir = info.baseDir || (info.scope === "project" ? path.join(tab?.cwd || options.cwd, ".pi") : agentDir);
2939
+ return path.relative(baseDir, skill.filePath);
2940
+ }
2941
+
2942
+ async function setToolConfigData(tab, body) {
2943
+ return sendWebuiHelperCommand(tab, "tools-set", {
2944
+ enabledTools: Array.isArray(body.enabledTools) ? body.enabledTools : undefined,
2945
+ disabledTools: Array.isArray(body.disabledTools) ? body.disabledTools : undefined,
2946
+ });
2947
+ }
2948
+
2949
+ async function getSkillConfigDataFromRuntime(tab) {
2950
+ return sendWebuiHelperCommand(tab, "skills-state");
2951
+ }
2952
+
2953
+ function desiredSkillEnabledFromBody(skillName, body) {
2954
+ if (Array.isArray(body.enabledSkills)) return body.enabledSkills.map(String).includes(skillName);
2955
+ if (Array.isArray(body.disabledSkills)) return !body.disabledSkills.map(String).includes(skillName);
2956
+ throw makeHttpError(400, "Skill update requires enabledSkills or disabledSkills");
2957
+ }
2958
+
2959
+ function updatePatternListForResource(current, pattern, enabled) {
2960
+ const updated = (current || []).filter((item) => {
2961
+ const text = String(item || "");
2962
+ const stripped = text.startsWith("!") || text.startsWith("+") || text.startsWith("-") ? text.slice(1) : text;
2963
+ return stripped !== pattern;
2964
+ });
2965
+ updated.push(`${enabled ? "+" : "-"}${pattern}`);
2966
+ return updated;
2967
+ }
2968
+
2969
+ function setSkillPathsForScope(settingsManager, scope, updated) {
2970
+ if (scope === "project") settingsManager.setProjectSkillPaths(updated);
2971
+ else settingsManager.setSkillPaths(updated);
2972
+ }
2973
+
2974
+ function toggleConfiguredSkill(tab, settingsManager, skill, enabled) {
2975
+ const info = skill.sourceInfo || {};
2976
+ const scope = info.scope === "project" ? "project" : "user";
2977
+ if (info.origin === "package") {
2978
+ const settings = scope === "project" ? settingsManager.getProjectSettings() : settingsManager.getGlobalSettings();
2979
+ const packages = [...(settings.packages || [])];
2980
+ const packageIndex = packages.findIndex((item) => (typeof item === "string" ? item : item?.source) === info.source);
2981
+ if (packageIndex < 0) return false;
2982
+ let packageEntry = packages[packageIndex];
2983
+ if (typeof packageEntry === "string") {
2984
+ packageEntry = { source: packageEntry };
2985
+ packages[packageIndex] = packageEntry;
2986
+ }
2987
+ const pattern = path.relative(info.baseDir || path.dirname(skill.filePath), skill.filePath);
2988
+ packageEntry.skills = updatePatternListForResource(packageEntry.skills || [], pattern, enabled);
2989
+ if (scope === "project") settingsManager.setProjectPackages(packages);
2990
+ else settingsManager.setPackages(packages);
2991
+ return true;
2992
+ }
2993
+
2994
+ const settings = scope === "project" ? settingsManager.getProjectSettings() : settingsManager.getGlobalSettings();
2995
+ const pattern = getResourcePatternForSkill(tab, skill);
2996
+ setSkillPathsForScope(settingsManager, scope, updatePatternListForResource(settings.skills || [], pattern, enabled));
2997
+ return true;
2998
+ }
2999
+
3000
+ async function setSkillConfigData(tab, body) {
3001
+ const { skills, settingsManager } = await resolveSkillResources(tab);
3002
+ let configChanged = false;
3003
+ for (const skill of skills) {
3004
+ const desiredEnabled = desiredSkillEnabledFromBody(skill.name, body);
3005
+ if (skill.configEnabled !== desiredEnabled && toggleConfiguredSkill(tab, settingsManager, skill, desiredEnabled)) configChanged = true;
3006
+ }
3007
+
3008
+ const runtimeOnly = skills.length === 0;
3009
+ if (runtimeOnly) {
3010
+ await sendWebuiHelperCommand(tab, "skills-set", {
3011
+ enabledSkills: Array.isArray(body.enabledSkills) ? body.enabledSkills : undefined,
3012
+ disabledSkills: Array.isArray(body.disabledSkills) ? body.disabledSkills : undefined,
3013
+ });
3014
+ }
3015
+
3016
+ const activeTab = configChanged ? await restartTabRpc(tab, "skills-config") : tab;
3017
+ return getMergedSkillConfigData(activeTab);
3018
+ }
3019
+
3020
+ async function annotateSkillCommandState(tab, commands) {
3021
+ let disabledSkills = new Set();
3022
+ try {
3023
+ const state = await getMergedSkillConfigData(tab);
3024
+ disabledSkills = new Set((state.skills || []).filter((skill) => skill.enabled === false).map((skill) => skill.name));
3025
+ } catch {
3026
+ // Commands should remain available even if an older tab has not loaded the helper yet.
3027
+ }
3028
+
3029
+ return commands
3030
+ .filter((command) => command?.name !== WEBUI_HELPER_COMMAND)
3031
+ .map((command) => {
3032
+ const skillName = command?.source === "skill" && String(command.name || "").startsWith("skill:") ? String(command.name).slice("skill:".length) : "";
3033
+ return skillName ? { ...command, enabled: !disabledSkills.has(skillName) } : command;
3034
+ });
3035
+ }
3036
+
2720
3037
  async function getCommandData(tab) {
2721
3038
  try {
2722
3039
  const response = await tab.rpc.send({ type: "get_commands" });
2723
3040
  if (response.success === false) throw makeHttpError(400, response.error || "failed to load commands");
2724
- return { commands: [...NATIVE_SLASH_COMMANDS, ...(response.data?.commands || [])], rpcRunning: true };
3041
+ const rpcCommands = await annotateSkillCommandState(tab, response.data?.commands || []);
3042
+ return { commands: [...NATIVE_SLASH_COMMANDS, ...rpcCommands], rpcRunning: true };
2725
3043
  } catch (error) {
2726
3044
  const message = sanitizeError(error);
2727
3045
  if (!/Pi RPC process is not running/i.test(message)) throw error;
@@ -3461,6 +3779,8 @@ async function webuiStatus({ detailed = false, eventLimit = 40 } = {}) {
3461
3779
  const data = {
3462
3780
  online: true,
3463
3781
  webuiVersion: packageJson.version,
3782
+ webuiDev: webuiDevServer,
3783
+ webuiMode: webuiDevServer ? "dev" : "production",
3464
3784
  webuiPid: process.pid,
3465
3785
  startedAt: serverStartedAt,
3466
3786
  cwd: options.cwd,
@@ -3537,6 +3857,8 @@ const server = createServer(async (req, res) => {
3537
3857
  sendSse(res, {
3538
3858
  type: "webui_connected",
3539
3859
  version: packageJson.version,
3860
+ webuiDev: webuiDevServer,
3861
+ webuiMode: webuiDevServer ? "dev" : "production",
3540
3862
  tabId: tab.id,
3541
3863
  tabTitle: tab.title,
3542
3864
  pid: tab.rpc.child?.pid,
@@ -3559,6 +3881,8 @@ const server = createServer(async (req, res) => {
3559
3881
  sendJson(res, 200, {
3560
3882
  ok: true,
3561
3883
  webuiVersion: status.webuiVersion,
3884
+ webuiDev: status.webuiDev,
3885
+ webuiMode: status.webuiMode,
3562
3886
  webuiPid: status.webuiPid,
3563
3887
  piPid: status.piPid,
3564
3888
  piRunning: status.piRunning,
@@ -3629,6 +3953,15 @@ const server = createServer(async (req, res) => {
3629
3953
  return;
3630
3954
  }
3631
3955
 
3956
+ if (url.pathname === "/api/restart" && req.method === "POST") {
3957
+ if (!isLocalAddress(req.socket.remoteAddress)) throw makeHttpError(403, "Restart is only allowed from localhost");
3958
+ const restorableTabs = await restorableTabsForRestart();
3959
+ const child = spawnRestartServer(restorableTabs);
3960
+ sendJson(res, 200, { ok: true, message: "Pi Web UI restarting", webuiPid: process.pid, nextWebuiPid: child.pid, restorableTabCount: restorableTabs.length });
3961
+ setTimeout(() => shutdown("api restart"), 20).unref();
3962
+ return;
3963
+ }
3964
+
3632
3965
  if (url.pathname === "/api/shutdown" && req.method === "POST") {
3633
3966
  if (!isLocalAddress(req.socket.remoteAddress)) throw makeHttpError(403, "Shutdown is only allowed from localhost");
3634
3967
  sendJson(res, 200, { ok: true, message: "Pi Web UI shutting down", webuiPid: process.pid });
@@ -3750,6 +4083,32 @@ const server = createServer(async (req, res) => {
3750
4083
  return;
3751
4084
  }
3752
4085
 
4086
+ if (url.pathname === "/api/tools" && req.method === "GET") {
4087
+ const tab = getRequestedTab(req, url);
4088
+ sendJson(res, 200, { ok: true, data: await getToolConfigData(tab) });
4089
+ return;
4090
+ }
4091
+
4092
+ if (url.pathname === "/api/tools" && req.method === "POST") {
4093
+ const body = await readJsonBody(req);
4094
+ const tab = getRequestedTab(req, url, body);
4095
+ sendJson(res, 200, { ok: true, data: await setToolConfigData(tab, body) });
4096
+ return;
4097
+ }
4098
+
4099
+ if (url.pathname === "/api/skills" && req.method === "GET") {
4100
+ const tab = getRequestedTab(req, url);
4101
+ sendJson(res, 200, { ok: true, data: await getMergedSkillConfigData(tab) });
4102
+ return;
4103
+ }
4104
+
4105
+ if (url.pathname === "/api/skills" && req.method === "POST") {
4106
+ const body = await readJsonBody(req);
4107
+ const tab = getRequestedTab(req, url, body);
4108
+ sendJson(res, 200, { ok: true, data: await setSkillConfigData(tab, body) });
4109
+ return;
4110
+ }
4111
+
3753
4112
  if (url.pathname === "/api/commands" && req.method === "GET") {
3754
4113
  const tab = getRequestedTab(req, url);
3755
4114
  sendJson(res, 200, { type: "response", command: "get_commands", success: true, data: await getCommandData(tab) });
@@ -3879,7 +4238,15 @@ server.listen(options.port, currentHost, () => {
3879
4238
 
3880
4239
  function shutdown(signal) {
3881
4240
  console.log(`\n${signal}: shutting down Pi Web UI...`);
3882
- server.close(() => process.exit(0));
4241
+ const forceCloseTimer = setTimeout(() => {
4242
+ server.closeAllConnections?.();
4243
+ }, NETWORK_REBIND_FORCE_CLOSE_MS);
4244
+ forceCloseTimer.unref?.();
4245
+ server.close(() => {
4246
+ clearTimeout(forceCloseTimer);
4247
+ process.exit(0);
4248
+ });
4249
+ server.closeIdleConnections?.();
3883
4250
  for (const tab of tabs.values()) tab.rpc.stop();
3884
4251
  setTimeout(() => process.exit(0), 4000).unref();
3885
4252
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstpick/pi-package-webui",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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
  "type": "module",
@@ -20,13 +20,17 @@
20
20
  "../pi-extension-git-footer-status/index.ts",
21
21
  "../pi-extension-release-aur/index.ts",
22
22
  "../pi-extension-release-npm/index.ts",
23
+ "../pi-extension-setup-skills/index.ts",
23
24
  "../pi-extension-stats/index.ts",
24
25
  "../pi-extension-todo-progress/index.ts",
26
+ "../pi-extension-tools/index.ts",
25
27
  "node_modules/@firstpick/pi-extension-git-footer-status/index.ts",
26
28
  "node_modules/@firstpick/pi-extension-release-aur/index.ts",
27
29
  "node_modules/@firstpick/pi-extension-release-npm/index.ts",
30
+ "node_modules/@firstpick/pi-extension-setup-skills/index.ts",
28
31
  "node_modules/@firstpick/pi-extension-stats/index.ts",
29
- "node_modules/@firstpick/pi-extension-todo-progress/index.ts"
32
+ "node_modules/@firstpick/pi-extension-todo-progress/index.ts",
33
+ "node_modules/@firstpick/pi-extension-tools/index.ts"
30
34
  ],
31
35
  "skills": [
32
36
  "../pi-extension-release-aur/skills",
@@ -45,7 +49,7 @@
45
49
  "pi-webui": "./bin/pi-webui.mjs"
46
50
  },
47
51
  "scripts": {
48
- "check": "node --check public/app.js && node --check bin/pi-webui.mjs && node tests/mobile-static.test.mjs && node tests/native-parity.test.mjs",
52
+ "check": "node --check public/app.js && node --check bin/pi-webui.mjs && node --check webui-rpc-helper.mjs && node tests/mobile-static.test.mjs && node tests/native-parity.test.mjs",
49
53
  "test": "node tests/mobile-static.test.mjs && node tests/native-parity.test.mjs"
50
54
  },
51
55
  "dependencies": {
@@ -55,13 +59,16 @@
55
59
  "@firstpick/pi-extension-git-footer-status": "^0.2.1",
56
60
  "@firstpick/pi-extension-release-aur": "^0.1.3",
57
61
  "@firstpick/pi-extension-release-npm": "^0.3.3",
62
+ "@firstpick/pi-extension-setup-skills": "^0.1.5",
58
63
  "@firstpick/pi-extension-stats": "^0.2.0",
59
64
  "@firstpick/pi-extension-todo-progress": "^0.1.7",
65
+ "@firstpick/pi-extension-tools": "^0.1.4",
60
66
  "@firstpick/pi-prompts-git-pr": "^0.1.0",
61
67
  "@firstpick/pi-themes-bundle": "^0.1.1"
62
68
  },
63
69
  "files": [
64
70
  "index.ts",
71
+ "webui-rpc-helper.mjs",
65
72
  "bin",
66
73
  "public",
67
74
  "images",