pi-web-ui 0.16.2 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -832,6 +832,9 @@ export class ClientSession {
832
832
  // left panel shows every background chat (a fresh socket never got the
833
833
  // newChat/switch pushes).
834
834
  this.emitConversations();
835
+ // Reconnect: same for the slash-command catalog (the picker needs it even
836
+ // before the client asks).
837
+ void this.pushSlashCommands();
835
838
  }
836
839
  detachSink(send) {
837
840
  this.sinks.delete(send);
@@ -1652,11 +1655,226 @@ export class ClientSession {
1652
1655
  }, SNAPSHOT_INTERVAL_MS);
1653
1656
  }
1654
1657
  // ---------------------------------------------------------------------------
1658
+ // Slash commands
1659
+ // ---------------------------------------------------------------------------
1660
+ /**
1661
+ * Slash commands implemented natively by the web server (the pi CLI's built-in
1662
+ * interactive commands like /model and /new are NOT handled by the SDK's
1663
+ * prompt() — without this they'd be sent to the model as plain text). Keep in
1664
+ * sync with execNativeCommand(). /help and /copy are client-side UI actions
1665
+ * (they never reach the server) but stay listed so the picker shows them.
1666
+ */
1667
+ static NATIVE_COMMANDS = [
1668
+ { name: "new", description: "新建对话" },
1669
+ { name: "model", description: "切换模型", argumentHint: "[名称]" },
1670
+ { name: "compact", description: "压缩上下文", argumentHint: "[说明]" },
1671
+ { name: "cwd", description: "切换工作目录", argumentHint: "<路径>" },
1672
+ {
1673
+ name: "thinking",
1674
+ description: "设置思考强度",
1675
+ argumentHint: "<off|low|medium|high>",
1676
+ },
1677
+ { name: "resume", description: "刷新会话列表" },
1678
+ { name: "help", description: "显示全部命令" },
1679
+ { name: "copy", description: "复制上一条助手回复" },
1680
+ ];
1681
+ /** Parse a prompt into "/command args" — returns null when it isn't one. */
1682
+ parseSlash(text) {
1683
+ const trimmed = text.trim();
1684
+ if (!trimmed.startsWith("/"))
1685
+ return null;
1686
+ const m = trimmed.match(/^\/([^\s]+)\s*([\s\S]*)$/);
1687
+ if (!m || !m[1])
1688
+ return null;
1689
+ return { name: m[1], args: m[2].trim() };
1690
+ }
1691
+ /** Run a native slash command (see NATIVE_COMMANDS). Returns false when the
1692
+ * name is not a native command (the prompt falls through to the SDK). */
1693
+ async execNativeCommand(name, args) {
1694
+ switch (name) {
1695
+ case "new":
1696
+ await this.newChat();
1697
+ return true;
1698
+ case "model": {
1699
+ if (!args) {
1700
+ const current = this.session.model;
1701
+ this.emit({
1702
+ type: "notice",
1703
+ level: "info",
1704
+ text: current
1705
+ ? `当前模型:${current.name}(${current.provider}/${current.id})。用法:/model <名称>`
1706
+ : `用法:/model <名称>`,
1707
+ });
1708
+ return true;
1709
+ }
1710
+ const query = args.toLowerCase();
1711
+ const available = await this.session.modelRuntime.getAvailable();
1712
+ // Prefer an exact "provider/id" match, else id/name substring.
1713
+ const exact = available.find((m) => m.provider + "/" + m.id === args.trim());
1714
+ const matches = exact
1715
+ ? [exact]
1716
+ : available.filter((m) => m.id.toLowerCase().includes(query) ||
1717
+ m.name.toLowerCase().includes(query) ||
1718
+ m.provider.toLowerCase().includes(query));
1719
+ if (matches.length === 0) {
1720
+ this.emit({
1721
+ type: "notice",
1722
+ level: "error",
1723
+ text: `没有匹配到模型:${args}(可用模型见顶栏模型列表)`,
1724
+ });
1725
+ return true;
1726
+ }
1727
+ const pick = matches[0];
1728
+ if (matches.length > 1) {
1729
+ this.emit({
1730
+ type: "notice",
1731
+ level: "warning",
1732
+ text: `找到 ${matches.length} 个匹配模型,已选用:${pick.name}(精确匹配请用 provider/id)`,
1733
+ });
1734
+ }
1735
+ await this.setModel(`${pick.provider}/${pick.id}`);
1736
+ return true;
1737
+ }
1738
+ case "compact":
1739
+ try {
1740
+ await this.session.compact(args || undefined);
1741
+ }
1742
+ catch (err) {
1743
+ this.emit({
1744
+ type: "notice",
1745
+ level: "error",
1746
+ text: `压缩上下文失败:${err.message}`,
1747
+ });
1748
+ }
1749
+ return true;
1750
+ case "cwd":
1751
+ if (!args) {
1752
+ this.emit({
1753
+ type: "notice",
1754
+ level: "info",
1755
+ text: `当前工作目录:${this.cwd}。用法:/cwd <路径>`,
1756
+ });
1757
+ }
1758
+ else {
1759
+ await this.setCwd(args);
1760
+ }
1761
+ return true;
1762
+ case "thinking": {
1763
+ const ALIAS = {
1764
+ off: "off",
1765
+ minimal: "minimal",
1766
+ low: "low",
1767
+ medium: "medium",
1768
+ high: "high",
1769
+ xhigh: "xhigh",
1770
+ max: "max",
1771
+ 关闭: "off",
1772
+ 极简: "minimal",
1773
+ 低: "low",
1774
+ 中: "medium",
1775
+ 高: "high",
1776
+ 极高: "xhigh",
1777
+ 最大: "max",
1778
+ };
1779
+ const level = ALIAS[args.trim().toLowerCase()];
1780
+ if (!level) {
1781
+ this.emit({
1782
+ type: "notice",
1783
+ level: "error",
1784
+ text: `无效的思考强度:${args || "(空)"}。可用:off / minimal / low / medium / high / xhigh / max`,
1785
+ });
1786
+ return true;
1787
+ }
1788
+ this.setThinking(level);
1789
+ return true;
1790
+ }
1791
+ case "resume":
1792
+ await this.refreshSessions();
1793
+ this.emit({
1794
+ type: "notice",
1795
+ level: "info",
1796
+ text: "会话列表已刷新,请在左侧「历史对话」中选择",
1797
+ });
1798
+ return true;
1799
+ case "help":
1800
+ case "copy":
1801
+ // Client-side UI actions — the client handles them before sending;
1802
+ // swallow here so the SDK never sees them as plain prompt text.
1803
+ return true;
1804
+ default:
1805
+ return false;
1806
+ }
1807
+ }
1808
+ /**
1809
+ * Catalog of slash commands for the chat input: web-native builtins first,
1810
+ * then the SDK's invokable commands for the ACTIVE conversation (extension
1811
+ * commands, prompt templates, skills) — the same set the SDK expands when a
1812
+ * prompt text starts with "/" (see AgentSession.prompt).
1813
+ */
1814
+ async pushSlashCommands() {
1815
+ const commands = [];
1816
+ const seen = new Set();
1817
+ for (const c of ClientSession.NATIVE_COMMANDS) {
1818
+ commands.push({ ...c, source: "builtin" });
1819
+ seen.add(c.name);
1820
+ }
1821
+ try {
1822
+ const s = this.session;
1823
+ // Extension commands — the SDK already suffixes collisions with builtin
1824
+ // names ("new:2"), and those still reach the SDK since execNativeCommand
1825
+ // only intercepts the exact native names.
1826
+ for (const cmd of s.extensionRunner.getRegisteredCommands()) {
1827
+ if (seen.has(cmd.invocationName))
1828
+ continue;
1829
+ commands.push({
1830
+ name: cmd.invocationName,
1831
+ description: cmd.description,
1832
+ source: "extension",
1833
+ });
1834
+ seen.add(cmd.invocationName);
1835
+ }
1836
+ // Prompt templates: /templatename args
1837
+ for (const t of s.promptTemplates) {
1838
+ if (seen.has(t.name))
1839
+ continue;
1840
+ commands.push({
1841
+ name: t.name,
1842
+ description: t.description,
1843
+ source: "prompt",
1844
+ });
1845
+ seen.add(t.name);
1846
+ }
1847
+ // Skills: /skill:name args
1848
+ for (const skill of s.resourceLoader.getSkills().skills) {
1849
+ const name = `skill:${skill.name}`;
1850
+ if (seen.has(name))
1851
+ continue;
1852
+ commands.push({
1853
+ name,
1854
+ description: skill.description,
1855
+ source: "skill",
1856
+ });
1857
+ }
1858
+ }
1859
+ catch {
1860
+ // Session not ready yet — native-only catalog still serves the picker.
1861
+ }
1862
+ this.emit({ type: "slash_commands", commands });
1863
+ }
1864
+ // ---------------------------------------------------------------------------
1655
1865
  // Commands
