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 +20 -3
- package/README.md +10 -8
- package/README.zh-CN.md +1 -1
- package/bin/pi-web-ui.mjs +21 -4
- package/dist/server/agent-service.js +22 -7
- package/dist/server/dsh/dsh-agent-service.js +16 -6
- package/dist/server/index.js +38 -1
- package/dist/server/launch-origin.js +148 -0
- package/dist/server/slash-commands.js +19 -3
- package/dist/server/terminals.js +17 -9
- package/package.json +1 -1
- package/web/dist/assets/{TerminalPanel-BYigWYyx.js → TerminalPanel-DrYGGZSV.js} +1 -1
- package/web/dist/assets/{index-D7BpHJ59.js → index-BFA8Pn_5.js} +40 -40
- package/web/dist/assets/{index-GKv6Qo91.css → index-BduNm7_u.css} +1 -1
- package/web/dist/index.html +2 -2
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
|
|
20
|
-
-
|
|
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。Windows(NSIS,可选安装目录)/ 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.
|
|
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
|
|
182
|
-
|
|
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
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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(
|
|
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
|
-
|
|
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) {
|
package/dist/server/index.js
CHANGED
|
@@ -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
|
-
{
|
|
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
|
-
|
|
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) {
|