@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 +4 -2
- package/WEBUI_TUI_NATIVE_PARITY.json +26 -0
- package/bin/pi-webui.mjs +371 -4
- package/package.json +10 -3
- package/public/app.js +409 -35
- package/public/index.html +26 -6
- package/public/service-worker.js +1 -1
- package/public/styles.css +225 -7
- package/start-webui.sh +9 -48
- package/tests/mobile-static.test.mjs +47 -6
- package/tests/native-parity.test.mjs +14 -0
- package/webui-rpc-helper.mjs +231 -0
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`,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|