pi-web-ui 0.80.0 → 0.80.1

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/CHANGELOG.md CHANGED
@@ -12,12 +12,28 @@
12
12
 
13
13
  暂无未发布内容。
14
14
 
15
+ ## [0.80.1] — 2026-09-12
16
+
17
+ ### Added
18
+
19
+ - **更新面板新增「重启服务」**:由 `pi-web-ui server start|install` 起的实例,更新面板底部多一个按钮,点一下服务就重启(等价于 `pi-web-ui server restart`)——更新完立即生效,不用回终端。服务端 `server/launch-origin.ts` 判定本实例是不是被平台服务托管(launchd / systemd / Windows watchdog),判定结果随 `ready.service` 下发,`pi-web-ui server status` 也会显示启动方式;认不出来(前台 `pi-web-ui`、`npm run dev`、Docker)就不画按钮、也拒绝 `restart_service`——那里没有 supervisor,退出就真的停了。已装好的服务不用重装(运行时靠 `XPC_SERVICE_NAME` / `INVOCATION_ID` / `%APPDATA%\pi-web-ui\<name>.pid` 对比 `process.ppid` 识别),新装的另外烘焙 `PI_WEB_LAUNCHED_BY=service` / `PI_WEB_SERVICE_NAME`。回归:`tests/restart-service-test.mjs`。
20
+
21
+ ### Fixed
22
+
23
+ - **终端接管 bash 修复:没有尾部管道的命令不再报 `Cannot read properties of null (reading 'segment')`(issue #121)**:`date`、`ls | head -5` 这类命令没有「尾部限输出管道」,`detectTrailingLimiter()` 返回 `null`,而 #91 v2 的取值重构把原本的可选链写成了非空断言 `limiter!.segment` —— 结果几乎每条一次性 bash 命令都在取值处直接 TypeError(只有以 `| tail` / `| less` / `| more` / `| cat` 结尾的命令能跑)。现已改回可选链(这几个值只在真的拆掉管道时才被取用)。回归:`tests/unit/terminal-bash-limiter.test.ts`(桩终端钉住取值路径,CI 必跑);`tests/terminal-bash-test.mjs` 同步恢复可跑(动态导入走 `pathToFileURL`,Windows 上也跑得起来;提示文案断言钉死中文;一次性终端退出改为轮询而非固定等待)。
24
+
25
+ <!-- auto-i18n:start -->
26
+ ### i18n
27
+
28
+ - 前端新增 key(3):`restartService`、`restartingService`、`restartServiceTip`
29
+ <!-- auto-i18n:end -->
30
+
15
31
  ## [0.80.0] — 2026-09-12
16
32
 
17
33
  ### Added
18
34
 
19
- - **桌面版(Electron 外壳)**:同一套服务端 + 前端装进一个原生窗口——主进程用随机空闲口起 `dist/server/index.js`(`ELECTRON_RUN_AS_NODE` 当纯 Node 用,不再额外捆一个 Node),`/api/health` 就绪后 `BrowserWindow` 直接加载该地址,因此前端 `appUrl("/ws")`、`server/protocol.ts` 全部零改动。可与网页版并存:不抢 `8787`(`PI_WEB_PORT` 被占用时自动退到随机空闲口)、独立数据目录(`<userData>/data`)、独立单实例锁;外链丢给系统浏览器,renderer 走 `contextIsolation + sandbox` 且无 Node。Windows 安装包(NSIS,可选安装目录)随每个 Release 由 CI 出包并附在 Release 页面(macOS dmg / Linux AppImage 已配好,CI 暂只出 Windows);开发用 `npm run desktop:dev`,本地打包用 `npm run desktop:dist`。
20
- - 当前**未签名**:首次运行 Windows 会提示「未知发布者」(进展见仓库 README 的 Code signing policy 一节)。
35
+ - **桌面版(Electron 外壳)**:同一套服务端 + 前端装进一个原生窗口——主进程用随机空闲口起 `dist/server/index.js`(`ELECTRON_RUN_AS_NODE` 当纯 Node 用,不再额外捆一个 Node),`/api/health` 就绪后 `BrowserWindow` 直接加载该地址,因此前端 `appUrl("/ws")`、`server/protocol.ts` 全部零改动。可与网页版并存:不抢 `8787`(`PI_WEB_PORT` 被占用时自动退到随机空闲口)、独立数据目录(`<userData>/data`)、独立单实例锁;外链丢给系统浏览器,renderer 走 `contextIsolation + sandbox` 且无 Node。WindowsNSIS,可选安装目录)/ macOS(dmg)/ Linux(AppImage)安装包随每个 Release 由 CI 并行出包并附在 Release 页面;开发用 `npm run desktop:dev`,本地打包用 `npm run desktop:dist`。
36
+ - 当前三平台产物都**未签名**:Windows 首启有 SmartScreen「未知发布者」提示,macOS 首次需右键 → 打开(Gatekeeper),进展见仓库 README 的 Code signing policy 一节。
21
37
 
22
38
  ### Changed
23
39
 