1656
1866
  // ---------------------------------------------------------------------------
1657
1867
  async prompt(text, attachments) {
1658
1868
  try {
1659
1869
  const s = this.session;
1870
+ // Native slash commands (see NATIVE_COMMANDS) are executed here and
1871
+ // never reach the SDK. Extension / skill / template commands fall
1872
+ // through — AgentSession.prompt() handles those itself.
1873
+ const slash = this.parseSlash(text);
1874
+ if (slash && (await this.execNativeCommand(slash.name, slash.args))) {
1875
+ this.flushSnapshot();
1876
+ return;
1877
+ }
1660
1878
  // Attach files as independent nextTurn context messages (asides) so the
1661
1879
  // user message stays clean; they render as separate attachment cards.
1662
1880
  const asides = await this.buildAttachmentMessages(attachments);
@@ -2766,6 +2984,8 @@ export class ClientSession {
2766
2984
  void this.pushProjects();
2767
2985
  this.webUi.refresh();
2768
2986
  this.emitConversations();
2987
+ // Skills / prompt templates are project-bound — refresh the catalog.
2988
+ void this.pushSlashCommands();
2769
2989
  this.emit({
2770
2990
  type: "notice",
2771
2991
  level: "info",
@@ -224,6 +224,9 @@ wss.on("connection", (ws) => {
224
224
  case "get_state":
225
225
  cs.flushSnapshot();
226
226
  break;
227
+ case "get_commands":
228
+ void cs.pushSlashCommands();
229
+ break;
227
230
  case "list_sessions":
228
231
  void cs.refreshSessions();
229
232
  break;
@@ -1,184 +1,184 @@
1
- /**
2
- * pi-web-ui 的 pi 扩展 —— 提供命令行集成。
3
- *
4
- * 能力:
5
- * /webui 启动本机 pi-web-ui 服务器,打开浏览器访问
6
- * /webui --port 9000 指定端口启动
7
- * /webui --no-browser 启动但不开浏览器
8
- * /webui stop 停止已启动的服务器
9
- * /webui status 查看运行状态 / URL
10
- *
11
- * 实现说明:
12
- * - 不依赖全局 bin(pi install 后 pi-web-ui 命令不一定在 PATH),直接用
13
- * node 调包内 dist/server/index.js,通过环境变量 PORT / PI_WEB_CWD /
14
- * PI_WEB_DATA_DIR 控制。
15
- * - 工作目录默认用当前 pi 会话的 ctx.cwd;可用 --cwd / path 覆盖。
16
- * - 服务器作为子进程后台运行,/webui 不阻塞 pi。
17
- * - 每个 pi 会话管理一个子进程;session_shutdown 时清理,避免孤儿进程。
18
- */
19
-
20
- import { spawn } from "node:child_process";
21
- import { existsSync } from "node:fs";
22
- import { dirname, join, resolve } from "node:path";
23
- import { fileURLToPath } from "node:url";
24
- import net from "node:net";
25
- import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
26
-
27
- // 本文件位于 <pkg>/extensions/webui.ts → 包根在上一级
28
- const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
29
- const SERVER_ENTRY = join(PKG_ROOT, "dist", "server", "index.js");
30
- const NODE = process.execPath;
31
-
32
- /** 每个会话的服务器子进程 + 元数据 */
33
- interface RunningServer {
34
- proc: ReturnType<typeof spawn>;
35
- port: number;
36
- cwd: string;
37
- url: string;
38
- }
39
-
40
- // 会话 → 运行实例(模块级 Map;每会话一个会话对象,无需清理全局)
41
- const running = new Map<string, RunningServer>();
42
-
43
- /** 找一个空闲端口 */
44
- function findFreePort(from = 8787): Promise<number> {
45
- return new Promise((resolve_, reject) => {
46
- const srv = net.createServer();
47
- srv.listen(from, () => {
48
- const port = (srv.address() as net.AddressInfo).port;
49
- srv.close(() => resolve_(port));
50
- });
51
- srv.on("error", () => {
52
- // 端口被占则顺延
53
- findFreePort(from + 1).then(resolve_, reject);
54
- });
55
- });
56
- }
57
-
58
- /** 解析 --key value / --flag 参数 */
59
- function parseArgs(args: string): { port?: number; cwd?: string; noBrowser: boolean } {
60
- const out: { port?: number; cwd?: string; noBrowser: boolean } = { noBrowser: false };
61
- const toks = args.split(/\s+/).filter(Boolean);
62
- for (let i = 0; i < toks.length; i++) {
63
- const t = toks[i];
64
- if ((t === "--port" || t === "-p") && toks[i + 1]) {
65
- const n = Number(toks[++i]);
66
- if (Number.isInteger(n) && n > 0 && n < 65536) out.port = n;
67
- } else if ((t === "--cwd") && toks[i + 1]) {
68
- out.cwd = resolve(toks[++i]);
69
- } else if (t === "--no-browser") {
70
- out.noBrowser = true;
71
- }
72
- }
73
- return out;
74
- }
75
-
76
- /** 打开浏览器 */
77
- async function openBrowser(url: string): Promise<void> {
78
- const { platform } = process;
79
- const cmd =
80
- platform === "darwin"
81
- ? ["open", url]
82
- : platform === "win32"
83
- ? ["cmd", "/c", "start", "", url]
84
- : ["xdg-open", url];
85
- try {
86
- spawn(cmd[0], cmd.slice(1), { stdio: "ignore", detached: true }).unref();
87
- } catch {
88
- /* 忽略打开失败(headless 等场景) */
89
- }
90
- }
91
-
92
- export default function (pi: ExtensionAPI): void {
93
- pi.registerCommand("webui", {
94
- description: "启动本机 pi-web-ui Web 界面(/webui [--port N] [--cwd PATH] [--no-browser] | stop | status)",
95
- handler: async (args: string, ctx: ExtensionCommandContext) => {
96
- const sid = ctx.sessionManager.getSessionId();
97
- const opts = parseArgs(args);
98
- const action = (args.split(/\s+/)[0] || "start").toLowerCase();
99
-
100
- // 停止
101
- if (action === "stop" || action === "kill") {
102
- const inst = running.get(sid);
103
- if (!inst) {
104
- ctx.ui.notify("没有正在运行的本机 pi-web-ui 服务器", "info");
105
- return;
106
- }
107
- inst.proc.kill("SIGTERM");
108
- running.delete(sid);
109
- ctx.ui.notify(`已停止 pi-web-ui (${inst.url})`, "info");
110
- return;
111
- }
112
-
113
- // 状态
114
- if (action === "status") {
115
- const inst = running.get(sid);
116
- if (!inst) {
117
- ctx.ui.notify("本机 pi-web-ui 未运行", "info");
118
- return;
119
- }
120
- const alive = inst.proc.exitCode === null;
121
- ctx.ui.notify(
122
- alive ? `pi-web-ui 运行中 → ${inst.url}\n端口 ${inst.port} · cwd ${inst.cwd}` : `已退出(exit=${inst.proc.exitCode})`,
123
- alive ? "info" : "warning",
124
- );
125
- return;
126
- }
127
-
128
- // 默认 start
129
- if (action !== "start" && action !== "run") {
130
- ctx.ui.notify(`未知动作 ${action}(可用 start|stop|status)`, "warning");
131
- return;
132
- }
133
-
134
- // 已运行则提示
135
- const existing = running.get(sid);
136
- if (existing && existing.proc.exitCode === null) {
137
- ctx.ui.notify(`pi-web-ui 已在运行 → ${existing.url}`, "info");
138
- return;
139
- }
140
-
141
- // 检查是否已构建
142
- if (!existsSync(SERVER_ENTRY)) {
143
- ctx.ui.notify(
144
- "缺少 dist/ 产物(当前安装未包含已构建前端)。请运行 `npm run build` 后重试,或用 pi-web-ui 官方 npm 包。",
145
- "warning",
146
- );
147
- return;
148
- }
149
-
150
- const port = opts.port ?? (await findFreePort());
151
- const cwd = opts.cwd ?? ctx.cwd;
152
- const url = `http://localhost:${port}`;
153
-
154
- const env = {
155
- ...process.env,
156
- PORT: String(port),
157
- PI_WEB_CWD: cwd,
158
- ...(process.env.PI_WEB_DATA_DIR ? {} : { PI_WEB_DATA_DIR: join(cwd, ".pi-web") }),
159
- };
160
- const proc = spawn(NODE, [SERVER_ENTRY], { cwd, env, stdio: "ignore", detached: true });
161
- proc.unref();
162
- running.set(sid, { proc, port, cwd, url });
163
-
164
- ctx.ui.notify(`pi-web-ui 启动中 → ${url}\n端口 ${port} · cwd ${cwd}\n(几秒后可用,/webui status 查看)`);
165
-
166
- if (!opts.noBrowser) await openBrowser(url);
167
-
168
- // 进程退出时清理
169
- proc.on("exit", () => {
170
- if (running.get(sid)?.proc === proc) running.delete(sid);
171
- });
172
- },
173
- });
174
-
175
- // 会话结束清理子进程,避免孤儿
176
- pi.on("session_shutdown", async (_event, ctx) => {
177
- const sid = ctx.sessionManager.getSessionId();
178
- const inst = running.get(sid);
179
- if (inst && inst.proc.exitCode === null) {
180
- inst.proc.kill("SIGTERM");
181
- running.delete(sid);
182
- }
183
- });
184
- }
1
+ /**
2
+ * pi-web-ui 的 pi 扩展 —— 提供命令行集成。
3
+ *
4
+ * 能力:
5
+ * /webui 启动本机 pi-web-ui 服务器,打开浏览器访问
6
+ * /webui --port 9000 指定端口启动
7
+ * /webui --no-browser 启动但不开浏览器
8
+ * /webui stop 停止已启动的服务器
9
+ * /webui status 查看运行状态 / URL
10
+ *
11
+ * 实现说明:
12
+ * - 不依赖全局 bin(pi install 后 pi-web-ui 命令不一定在 PATH),直接用
13
+ * node 调包内 dist/server/index.js,通过环境变量 PORT / PI_WEB_CWD /
14
+ * PI_WEB_DATA_DIR 控制。
15
+ * - 工作目录默认用当前 pi 会话的 ctx.cwd;可用 --cwd / path 覆盖。
16
+ * - 服务器作为子进程后台运行,/webui 不阻塞 pi。
17
+ * - 每个 pi 会话管理一个子进程;session_shutdown 时清理,避免孤儿进程。
18
+ */
19
+
20
+ import { spawn } from "node:child_process";
21
+ import { existsSync } from "node:fs";
22
+ import { dirname, join, resolve } from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+ import net from "node:net";
25
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
26
+
27
+ // 本文件位于 <pkg>/extensions/webui.ts → 包根在上一级
28
+ const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
29
+ const SERVER_ENTRY = join(PKG_ROOT, "dist", "server", "index.js");
30
+ const NODE = process.execPath;
31
+
32
+ /** 每个会话的服务器子进程 + 元数据 */
33
+ interface RunningServer {
34
+ proc: ReturnType<typeof spawn>;
35
+ port: number;
36
+ cwd: string;
37
+ url: string;
38
+ }
39
+
40
+ // 会话 → 运行实例(模块级 Map;每会话一个会话对象,无需清理全局)
41
+ const running = new Map<string, RunningServer>();
42
+
43
+ /** 找一个空闲端口 */
44
+ function findFreePort(from = 8787): Promise<number> {
45
+ return new Promise((resolve_, reject) => {
46
+ const srv = net.createServer();
47
+ srv.listen(from, () => {
48
+ const port = (srv.address() as net.AddressInfo).port;
49
+ srv.close(() => resolve_(port));
50
+ });
51
+ srv.on("error", () => {
52
+ // 端口被占则顺延
53
+ findFreePort(from + 1).then(resolve_, reject);
54
+ });
55
+ });
56
+ }
57
+
58
+ /** 解析 --key value / --flag 参数 */
59
+ function parseArgs(args: string): { port?: number; cwd?: string; noBrowser: boolean } {
60
+ const out: { port?: number; cwd?: string; noBrowser: boolean } = { noBrowser: false };
61
+ const toks = args.split(/\s+/).filter(Boolean);
62
+ for (let i = 0; i < toks.length; i++) {
63
+ const t = toks[i];
64
+ if ((t === "--port" || t === "-p") && toks[i + 1]) {
65
+ const n = Number(toks[++i]);
66
+ if (Number.isInteger(n) && n > 0 && n < 65536) out.port = n;
67
+ } else if ((t === "--cwd") && toks[i + 1]) {
68
+ out.cwd = resolve(toks[++i]);
69
+ } else if (t === "--no-browser") {
70
+ out.noBrowser = true;
71
+ }
72
+ }
73
+ return out;
74
+ }
75
+
76
+ /** 打开浏览器 */
77
+ async function openBrowser(url: string): Promise<void> {
78
+ const { platform } = process;
79
+ const cmd =
80
+ platform === "darwin"
81
+ ? ["open", url]
82
+ : platform === "win32"
83
+ ? ["cmd", "/c", "start", "", url]
84
+ : ["xdg-open", url];
85
+ try {
86
+ spawn(cmd[0], cmd.slice(1), { stdio: "ignore", detached: true }).unref();
87
+ } catch {
88
+ /* 忽略打开失败(headless 等场景) */
89
+ }
90
+ }
91
+
92
+ export default function (pi: ExtensionAPI): void {
93
+ pi.registerCommand("webui", {
94
+ description: "启动本机 pi-web-ui Web 界面(/webui [--port N] [--cwd PATH] [--no-browser] | stop | status)",
95
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
96
+ const sid = ctx.sessionManager.getSessionId();
97
+ const opts = parseArgs(args);
98
+ const action = (args.split(/\s+/)[0] || "start").toLowerCase();
99
+
100
+ // 停止
101
+ if (action === "stop" || action === "kill") {
102
+ const inst = running.get(sid);
103
+ if (!inst) {
104
+ ctx.ui.notify("没有正在运行的本机 pi-web-ui 服务器", "info");
105
+ return;
106
+ }
107
+ inst.proc.kill("SIGTERM");
108
+ running.delete(sid);
109
+ ctx.ui.notify(`已停止 pi-web-ui (${inst.url})`, "info");
110
+ return;
111
+ }
112
+
113
+ // 状态
114
+ if (action === "status") {
115
+ const inst = running.get(sid);
116
+ if (!inst) {
117
+ ctx.ui.notify("本机 pi-web-ui 未运行", "info");
118
+ return;
119
+ }
120
+ const alive = inst.proc.exitCode === null;
121
+ ctx.ui.notify(
122
+ alive ? `pi-web-ui 运行中 → ${inst.url}\n端口 ${inst.port} · cwd ${inst.cwd}` : `已退出(exit=${inst.proc.exitCode})`,
123
+ alive ? "info" : "warning",
124
+ );
125
+ return;
126
+ }
127
+
128
+ // 默认 start
129
+ if (action !== "start" && action !== "run") {
130
+ ctx.ui.notify(`未知动作 ${action}(可用 start|stop|status)`, "warning");
131
+ return;
132
+ }
133
+
134
+ // 已运行则提示
135
+ const existing = running.get(sid);
136
+ if (existing && existing.proc.exitCode === null) {
137
+ ctx.ui.notify(`pi-web-ui 已在运行 → ${existing.url}`, "info");
138
+ return;
139
+ }
140
+
141
+ // 检查是否已构建
142
+ if (!existsSync(SERVER_ENTRY)) {
143
+ ctx.ui.notify(
144
+ "缺少 dist/ 产物(当前安装未包含已构建前端)。请运行 `npm run build` 后重试,或用 pi-web-ui 官方 npm 包。",
145
+ "warning",
146
+ );
147
+ return;
148
+ }
149
+
150
+ const port = opts.port ?? (await findFreePort());
151
+ const cwd = opts.cwd ?? ctx.cwd;
152
+ const url = `http://localhost:${port}`;
153
+
154
+ const env = {
155
+ ...process.env,
156
+ PORT: String(port),
157
+ PI_WEB_CWD: cwd,
158
+ ...(process.env.PI_WEB_DATA_DIR ? {} : { PI_WEB_DATA_DIR: join(cwd, ".pi-web") }),
159
+ };
160
+ const proc = spawn(NODE, [SERVER_ENTRY], { cwd, env, stdio: "ignore", detached: true });
161
+ proc.unref();
162
+ running.set(sid, { proc, port, cwd, url });
163
+
164
+ ctx.ui.notify(`pi-web-ui 启动中 → ${url}\n端口 ${port} · cwd ${cwd}\n(几秒后可用,/webui status 查看)`);
165
+
166
+ if (!opts.noBrowser) await openBrowser(url);
167
+
168
+ // 进程退出时清理
169
+ proc.on("exit", () => {
170
+ if (running.get(sid)?.proc === proc) running.delete(sid);
171
+ });
172
+ },
173
+ });
174
+
175
+ // 会话结束清理子进程,避免孤儿
176
+ pi.on("session_shutdown", async (_event, ctx) => {
177
+ const sid = ctx.sessionManager.getSessionId();
178
+ const inst = running.get(sid);
179
+ if (inst && inst.proc.exitCode === null) {
180
+ inst.proc.kill("SIGTERM");
181
+ running.delete(sid);
182
+ }
183
+ });
184
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-ui",
3
- "version": "0.16.2",
3
+ "version": "0.17.0",
4
4
  "description": "Web chat interface for the pi coding agent, powered by the pi SDK (@earendil-works/pi-coding-agent) — one-command run, Docker/systemd/launchd deployable",
5
5
  "license": "MIT",
6
6
  "type": "module",