@@ -481,7 +497,8 @@
481
497
  - 0.35.1(2026-08-27):编辑重问保留附件(#18)+ 全窗口拖放(#19)。
482
498
  - 0.29.0(2026-08-23):全局搜索弹窗(Ctrl+K)+ 消息列表惰性窗口化。
483
499
 
484
- [Unreleased]: https://github.com/xing-shuyin/pi-web-ui/compare/v0.79.0...main
500
+ [Unreleased]: https://github.com/xing-shuyin/pi-web-ui/compare/v0.80.1...main
501
+ [0.80.1]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.80.1
485
502
  [0.80.0]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.80.0
486
503
  [0.79.0]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.79.0
487
504
  [0.78.0]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.78.0
package/README.md CHANGED
@@ -64,7 +64,7 @@ theme switching, and a full settings panel — tuned for daily development.
64
64
  - **Streaming agent chat over WebSocket** — the pi SDK runs in-process; events are pushed as snapshots (60 ms throttled) and the browser renders them.
65
65
  - Thinking blocks, tool-call cards and bash outputs with live status (running → finished · waiting for the model · duration).
66
66
  - **Steer (follow-up queueing)** — send a follow-up while the agent is replying; it is queued and injected as soon as the current turn's tool calls settle (the "Interrupt" equivalent of the pi CLI).
67
- - **Slash commands** — `/` opens a command picker (built-in / extension / template / skill); built-ins include `/new /model /compact /cwd /thinking /resume`, plus `/help` (command list) and `/copy` (copy last reply).
67
+ - **Slash commands** — `/` opens a command picker (built-in / extension / template / skill); built-ins include `/new /model /compact /cwd /thinking /resume`, plus `/help` (command list) and `/copy` (copy last reply). `/new` takes an optional first prompt (`/new fix the failing test`) and sends it as the new chat's first message.
68
68
  - **Multiple conversations per project** — each conversation gets its own agent runtime and keeps running in the background after you switch away; the "Running conversations" list shows stream progress and lets you switch back.
69
69
  - **Edit & re-ask** — fork any past question into a new branch and re-prompt; the original conversation stays untouched.
70
70
  - Long threads auto-collapse messages older than 30 into lazy summary rows (click to expand).
@@ -178,20 +178,22 @@ npm i -g --allow-scripts=node-pty,@google/genai,protobufjs pi-web-ui@latest
178
178
 
179
179
  ### 🖥️ Desktop app (Windows installer)
180
180
 
181
- Prefer a window over a browser tab? Every release ships a Windows installer built
182
- from this repository by GitHub Actions:
181
+ Prefer a window over a browser tab? Every release ships installers built from
182
+ this repository by GitHub Actions:
183
183
 
184
- **[⬇ Download the latest installer](https://github.com/xing-shuyin/pi-web-ui/releases/latest)**
185
- — `pi-web-ui-desktop Setup <version>.exe`
184
+ **[⬇ Download the latest installers](https://github.com/xing-shuyin/pi-web-ui/releases/latest)**
185
+ Windows (`pi-web-ui-desktop Setup <version>.exe`), macOS (`.dmg`) and Linux
186
+ (`.AppImage`).
186
187
 
187
188
  The desktop shell reuses this very server: it spawns `dist/server/index.js` on a
188
189
  random free loopback port and opens a window pointed at it (see
189
190
  [`desktop/README.md`](desktop/README.md)). The web version is untouched — no
190
191
  fight over port `8787`, separate data directory, both can run side by side.
191
192
 
192
- Currently Windows-only and **not code signed yet** Windows SmartScreen will
193
- show an “unknown publisher” prompt the first time you run it (see the
194
- [code signing policy](#code-signing-policy)). Build it yourself with
193
+ **Nothing is code signed yet**: on Windows SmartScreen shows an “unknown
194
+ publisher” prompt, and on macOS you have to right-click → **Open** the app the
195
+ first time (Gatekeeper is stricter than SmartScreen) see the
196
+ [code signing policy](#code-signing-policy). Build it locally with
195
197
  `npm run desktop:dist`.
196
198
 
197
199
  ### Termux (Android)
package/README.zh-CN.md CHANGED
@@ -28,7 +28,7 @@
28
28
  - WebSocket 流式聊天 —— pi SDK 在服务端进程内运行,事件以快照(60ms 节流)推送,浏览器按快照渲染。
29
29
  - 思考块、工具调用卡片、bash 输出,实时显示状态(执行中 → 已结束 · 等模型 · 耗时)。
30
30
  - **补充(steer)** —— 回复流式中可排队发送跟进消息,当前回合工具结算后立即注入(对应 pi CLI 的 Enter 打断语义)。
31
- - **斜杠命令** —— 输入 `/` 弹出命令选择器(内置 / 扩展 / 模板 / 技能);内置 `/new /model /compact /cwd /thinking /resume`,另有 `/help`(命令清单)与 `/copy`(复制上一条回复)。
31
+ - **斜杠命令** —— 输入 `/` 弹出命令选择器(内置 / 扩展 / 模板 / 技能);内置 `/new /model /compact /cwd /thinking /resume`,另有 `/help`(命令清单)与 `/copy`(复制上一条回复)。`/new` 可带首条提示(`/new 修一下失败的测试`),会作为新对话的第一条消息发出去。
32
32
  - **每项目多对话并发** —— 每个对话独立 agent runtime,切走后仍在后台运行;「运行的对话」列表显示流式进度,可随时切回。
33
33
  - **编辑重问** —— 把任意历史问题 fork 成新分支重新提问,原对话不受影响。
34
34
  - 超过 30 条的消息自动折叠为摘要行(惰性渲染,点击展开)。
package/bin/pi-web-ui.mjs CHANGED
@@ -1022,11 +1022,19 @@ function serviceOptions(opts) {
1022
1022
  return { name, port, cwd, dataDir, engine, host, agentDir };
1023
1023
  }
1024
1024
 
1025
- function serviceEnv(port, cwd, dataDir, engine, host, agentDir) {
1025
+ function serviceEnv(port, cwd, dataDir, engine, host, agentDir, service = {}) {
1026
1026
  const env = {
1027
1027
  PI_WEB_PORT: port,
1028
1028
  PI_WEB_CWD: cwd,
1029
1029
  };
1030
+ // 启动来源标记(见 server/launch-origin.ts):只有真正被平台服务管理器托管的
1031
+ // 启动器才写。桌面快捷方式 / .command 在「未安装服务」时是前台跑(退出不回来),
1032
+ // 不能带这个标记,所以它们不传 service。已装好的老服务没有这两个变量,服务端
1033
+ // 仍能靠运行时判据(XPC_SERVICE_NAME / INVOCATION_ID / PID 文件)认出来。
1034
+ if (service.name) {
1035
+ env.PI_WEB_LAUNCHED_BY = "service";
1036
+ env.PI_WEB_SERVICE_NAME = service.name;
1037
+ }
1030
1038
  // Interactive Windows tasks inherit the user's PATH; only systemd/launchd
1031
1039
  // run with a minimal environment that needs an explicit PATH.
1032
1040
  if (!isWin) env.PATH = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
@@ -1047,7 +1055,7 @@ function installLaunchd(opts) {
1047
1055
  const { name, port, cwd, dataDir, engine, host, agentDir } = serviceOptions(opts);
1048
1056
  const label = serviceLabel(name);
1049
1057
  const plist = launchAgentPlist(name);
1050
- const content = buildPlist(label, cwd, serviceEnv(port, cwd, dataDir, engine, host, agentDir));
1058
+ const content = buildPlist(label, cwd, serviceEnv(port, cwd, dataDir, engine, host, agentDir, { name }));
1051
1059
  if (opts.print) {
1052
1060
  console.log(`# ${plist}\n${content}`);
1053
1061
  return;
@@ -1071,7 +1079,7 @@ function installLaunchd(opts) {
1071
1079
 
1072
1080
  function installSystemd(opts) {
1073
1081
  const { name, port, cwd, dataDir, engine, host, agentDir } = serviceOptions(opts);
1074
- const content = buildUnit(cwd, serviceEnv(port, cwd, dataDir, engine, host, agentDir));
1082
+ const content = buildUnit(cwd, serviceEnv(port, cwd, dataDir, engine, host, agentDir, { name }));
1075
1083
  const unitPath = systemdUnitPath(name);
1076
1084
  if (opts.print) {
1077
1085
  console.log(`# ${unitPath}\n${content}`);
@@ -1120,7 +1128,7 @@ function uninstallSystemd(opts) {
1120
1128
 
1121
1129
  function installWindows(opts) {
1122
1130
  const { name, port, cwd, dataDir, engine, host, agentDir } = serviceOptions(opts);
1123
- const env = serviceEnv(port, cwd, dataDir, engine, host, agentDir);
1131
+ const env = serviceEnv(port, cwd, dataDir, engine, host, agentDir, { name });
1124
1132
  const ps1Path = winPs1Path(name);
1125
1133
  const vbsPath = winVbsPath(name);
1126
1134
  const pidPath = winPidFilePath(name);
@@ -1244,6 +1252,15 @@ async function printLiveStatus(opts) {
1244
1252
  console.log(" --- 实时状态 (control socket) ---");
1245
1253
  console.log(` 版本 : ${st.version} · PID ${st.pid}`);
1246
1254
  console.log(` 目录 : ${st.cwd}`);
1255
+ // 启动来源(server/launch-origin.ts):有 supervisor = 这个进程退出后会被自动
1256
+ // 拉起(server restart / 更新面板的「重启服务」才有意义)。
1257
+ console.log(
1258
+ ` 启动 : ${
1259
+ st.service
1260
+ ? `pi-web-ui 服务(${st.service.supervisor} · ${st.service.name})`
1261
+ : "前台 / 开发模式(无 supervisor,退出不自动重启)"
1262
+ }`,
1263
+ );
1247
1264
  console.log(` 排空 : ${st.quiesced ? `是(自 ${new Date(st.quiescedSince).toLocaleString()})` : "否"}`);
1248
1265
  console.log(
1249
1266
  ` 连接 : ${st.connectedClients} 个浏览器 · ${st.activeConversations} 个运行中对话 · ${st.pendingMessages} 条排队消息`,
@@ -45,6 +45,7 @@ import { collectSubagentDescendantIds, makeSubagentTools, subagentTitle, withSub
45
45
  import { makeDelegateTaskTool } from "./delegate-task.js";
46
46
  import { buildAttachmentMessages } from "./attachments.js";
47
47
  import { BUILTIN_SOUL, DEFAULT_PROMPT_TEMPLATE, buildToolsSchemaText, renderPromptTemplate, resolveSectionTexts, } from "./prompt-composer.js";
48
+ import { launchOrigin, toServiceInfo } from "./launch-origin.js";
48
49
  import { serializeMessage, serializeStreamingMessage, stripTransientRetryErrors, } from "./serialize.js";
49
50
  import { loadCommands, saveCommandsFile, TerminalManager } from "./terminals.js";
50
51
  const SNAPSHOT_INTERVAL_MS = 60;
@@ -127,7 +128,9 @@ Many legacy Chinese text files (.html/.txt/.md/.log, exported documents) are GBK
127
128
  * terminal) plus head/tail (post-processed on the returned output) so the parameter
128
129
  * schema stays consistent with the terminal-backed tool.
129
130
  */
130
- export function makeKillableBashTool(cwd, kills) {
131
+ export function makeKillableBashTool(cwd, kills,
132
+ /** per-call 返回文本的服务端语言(默认英文);工具 definition 走 bilingual 内联双语。 */
133
+ lang = () => "en") {
131
134
  const base = createLocalBashOperations();
132
135
  const tool = createBashTool(cwd, {
133
136
  operations: {
@@ -176,7 +179,7 @@ export function makeKillableBashTool(cwd, kills) {
176
179
  // head/tail 后处理(native 无终端,直接截返回行即可)。
177
180
  const p = params;
178
181
  if ((p?.head || p?.tail) && result?.content?.[0]?.text != null) {
179
- result.content[0].text = applyHeadTail(result.content[0].text, p.head, p.tail);
182
+ result.content[0].text = applyHeadTail(result.content[0].text, p.head, p.tail, lang());
180
183
  }
181
184
  return result;
182
185
  },
@@ -1408,7 +1411,9 @@ export class ClientSession {
1408
1411
  // 「默认 bash 覆盖」开关(terminalBash)关 → 原生 SDK bash(纯进程、不开终端);
1409
1412
  // 开 → 终端接管 bash(persist 决定一次性/持久,可静默自动转后台)。
1410
1413
  customTools: [
1411
- makeAdaptiveBashTool(makeKillableBashTool(effectiveCwd, this.bashKills), makeTerminalBashTool(terminals, {
1414
+ makeAdaptiveBashTool(
1415
+ // issue #91:bash 返回按客户端 UI 语言出中英(英文默认)。
1416
+ makeKillableBashTool(effectiveCwd, this.bashKills, () => this.getLang()), makeTerminalBashTool(terminals, {
1412
1417
  cwd: effectiveCwd,
1413
1418
  // 设置开 = 用终端;此分支里 persist 未显式给时默认一次性(false)。
1414
1419
  defaultPersist: () => false,
@@ -2707,6 +2712,8 @@ export class ClientSession {
2707
2712
  cwd: () => this.cwd,
2708
2713
  getSession: () => this.session,
2709
2714
  newChat: () => this.newChat(),
2715
+ // /new <prompt>: deliver the text as the new session's first prompt.
2716
+ prompt: (text) => this.prompt(text),
2710
2717
  setModel: (id) => this.setModel(id),
2711
2718
  setCwd: (path) => this.setCwd(path),
2712
2719
  setThinking: (level) => this.setThinking(level),
@@ -3528,9 +3535,13 @@ export class ClientSession {
3528
3535
  });
3529
3536
  }
3530
3537
  }
3538
+ /** 新建/切到一个空白对话。返回值 = 「当前活动对话就是一个可以接收首条的
3539
+ * 空白新对话」——/new <prompt> 只在 true 时投递首条提示;false 表示没能进入
3540
+ * 新对话(准入关闭 / 同项目对话数达上限 / runtime 创建失败),此时照发会把
3541
+ * 首条提示投进用户原本正在用的那个对话里。 */
3531
3542
  async newChat() {
3532
3543
  if (this.quiesceBlocked())
3533
- return;
3544
+ return false;
3534
3545
  // Reuse an already-open blank conversation instead of piling up new ones
3535
3546
  // on every click: if the active chat has no messages it IS the new chat
3536
3547
  // (focus already on it); otherwise switch to the first blank one (under
@@ -3548,7 +3559,7 @@ export class ClientSession {
3548
3559
  const active = this.conv;
3549
3560
  if (active && isBlank(active)) {
3550
3561
  this.flushSnapshot();
3551
- return;
3562
+ return true;
3552
3563
  }
3553
3564
  for (const conv of this.convs.values()) {
3554
3565
  if (conv.id === this.activeId)
@@ -3556,7 +3567,7 @@ export class ClientSession {
3556
3567
  if (isBlank(conv)) {
3557
3568
  await this.switchConversation(conv.id);
3558
3569
  this.flushSnapshot();
3559
- return;
3570
+ return true;
3560
3571
  }
3561
3572
  }
3562
3573
  // Cap is per project — conversations of other projects keep their own
@@ -3570,7 +3581,7 @@ export class ClientSession {
3570
3581
  text: `当前项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),请先打开某个对话并离开(不继续对话)以移出列表`,
3571
3582
  textEn: `This project already has the max open conversations (${MAX_OPEN_CONVERSATIONS}). Open one and leave it (without continuing) to remove it from the list.`,
3572
3583
  });
3573
- return;
3584
+ return false;
3574
3585
  }
3575
3586
  // The outgoing conversation is left behind — apply the running-list
3576
3587
  // lifecycle. Removal is deferred until the new chat exists so the active
@@ -3579,6 +3590,7 @@ export class ClientSession {
3579
3590
  // Carry the model chosen in the active chat over to the new chat so it
3580
3591
  // doesn't silently revert to the ModelRuntime default model.
3581
3592
  const prevModel = this.conv.session.agent.state.model ?? null;
3593
+ let ready = false;
3582
3594
  try {
3583
3595
  const conversationId = this.nextConversationId();
3584
3596
  const terminals = this.makeTerminalManager(conversationId, this.cwd);
@@ -3617,6 +3629,7 @@ export class ClientSession {
3617
3629
  void this.pushSlashCommands();
3618
3630
  // 新对话即当前打开 → 插件重拉(轨迹视图跟随)。
3619
3631
  this.notifyConversationChanged();
3632
+ ready = true;
3620
3633
  }
3621
3634
  catch (err) {
3622
3635
  this.emit({
@@ -3627,6 +3640,7 @@ export class ClientSession {
3627
3640
  });
3628
3641
  }
3629
3642
  this.flushSnapshot();
3643
+ return ready;
3630
3644
  }
3631
3645
  /**
3632
3646
  * The active conversation is being left (new_chat / switch_conversation /
@@ -5163,6 +5177,7 @@ export class AgentService {
5163
5177
  connectedClients: this.socketCount,
5164
5178
  activeConversations: this.activeConversations(),
5165
5179
  pendingMessages: this.pendingMessages(),
5180
+ service: toServiceInfo(launchOrigin()),
5166
5181
  };
5167
5182
  }
5168
5183
  /** Get or create the session for a client, racing attach calls safely. */
@@ -38,6 +38,7 @@ import { TerminalManager, loadCommands, saveCommandsFile } from "../terminals.js
38
38
  import { saveUpload } from "../uploads.js";
39
39
  import { checkAll as checkAllUpdates, collectTargets } from "../update-check.js";
40
40
  import { previewKind } from "../text-sniff.js";
41
+ import { launchOrigin, toServiceInfo } from "../launch-origin.js";
41
42
  import { DshRuntime, loadDeepSeekKey } from "./dsh-client.js";
42
43
  import { DshStreamAccumulator, assistantMessageEventToUiMessage, toolResultEventToUiMessage, userMessageEventToUiMessage, } from "./dsh-serialize.js";
43
44
  import { firstUserText, findSessionFilesForCwd, readSessionLog, replayEventsToMessages } from "./dsh-sessions.js";
@@ -1137,13 +1138,15 @@ export class DshClientSession {
1137
1138
  }
1138
1139
  this.emit({ type: "conversations", conversations: list, activeId: this.activeId });
1139
1140
  }
1141
+ /** 语义同 pi 引擎的 newChat:true = 当前活动对话是可接收首条的空白新对话
1142
+ * (/new <prompt> 靠它决定要不要把首条提示发出去)。 */
1140
1143
  async newChat() {
1141
1144
  if (this.quiesceBlocked())
1142
- return;
1145
+ return false;
1143
1146
  const active = this.conv;
1144
1147
  if (active.messages.length === 0 && active.terminals.list().length === 0) {
1145
1148
  this.flushSnapshot();
1146
- return;
1149
+ return true;
1147
1150
  }
1148
1151
  for (const conv of this.convs.values()) {
1149
1152
  if (conv.id === this.activeId)
@@ -1151,7 +1154,7 @@ export class DshClientSession {
1151
1154
  if (conv.messages.length === 0) {
1152
1155
  this.switchConversation(conv.id);
1153
1156
  this.flushSnapshot();
1154
- return;
1157
+ return true;
1155
1158
  }
1156
1159
  }
1157
1160
  const openInProject = [...this.convs.values()].filter((c) => c.cwd === this.cwd).length;
@@ -1162,7 +1165,7 @@ export class DshClientSession {
1162
1165
  text: `当前项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个)`,
1163
1166
  textEn: `This project already has the max open conversations (${MAX_OPEN_CONVERSATIONS}).`,
1164
1167
  });
1165
- return;
1168
+ return false;
1166
1169
  }
1167
1170
  // 旧对话保留(listed 生命周期简化:不主动移除)。
1168
1171
  const prevModel = this.model;
@@ -1174,6 +1177,7 @@ export class DshClientSession {
1174
1177
  this.emitGoalStatus();
1175
1178
  this.pushTerminals();
1176
1179
  this.flushSnapshot();
1180
+ return true;
1177
1181
  }
1178
1182
  async switchConversation(id) {
1179
1183
  if (!this.convs.has(id) || id === this.activeId)
@@ -2854,9 +2858,14 @@ export class DshClientSession {
2854
2858
  /** 拦截执行斜杠命令;返回 true 表示已处理(不发给模型)。 */
2855
2859
  async execSlash(name, args) {
2856
2860
  switch (name) {
2857
- case "new":
2858
- await this.newChat();
2861
+ case "new": {
2862
+ const first = args.trim();
2863
+ // /new <prompt>:与 pi 引擎一致(共用 NATIVE_COMMANDS 的提示词),
2864
+ // 仅当真的落在空白新对话上才投递首条提示,否则会把提示误发进当前对话。
2865
+ if ((await this.newChat()) && first)
2866
+ await this.prompt(first);
2859
2867
  return true;
2868
+ }
2860
2869
  case "model": {
2861
2870
  if (!args.trim()) {
2862
2871
  this.emit({
@@ -3421,6 +3430,7 @@ export class DshAgentService {
3421
3430
  connectedClients: this.socketCount,
3422
3431
  activeConversations: this.activeConversations(),
3423
3432
  pendingMessages: this.pendingMessages(),
3433
+ service: toServiceInfo(launchOrigin()),
3424
3434
  };
3425
3435
  }
3426
3436
  async attach(clientId, send) {
@@ -37,6 +37,7 @@ import { scheduleUploadCleanup } from "./uploads.js";
37
37
  import { ensureWindowsBash, windowsBashDir } from "./ensure-bash.js";
38
38
  import { listThemes, resolveThemeFile } from "./themes.js";
39
39
  import { isManaged, managedRefusal } from "./managed.js";
40
+ import { launchOrigin, toServiceInfo } from "./launch-origin.js";
40
41
  import { parseTabs, tabsRefusal } from "./tabs.js";
41
42
  import { installPack, isKnownPack, listPacks, loadServerStrings, readPackFile, removePack, unloadServerStrings, } from "./locales.js";
42
43
  import { PluginManager, resolvePluginClientFile, } from "./plugins.js";
@@ -194,6 +195,13 @@ if (AUTH_TOKEN) {
194
195
  const ENGINE = process.env.PI_WEB_ENGINE === "dsh" ? "dsh" : "pi";
195
196
  /** PI_WEB_MANAGED=1: this instance is updated by whoever deploys it. */
196
197
  const MANAGED = isManaged();
198
+ /** Who started this process: a platform service manager (launchd / systemd /
199
+ * Windows watchdog — i.e. `pi-web-ui server start|install`) or nothing
200
+ * (foreground / dev / Docker). Decides whether the UPDATE panel offers
201
+ * "restart service" and what quitting means (see scheduleQuit). */
202
+ const ORIGIN = launchOrigin();
203
+ /** 下发给浏览器的服务信息(null = 没有 supervisor)。 */
204
+ const SERVICE_INFO = toServiceInfo(ORIGIN);
197
205
  /** PI_WEB_TABS: the tabs this instance offers. null = all of them, as before. */
198
206
  const TABS = parseTabs();
199
207
  app.get("/api/health", (_req, res) => {
@@ -665,8 +673,12 @@ service.onClientCwdChanged = (cwd) => pluginMgr.notifyCwd(cwd);
665
673
  function scheduleQuit() {
666
674
  const isLaunchd = process.platform === "darwin" && process.ppid === 1;
667
675
  const isSystemd = process.platform === "linux" && !!process.env.INVOCATION_ID;
676
+ // Windows `server install` runs the server under the powershell watchdog
677
+ // launcher (`while ($true) { node …; Start-Sleep 10 }`) — exiting brings it
678
+ // back within ~10s, same contract as launchd/systemd (see launch-origin.ts).
679
+ const isWinWatchdog = ORIGIN.supervisor === "windows-watchdog";
668
680
  const inDocker = existsSync("/.dockerenv");
669
- if (isLaunchd || isSystemd || inDocker) {
681
+ if (isLaunchd || isSystemd || isWinWatchdog || inDocker) {
670
682
  setTimeout(() => {
671
683
  console.log("pi-web-ui:quit — shutting down (supervisor will restart)…");
672
684
  if (isSystemd)
@@ -922,6 +934,30 @@ wss.on("connection", (ws) => {
922
934
  case "check_updates_all":
923
935
  void cs.checkUpdatesAll(msg.force === true);
924
936
  break;
937
+ case "restart_service": {
938
+ // Same effect as `pi-web-ui server restart`: this process exits and its
939
+ // supervisor brings it back (launchd/systemd immediately, the Windows
940
+ // watchdog within ~10s). Refused without a supervisor — exiting there
941
+ // would just stop the server the user is looking at.
942
+ if (!ORIGIN.supervisor) {
943
+ send({
944
+ type: "notice",
945
+ level: "error",
946
+ text: "当前实例不是由 pi-web-ui 服务启动的(前台运行),无法自动重启;请在终端里重启,或先用 pi-web-ui server install 安装服务。",
947
+ textEn: "This instance runs in the foreground, not as a pi-web-ui service — nothing would bring it back. Restart it in its terminal, or install the service with `pi-web-ui server install`.",
948
+ });
949
+ break;
950
+ }
951
+ send({
952
+ type: "notice",
953
+ level: "info",
954
+ text: "正在重启服务…页面会在服务恢复后自动重连。",
955
+ textEn: "Restarting the service… this page reconnects once it is back.",
956
+ });
957
+ // Let the notice (and this socket's backlog) flush before we go down.
958
+ setTimeout(() => void scheduleQuit(), 400);
959
+ break;
960
+ }
925
961
  case "dialog_response":
926
962
  cs.resolveDialog(msg.id, msg.value);
927
963
  break;
@@ -1153,6 +1189,7 @@ wss.on("connection", (ws) => {
1153
1189
  appVersion: appVersion(),
1154
1190
  managed: MANAGED,
1155
1191
  tabs: TABS ? [...TABS] : undefined,
1192
+ service: SERVICE_INFO ?? undefined,
1156
1193
  });
1157
1194
  // Plugin catalog: re-scan + activate new dirs on every attach so
1158
1195
  // freshly dropped plugins show up without a server restart.
@@ -0,0 +1,148 @@
1
+ /**
2
+ * launch-origin — 这个进程是被谁启动的?
3
+ *
4
+ * 界面里有一处需要知道答案:更新面板的「重启服务」按钮。它只有在**退出后会被
5
+ * 自动拉起**的实例上才有意义(`pi-web-ui server start` 起的是这种),前台
6
+ * `pi-web-ui` / `npm run dev` 起的实例点它 = 把服务关掉不回来了。
7
+ *
8
+ * 判定的是「有没有 supervisor 会在本进程退出后把它拉起来」,一共三种:
9
+ * - **launchd**(macOS,`server install` 写的 plist)——launchd 给每个 job 设
10
+ * `XPC_SERVICE_NAME`,据此认出我们生成的 label(com.xingshuyin.pi-web-ui /
11
+ * com.<name>.server);
12
+ * - **systemd**(Linux,`Restart=always`)——systemd 给每个 unit 设
13
+ * `INVOCATION_ID`;unit 名从 /proc/self/cgroup 里取(`…/pi-web-ui.service`);
14
+ * - **windows-watchdog**(Windows,`server install` 写的 ps1:
15
+ * `while ($true) { node …; Start-Sleep 10 }`)——没有环境变量可依,靠 PID
16
+ * 文件反查:ps1 把自己的 PID 写进 `%APPDATA%\pi-web-ui\<name>.pid`,而 node
17
+ * 是它的直接子进程,所以 `process.ppid` 等于文件里那个 PID。同名 ps1 里必须
18
+ * 真的有 watchdog 循环 —— 桌面快捷方式的 ps1 也写同一个 PID 文件,但它
19
+ * (未安装服务时)是前台跑,退出不会回来。
20
+ *
21
+ * 新版 `server install` 另外烘焙 `PI_WEB_LAUNCHED_BY=service` +
22
+ * `PI_WEB_SERVICE_NAME=<name>`(最高优先级、最明确的一路);已装好的老服务没有
23
+ * 这两个变量,所以上面的运行时判据必须保留。
24
+ *
25
+ * Docker **刻意不算**:容器由编排/`restart:` 策略管,重启容器不是这个按钮该干的事。
26
+ *
27
+ * 纯函数(env / ppid / fs 读取全部可注入)便于单测;`launchOrigin()` 是进程级
28
+ * 缓存的一次性探测,服务端多处共用同一份结论。
29
+ */
30
+ import { readdirSync, readFileSync } from "node:fs";
31
+ import { homedir } from "node:os";
32
+ import { join } from "node:path";
33
+ /** 默认服务名(`server install` 没传 --name 时)。 */
34
+ export const DEFAULT_SERVICE_NAME = "pi-web-ui";
35
+ /** 默认 launchd label —— 与 bin/pi-web-ui.mjs 的 serviceLabel() 保持一致。 */
36
+ const LAUNCHD_LABEL_DEFAULT = "com.xingshuyin.pi-web-ui";
37
+ /** Windows 启动器目录(CLI 的 winServiceDir 同源)。 */
38
+ const WIN_SERVICE_DIR_NAME = "pi-web-ui";
39
+ function defaultListDir(dir) {
40
+ try {
41
+ return readdirSync(dir);
42
+ }
43
+ catch {
44
+ return [];
45
+ }
46
+ }
47
+ function defaultReadFile(path) {
48
+ try {
49
+ return readFileSync(path, "utf8");
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ /** Windows 启动器目录:`%APPDATA%\pi-web-ui`(CLI 的 winServiceDir)。 */
56
+ export function winServiceDir(env = process.env) {
57
+ const appData = env.APPDATA?.trim() || join(homedir(), "AppData", "Roaming");
58
+ return join(appData, WIN_SERVICE_DIR_NAME);
59
+ }
60
+ /** launchd label → 服务名;不是我们生成的 label 返回 null。 */
61
+ function nameFromLaunchdLabel(label) {
62
+ if (label === LAUNCHD_LABEL_DEFAULT)
63
+ return DEFAULT_SERVICE_NAME;
64
+ // serviceLabel(`--name foo`) = com.foo.server
65
+ const m = /^com\.(.+)\.server$/.exec(label);
66
+ return m?.[1] ?? null;
67
+ }
68
+ /** systemd cgroup 内容 → unit 名(`0::/system.slice/pi-web-ui.service`)。 */
69
+ function nameFromCgroup(content) {
70
+ const m = /([A-Za-z0-9_.@-]+)\.service/.exec(content ?? "");
71
+ return m?.[1];
72
+ }
73
+ /** `PI_WEB_LAUNCHED_BY=service` 时的 supervisor(按平台推导)。 */
74
+ function supervisorForPlatform(platform) {
75
+ if (platform === "win32")
76
+ return "windows-watchdog";
77
+ if (platform === "darwin")
78
+ return "launchd";
79
+ return "systemd";
80
+ }
81
+ /** Windows:PID 文件里的 PID === 本进程的 ppid,且同名 ps1 里确实有 watchdog 循环。 */
82
+ function detectWindows(input, env) {
83
+ const dir = input.winServiceDir ?? winServiceDir(env);
84
+ const listDir = input.listDir ?? defaultListDir;
85
+ const readFile = input.readFile ?? defaultReadFile;
86
+ const ppid = input.ppid ?? process.ppid;
87
+ for (const file of listDir(dir)) {
88
+ if (!file.endsWith(".pid"))
89
+ continue;
90
+ const raw = readFile(join(dir, file));
91
+ if (raw === null)
92
+ continue;
93
+ if (Number(raw.trim()) !== ppid)
94
+ continue;
95
+ const name = file.slice(0, -".pid".length);
96
+ const ps1 = readFile(join(dir, `${name}.ps1`));
97
+ if (ps1 && ps1.includes("while ($true)"))
98
+ return { supervisor: "windows-watchdog", name };
99
+ }
100
+ return { supervisor: null };
101
+ }
102
+ /**
103
+ * 探测启动来源。`PI_WEB_LAUNCHED_BY=service` 优先(新版 install 烘焙的),
104
+ * 否则按平台识别 supervisor 自己的运行时痕迹。
105
+ */
106
+ export function detectLaunchOrigin(input = {}) {
107
+ const env = input.env ?? process.env;
108
+ const platform = input.platform ?? process.platform;
109
+ const serviceName = env.PI_WEB_SERVICE_NAME?.trim() || undefined;
110
+ if ((env.PI_WEB_LAUNCHED_BY ?? "").trim().toLowerCase() === "service") {
111
+ return { supervisor: supervisorForPlatform(platform), name: serviceName ?? DEFAULT_SERVICE_NAME };
112
+ }
113
+ if (platform === "darwin") {
114
+ const label = env.XPC_SERVICE_NAME?.trim();
115
+ if (!label)
116
+ return { supervisor: null };
117
+ const name = nameFromLaunchdLabel(label);
118
+ return name ? { supervisor: "launchd", name } : { supervisor: null };
119
+ }
120
+ if (platform === "linux") {
121
+ if (!env.INVOCATION_ID)
122
+ return { supervisor: null };
123
+ const readCgroup = input.readCgroup ?? defaultReadCgroup;
124
+ return { supervisor: "systemd", name: serviceName ?? nameFromCgroup(readCgroup()) ?? DEFAULT_SERVICE_NAME };
125
+ }
126
+ if (platform === "win32")
127
+ return detectWindows(input, env);
128
+ return { supervisor: null };
129
+ }
130
+ function defaultReadCgroup() {
131
+ return defaultReadFile("/proc/self/cgroup");
132
+ }
133
+ let cached = null;
134
+ /** 进程级缓存的一次性探测(服务端多处共用:ready 消息 / quit 语义 / 控制 socket)。 */
135
+ export function launchOrigin() {
136
+ cached ??= detectLaunchOrigin();
137
+ return cached;
138
+ }
139
+ /** 仅测试用:清掉缓存。 */
140
+ export function resetLaunchOriginCache() {
141
+ cached = null;
142
+ }
143
+ /** 下发给浏览器的服务信息(null = 没有 supervisor,界面不提供「重启服务」)。 */
144
+ export function toServiceInfo(origin) {
145
+ if (!origin.supervisor)
146
+ return null;
147
+ return { name: origin.name ?? DEFAULT_SERVICE_NAME, supervisor: origin.supervisor };
148
+ }
@@ -3,7 +3,13 @@
3
3
  * prompt() — without this they'd be sent to the model as plain text). Keep in
4
4
  * sync with exec(). */
5
5
  export const NATIVE_COMMANDS = [
6
- { name: "new", description: "新建对话", descriptionEn: "New chat" },
6
+ {
7
+ name: "new",
8
+ description: "新建对话(可带首条提示:/new <提示>)",
9
+ descriptionEn: "New chat (optional first prompt: /new <prompt>)",
10
+ argumentHint: "[提示]",
11
+ argumentHintEn: "[prompt]",
12
+ },
7
13
  {
8
14
  name: "name",
9
15
  description: "重命名当前会话",
@@ -135,9 +141,19 @@ export class SlashCommandsService {
135
141
  * name is not a native command (the prompt falls through to the SDK). */
136
142
  async exec(name, args) {
137
143
  switch (name) {
138
- case "new":
139
- await this.host.newChat();
144
+ case "new": {
145
+ const first = args.trim();
146
+ const ready = await this.host.newChat();
147
+ // /new <prompt>: deliver the text as the new session's first
148
+ // prompt, exactly as if typed after the switch. Empty = old
149
+ // behavior (blank chat, no send). Only when the switch actually
150
+ // landed on a blank chat — newChat() reports false when it bailed
151
+ // (cap reached / runtime creation failed) and sending anyway would
152
+ // drop the text into the conversation the user was already in.
153
+ if (ready !== false && first && this.host.prompt)
154
+ await this.host.prompt(first);
140
155
  return true;
156
+ }
141
157
  case "name": {
142
158
  const trimmed = args.trim();
143
159
  if (!trimmed) {