pi-web-ui 0.52.0 → 0.53.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.
package/README.md CHANGED
@@ -154,7 +154,7 @@ npm i -g --allow-scripts=node-pty,@google/genai,protobufjs pi-web-ui@latest
154
154
 
155
155
  ```bash
156
156
  pi-web-ui # foreground, http://localhost:8787
157
- PORT=9000 PI_WEB_CWD=/path/to/project pi-web-ui # custom port / workspace
157
+ PI_WEB_PORT=9000 PI_WEB_CWD=/path/to/project pi-web-ui # custom port / workspace
158
158
  ```
159
159
 
160
160
  **Stop**
package/bin/pi-web-ui.mjs CHANGED
@@ -17,14 +17,14 @@
17
17
  * - macOS → launchd 用户代理,label 默认 com.xingshuyin.pi-web-ui
18
18
  * (--name 自定义时 com.<name>.server),无需 sudo
19
19
  * - Linux → systemd 单元 <name>.service(/etc/systemd/system/,自动 sudo)
20
- * - Windows → 计划任务(Task Scheduler / schtasks,登录后自启,无需管理员),
21
- * 隐藏窗口启动(无黑窗);PowerShell 启动脚本与任务 XML 生成在
20
+ * - Windows → 登录自启 Run 键(HKCU,无需管理员)+ wscript 隐藏启动(无黑窗);
21
+ * PowerShell 启动脚本 / VBS 启动器 / PID 文件生成在
22
22
  * %APPDATA%\pi-web-ui\
23
23
  *
24
- * 环境变量(前台与系统服务均适用):PORT / PI_WEB_CWD / PI_WEB_DATA_DIR /
24
+ * 环境变量(前台与系统服务均适用):PI_WEB_PORT / PI_WEB_CWD / PI_WEB_DATA_DIR /
25
25
  * PI_CODING_AGENT_DIR。
26
26
  */
27
- import { spawnSync } from "node:child_process";
27
+ import { spawn, spawnSync } from "node:child_process";
28
28
  import { createConnection } from "node:net";
29
29
  import { get as httpGet } from "node:http";
30
30
  import {
@@ -77,15 +77,15 @@ const HELP = `pi-web-ui v${pkg.version} — web chat for the pi coding agent
77
77
  pi-web-ui --version / --help
78
78
 
79
79
  server 选项:
80
- --port <n> 端口(默认 8787,或 $PORT
81
- --cwd <dir> 工作目录(默认 $PI_WEB_CWD 或当前目录)
80
+ --port <n> 端口(默认 8787,或 $PI_WEB_PORT
81
+ --cwd <dir> 工作目录(默认 $PI_WEB_CWD 或用户主目录;前台启动默认当前目录)
82
82
  --data-dir <dir> 会话数据目录(默认 <cwd>/.pi-web)
83
83
  --name <name> 服务名(默认 pi-web-ui;macOS 的 launchd label
84
84
  为 com.xingshuyin.pi-web-ui,自定义名时为 com.<name>.server)
85
85
  --print 只打印将生成的配置文件,不实际安装
86
86
 
87
- 平台: macOS → launchd 用户代理 · Linux → systemd · Windows → 计划任务(schtasks)
88
- Windows 任务登录后自启、无需管理员、隐藏窗口运行;stop 停止,uninstall 移除)
87
+ 平台: macOS → launchd 用户代理 · Linux → systemd · Windows → 登录自启 Run 键
88
+ HKCU 写入无需管理员;wscript 隐藏启动无黑窗;stop 停止,uninstall 移除)
89
89
  快捷方式: Windows → 桌面 .lnk · macOS → 桌面 .command 启动器 · Linux → 桌面 .desktop 图标
90
90
 
91
91
  界面插件(安装到 <data-dir>/plugins/,服务运行中刷新浏览器即生效):
@@ -101,7 +101,7 @@ server 选项:
101
101
  --force 目标已存在时覆盖
102
102
 
103
103
  环境变量(前台与系统服务均适用):
104
- PORT / PI_WEB_CWD / PI_WEB_DATA_DIR / PI_CODING_AGENT_DIR
104
+ PI_WEB_PORT / PI_WEB_CWD / PI_WEB_DATA_DIR / PI_CODING_AGENT_DIR
105
105
  `;
106
106
 
107
107
  /** Minimum Node required by the pi SDK (its dist uses `import … with { type: "json" }`). */
@@ -258,12 +258,10 @@ function openBrowserWhenUp(url) {
258
258
  }
259
259
 
260
260
  async function startForeground(opts) {
261
- if (opts.port) process.env.PORT = opts.port;
261
+ if (opts.port) process.env.PI_WEB_PORT = opts.port;
262
262
  if (opts.cwd) process.env.PI_WEB_CWD = resolve(opts.cwd);
263
263
  if (opts.dataDir) process.env.PI_WEB_DATA_DIR = resolve(opts.dataDir);
264
- const url = `http://localhost:${String(
265
- opts.port ?? process.env.PORT ?? "8787",
266
- )}`;
264
+ const url = `http://localhost:${effectivePort(opts)}`;
267
265
  await import(pathToFileURL(SERVER_ENTRY).href);
268
266
  if (!opts.noBrowser) openBrowserWhenUp(url);
269
267
  }
@@ -307,7 +305,7 @@ function systemdUnitPath(name) {
307
305
  return `/etc/systemd/system/${name}.service`;
308
306
  }
309
307
 
310
- /** Windows: per-user config dir (%APPDATA%\pi-web-ui) holding the .cmd wrapper + task XML. */
308
+ /** Windows: per-user config dir (%APPDATA%\pi-web-ui) holding the ps1/vbs launchers + pid file. */
311
309
  function winServiceDir() {
312
310
  return join(
313
311
  process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"),
@@ -323,12 +321,17 @@ function winPs1Path(name) {
323
321
  return join(winServiceDir(), `${name}.ps1`);
324
322
  }
325
323
 
324
+ function winVbsPath(name) {
325
+ return join(winServiceDir(), `${name}.vbs`);
326
+ }
327
+
326
328
  function winTaskXmlPath(name) {
327
329
  return join(winServiceDir(), `${name}.xml`);
328
330
  }
329
331
 
330
- function winLogPath() {
331
- return join(homedir(), "pi-web-ui.log");
332
+ /** Windows log file (per service name — multiple services must not share one log). */
333
+ function winLogPath(name) {
334
+ return join(homedir(), name === "pi-web-ui" ? "pi-web-ui.log" : `pi-web-ui-${name}.log`);
332
335
  }
333
336
 
334
337
  /** True when a scheduled task with this name exists (schtasks exits 0). */
@@ -339,6 +342,38 @@ function winTaskExists(name) {
339
342
  );
340
343
  }
341
344
 
345
+ /** Windows: per-user autostart registry key (HKCU — writable without admin). */
346
+ function winRunKeyName() {
347
+ return "Software\\Microsoft\\Windows\\CurrentVersion\\Run";
348
+ }
349
+
350
+ /** True when the per-user autostart Run key value exists. */
351
+ function winRunKeyInstalled(name) {
352
+ return (
353
+ spawnSync("reg", ["query", `HKCU\\${winRunKeyName()}`, "/v", name], {
354
+ stdio: "ignore",
355
+ }).status === 0
356
+ );
357
+ }
358
+
359
+ /** Set the per-user autostart Run key value (no admin needed for HKCU). */
360
+ function winRunKeySet(name, value) {
361
+ run(
362
+ "reg",
363
+ ["add", `HKCU\\${winRunKeyName()}`, "/v", name, "/t", "REG_SZ", "/d", value, "/f"],
364
+ { silent: true },
365
+ );
366
+ }
367
+
368
+ /** Remove the per-user autostart Run key value (missing key is a no-op). */
369
+ function winRunKeyDelete(name) {
370
+ run(
371
+ "reg",
372
+ ["delete", `HKCU\\${winRunKeyName()}`, "/v", name, "/f"],
373
+ { ignoreError: true, silent: true },
374
+ );
375
+ }
376
+
342
377
  // ---------------------------------------------------------------------------
343
378
  // 桌面快捷方式(server shortcut)
344
379
  // ---------------------------------------------------------------------------
@@ -434,10 +469,10 @@ function shQuote(s) {
434
469
 
435
470
  /**
436
471
  * Windows shortcut launcher. Double-click the .lnk → this script runs hidden:
437
- * server already up → just open the browser; scheduled task installed → start
438
- * it (manageable via `server stop`); otherwise run the server in the foreground
439
- * of this hidden window and record its PID so `server stop` / `server
440
- * uninstall` can terminate it too.
472
+ * server already up → just open the browser; autostart service installed
473
+ * (HKCU Run key + wscript/VBS launcher) start it (manageable via `server
474
+ * stop`); otherwise run the server in the foreground of this hidden window and
475
+ * record its PID so `server stop` / `server uninstall` can terminate it too.
441
476
  */
442
477
  function buildWinShortcutPs1(env, cwd, taskName, url, logPath, pidPath) {
443
478
  const sets = Object.entries(env)
@@ -447,13 +482,13 @@ function buildWinShortcutPs1(env, cwd, taskName, url, logPath, pidPath) {
447
482
  return [
448
483
  "# Generated by: pi-web-ui server shortcut (rerun to change)",
449
484
  "# Runs hidden from the desktop shortcut: if the server is already up it",
450
- "# opens the browser; a scheduled task (if installed) is used; otherwise",
451
- "# the server runs in the foreground of this hidden window and its PID is",
452
- "# recorded so `server stop` / `server uninstall` can terminate it.",
485
+ "# opens the browser; the autostart service (if installed) is started;",
486
+ "# otherwise the server runs in the foreground of this hidden window and",
487
+ "# its PID is recorded so `server stop` / `server uninstall` can stop it.",
453
488
  "$ErrorActionPreference = 'Continue'",
454
489
  `$url = ${psQuote(url)}`,
455
490
  `$health = ${psQuote(url + "/api/health")}`,
456
- `$taskName = ${psQuote(taskName)}`,
491
+ `$svcName = ${psQuote(taskName)}`,
457
492
  `$pidFile = ${psQuote(pidPath)}`,
458
493
  `$log = ${psQuote(logPath)}`,
459
494
  "",
@@ -465,9 +500,10 @@ function buildWinShortcutPs1(env, cwd, taskName, url, logPath, pidPath) {
465
500
  "# 已在运行 → 直接打开浏览器",
466
501
  "if (Test-Up) { Open-Browser; exit 0 }",
467
502
  "",
468
- "# 已安装计划任务 走服务启动(server stop 可管理)",
469
- "if (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue) {",
470
- " schtasks /Run /TN $taskName | Out-Null",
503
+ "# 已安装自启服务(HKCU Run 键 + wscript 隐藏启动)→ 走服务启动(server stop 可管理)",
504
+ "$vbs = Join-Path $env:APPDATA ('pi-web-ui\\' + $svcName + '.vbs')",
505
+ "if (Test-Path $vbs) {",
506
+ " wscript.exe $vbs",
471
507
  " for ($i = 0; $i -lt 120; $i++) {",
472
508
  " Start-Sleep -Milliseconds 250",
473
509
  " if (Test-Up) { Open-Browser; exit 0 }",
@@ -496,13 +532,15 @@ function buildWinShortcutPs1(env, cwd, taskName, url, logPath, pidPath) {
496
532
 
497
533
 
498
534
  /**
499
- * Build the tiny VBS launcher the desktop .lnk invokes. Its purpose is purely to
500
- * start the .ps1 *without* creating any console host: wscript.exe has no console,
501
- * and WScript.Shell.Run(…, 0, False) launches the child hidden and returns at once
502
- * (0 = hidden window, False = don't wait). Result: double-clicking the icon never
503
- * flashes a black box and no leftover console appears in the taskbar.
535
+ * Build the tiny VBS launcher that starts a .ps1 *without* creating any console
536
+ * host: wscript.exe has no console, and WScript.Shell.Run(…, 0, False) launches
537
+ * the child hidden and returns at once (0 = hidden window, False = don't wait).
538
+ * Used by both the desktop .lnk and the logon autostart service. Note:
539
+ * `powershell -WindowStyle Hidden` alone is unreliable when Windows itself
540
+ * spawns the process (Task Scheduler) — the console window can still show;
541
+ * this launcher never creates one at all.
504
542
  */
505
- function buildWinShortcutVbs(ps1Path) {
543
+ function buildWinHiddenVbs(ps1Path) {
506
544
  const cmd = `${winPowershell()} -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "${ps1Path}"`;
507
545
  // VBScript 字符串没有 \" 转义(也没有 \uXXXX),内嵌引号必须写成 "";
508
546
  // 不能用 JSON.stringify —— 它输出 \" 会在 VBScript 里提前结束字符串(语句未结束 800A0401),
@@ -534,7 +572,7 @@ function installWinShortcut(opts) {
534
572
  cwd,
535
573
  name,
536
574
  url,
537
- winLogPath(),
575
+ winLogPath(name),
538
576
  winPidFilePath(name),
539
577
  );
540
578
  if (opts.print) {
@@ -545,7 +583,7 @@ function installWinShortcut(opts) {
545
583
  writeFileSync(ps1Path, "\uFEFF" + ps1, "utf8"); // PS 5.1 需要 BOM
546
584
  // wscript host + VBS launcher: no console window / taskbar black box on double-click.
547
585
  const vbsPath = winShortcutVbsPath(name);
548
- writeFileSync(vbsPath, "\uFEFF" + buildWinShortcutVbs(ps1Path), "utf16le"); // wscript 只认 UTF-16/ANSI,UTF-8 BOM 会报“无效字符”,中文路径用 utf16le + BOM
586
+ writeFileSync(vbsPath, "\uFEFF" + buildWinHiddenVbs(ps1Path), "utf16le"); // wscript 只认 UTF-16/ANSI,UTF-8 BOM 会报“无效字符”,中文路径用 utf16le + BOM
549
587
  // 把品牌图标准备好:复制到用户目录(.lnk 图标指向稳定路径)
550
588
  if (existsSync(APP_ICO_SOURCE)) {
551
589
  copyFileSync(APP_ICO_SOURCE, winIcoPath());
@@ -768,85 +806,38 @@ function psQuote(s) {
768
806
  }
769
807
 
770
808
  /**
771
- * Build the PowerShell launcher the scheduled task runs. Task Scheduler
772
- * launches it with -WindowStyle Hidden, so the console window is created
773
- * hidden (SW_HIDE) no black cmd window stays open while the server runs,
774
- * and there is nothing to accidentally close/kill. The script sets the env,
775
- * cd's to the workspace, then runs node in the foreground with output
776
- * appended to the log, so the task instance IS the powershell process and
777
- * `schtasks /End` still stops the whole tree.
809
+ * Build the PowerShell launcher the autostart service runs. Windows never sees
810
+ * a console from it: the HKCU Run key launches wscript.exe VBS → powershell
811
+ * (hidden), so no black box can appear. The script sets the env, cd's to the
812
+ * workspace, records its PID to <name>.pid (for `server stop`), then runs node
813
+ * inside a watchdog loop if node exits, it restarts after 10s (same
814
+ * philosophy as launchd KeepAlive / systemd Restart=always). `server stop`
815
+ * force-kills the recorded PID tree, which takes the loop down with it.
778
816
  */
779
- function buildWinStartPs1(env, cwd, logPath) {
817
+ function buildWinStartPs1(env, cwd, logPath, pidPath) {
780
818
  const sets = Object.entries(env)
781
819
  .map(([k, v]) => `$env:${k} = ${psQuote(v)}`)
782
820
  .join("\r\n");
783
821
  return [
784
822
  "# Generated by: pi-web-ui server install (rerun to change)",
785
- "# Starts the server with a hidden console window (no black cmd box).",
823
+ "# Runs the server with no console window (wscript+VBS launcher) and",
824
+ "# restarts it if it crashes (watchdog, like launchd/systemd).",
786
825
  sets,
787
826
  `Set-Location ${psQuote(cwd)}`,
788
- `& ${psQuote(NODE)} ${psQuote(SERVER_ENTRY)} *>> ${psQuote(logPath)}`,
827
+ `$PID | Out-File -Encoding ascii ${psQuote(pidPath)}`,
828
+ "try {",
829
+ " while ($true) {",
830
+ ` & ${psQuote(realNode())} ${psQuote(SERVER_ENTRY)} *>> ${psQuote(logPath)}`,
831
+ " Start-Sleep 10",
832
+ " }",
833
+ "} finally {",
834
+ ` Remove-Item ${psQuote(pidPath)} -ErrorAction SilentlyContinue`,
835
+ "}",
789
836
  "",
790
837
  ].join("\r\n");
791
838
  }
792
839
 
793
- /**
794
- * Build the Task Scheduler XML for a user task: LogonTrigger (starts at logon,
795
- * like a launchd agent — no admin needed), InteractiveToken, auto-restart on
796
- * failure. The task runs the PowerShell launcher via
797
- * powershell.exe -WindowStyle Hidden, so no console window ever appears.
798
- */
799
- function buildWinTaskXml(ps1Path, cwd) {
800
- const powershell = winPowershell();
801
- return `<?xml version="1.0" encoding="UTF-16"?>
802
- <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
803
- <RegistrationInfo>
804
- <Description>pi-web-ui — web chat for the pi coding agent (auto-start at logon)</Description>
805
- </RegistrationInfo>
806
- <Triggers>
807
- <LogonTrigger>
808
- <Enabled>true</Enabled>
809
- </LogonTrigger>
810
- </Triggers>
811
- <Principals>
812
- <Principal id="Author">
813
- <LogonType>InteractiveToken</LogonType>
814
- <RunLevel>LeastPrivilege</RunLevel>
815
- </Principal>
816
- </Principals>
817
- <Settings>
818
- <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
819
- <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
820
- <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
821
- <AllowHardTerminate>true</AllowHardTerminate>
822
- <StartWhenAvailable>false</StartWhenAvailable>
823
- <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
824
- <IdleSettings>
825
- <StopOnIdleEnd>false</StopOnIdleEnd>
826
- <RestartOnIdle>false</RestartOnIdle>
827
- </IdleSettings>
828
- <AllowStartOnDemand>true</AllowStartOnDemand>
829
- <Enabled>true</Enabled>
830
- <Hidden>false</Hidden>
831
- <RunOnlyIfIdle>false</RunOnlyIfIdle>
832
- <WakeToRun>false</WakeToRun>
833
- <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
834
- <Priority>7</Priority>
835
- <RestartOnFailure>
836
- <Interval>PT1M</Interval>
837
- <Count>3</Count>
838
- </RestartOnFailure>
839
- </Settings>
840
- <Actions Context="Author">
841
- <Exec>
842
- <Command>${esc(powershell)}</Command>
843
- <Arguments>-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "${esc(ps1Path)}"</Arguments>
844
- <WorkingDirectory>${esc(cwd)}</WorkingDirectory>
845
- </Exec>
846
- </Actions>
847
- </Task>
848
- `;
849
- }
840
+
850
841
 
851
842
  function esc(s) {
852
843
  return s
@@ -856,6 +847,35 @@ function esc(s) {
856
847
  .replace(/"/g, "&quot;");
857
848
  }
858
849
 
850
+ /** Start the autostart service now: wscript runs the VBS hidden and exits at once. */
851
+ function launchWinService(vbsPath) {
852
+ const child = spawn(winWscript(), [vbsPath], {
853
+ detached: true,
854
+ stdio: "ignore",
855
+ windowsHide: true,
856
+ });
857
+ child.unref();
858
+ }
859
+
860
+ /** Kill a running Windows instance via its PID file (whole tree). */
861
+ function stopWinInstance(name) {
862
+ const pid = winReadPid(name);
863
+ if (pid) {
864
+ if (pidAlive(pid)) {
865
+ run("taskkill", ["/PID", String(pid), "/T", "/F"], {
866
+ ignoreError: true,
867
+ silent: true,
868
+ });
869
+ // taskkill /F 异步生效:等到进程树真正退出(restart 需要避免端口竞争)
870
+ const deadline = Date.now() + 5000;
871
+ while (Date.now() < deadline && pidAlive(pid)) {
872
+ spawnSync("ping", ["-n", "2", "127.0.0.1"], { stdio: "ignore" });
873
+ }
874
+ }
875
+ rmSync(winPidFilePath(name), { force: true });
876
+ }
877
+ }
878
+
859
879
  /** Build the launchd plist XML. */
860
880
  function buildPlist(label, cwd, env) {
861
881
  const entries = Object.entries(env)
@@ -936,14 +956,21 @@ function ensureRootForSystemctl() {
936
956
  process.exit(res.status ?? 1);
937
957
  }
938
958
 
959
+ /** Resolve the effective HTTP port: --port > $PI_WEB_PORT > legacy $PORT > 8787. */
960
+ function effectivePort(opts) {
961
+ return String(opts.port ?? process.env.PI_WEB_PORT ?? process.env.PORT ?? "8787");
962
+ }
963
+
939
964
  /** Shared option normalization for install. */
940
965
  function serviceOptions(opts) {
941
966
  const name = opts.name ?? "pi-web-ui";
942
- const port = String(opts.port ?? process.env.PORT ?? "8787");
967
+ const port = effectivePort(opts);
943
968
  if (!/^\d{1,5}$/.test(port) || Number(port) < 1 || Number(port) > 65535) {
944
969
  fail(`无效端口: ${port}`);
945
970
  }
946
- const cwd = resolve(opts.cwd ?? process.env.PI_WEB_CWD ?? process.cwd());
971
+ // 服务默认以用户主目录为工作目录:安装命令的当前目录不可靠(例如 Windows 提权提示符
972
+ // 默认在 C:\WINDOWS\system32),主目录跨平台可预期;前台启动仍默认当前目录。
973
+ const cwd = resolve(opts.cwd ?? process.env.PI_WEB_CWD ?? homedir());
947
974
  if (!existsSync(cwd)) fail(`工作目录不存在: ${cwd}`);
948
975
  let dataDir;
949
976
  if (opts.dataDir) {
@@ -956,7 +983,7 @@ function serviceOptions(opts) {
956
983
 
957
984
  function serviceEnv(port, cwd, dataDir) {
958
985
  const env = {
959
- PORT: port,
986
+ PI_WEB_PORT: port,
960
987
  PI_WEB_CWD: cwd,
961
988
  };
962
989
  // Interactive Windows tasks inherit the user's PATH; only systemd/launchd
@@ -1051,59 +1078,65 @@ function installWindows(opts) {
1051
1078
  const { name, port, cwd, dataDir } = serviceOptions(opts);
1052
1079
  const env = serviceEnv(port, cwd, dataDir);
1053
1080
  const ps1Path = winPs1Path(name);
1054
- const xmlPath = winTaskXmlPath(name);
1055
- const ps1 = buildWinStartPs1(env, cwd, winLogPath());
1056
- const xml = buildWinTaskXml(ps1Path, cwd);
1081
+ const vbsPath = winVbsPath(name);
1082
+ const pidPath = winPidFilePath(name);
1083
+ const ps1 = buildWinStartPs1(env, cwd, winLogPath(name), pidPath);
1084
+ const vbs = buildWinHiddenVbs(ps1Path);
1085
+ // HKCU Run 键值:wscript.exe 以隐藏方式启动 VBS(无控制台,登录后自启)
1086
+ const runValue = `"${winWscript()}" "${vbsPath}"`;
1057
1087
  if (opts.print) {
1058
1088
  console.log(`# ${ps1Path}\n${ps1}`);
1059
- console.log(`# ${xmlPath}\n${xml}`);
1089
+ console.log(`# ${vbsPath}\n${vbs}`);
1090
+ console.log(`# 登录自启(HKCU Run 键,无需管理员)`);
1091
+ console.log(` reg add "HKCU\\${winRunKeyName()}" /v ${name} /t REG_SZ /d "${runValue}" /f`);
1060
1092
  return;
1061
1093
  }
1062
1094
  mkdirSync(dirname(ps1Path), { recursive: true });
1063
1095
  // UTF-8 with BOM: Windows PowerShell 5.1 misreads BOM-less UTF-8 as ANSI.
1064
1096
  writeFileSync(ps1Path, "\uFEFF" + ps1, "utf8");
1065
- // Remove the old-style .cmd wrapper from previous installs.
1097
+ // wscript host + VBS launcher: no console window / taskbar black box ever.
1098
+ writeFileSync(vbsPath, "\uFEFF" + vbs, "utf16le"); // wscript 只认 UTF-16/ANSI,UTF-8 BOM 会报“无效字符”
1099
+ // 迁移:移除旧版 .cmd 包装与历史计划任务(普通用户下 schtasks 无法创建,改为 Run 键)。
1066
1100
  if (existsSync(winCmdPath(name))) rmSync(winCmdPath(name));
1067
- // schtasks /Create /XML requires a UTF-16 file (with BOM).
1068
- writeFileSync(xmlPath, "\uFEFF" + xml, "utf16le");
1069
1101
  if (winTaskExists(name)) {
1102
+ run("schtasks", ["/End", "/TN", name], {
1103
+ ignoreError: true,
1104
+ silent: true,
1105
+ });
1106
+ // /End 异步生效:稍候再删任务,避免新实例与旧实例端口竞争
1107
+ spawnSync("ping", ["-n", "2", "127.0.0.1"], { stdio: "ignore" });
1070
1108
  run("schtasks", ["/Delete", "/TN", name, "/F"], {
1071
1109
  ignoreError: true,
1072
1110
  silent: true,
1073
1111
  });
1074
1112
  }
1075
- run("schtasks", ["/Create", "/TN", name, "/XML", xmlPath, "/F"]);
1076
- run("schtasks", ["/Run", "/TN", name], { ignoreError: true });
1077
- console.log(`✅ 已安装并启动计划任务 ${name}(隐藏窗口运行,无黑窗)`);
1113
+ winRunKeySet(name, runValue);
1114
+ launchWinService(vbsPath);
1115
+ console.log(`✅ 已安装并启动 ${name}(登录自启 · HKCU Run 键 · 无需管理员)`);
1116
+ console.log(` 窗口 : wscript 隐藏启动,无黑窗`);
1078
1117
  console.log(` 端口 : ${port}`);
1079
1118
  console.log(` 目录 : ${cwd}`);
1080
1119
  console.log(` 访问 : http://localhost:${port}`);
1081
- console.log(` 日志 : ${winLogPath()}`);
1082
- console.log(
1083
- ` 说明 : 登录后自启(与 launchd 用户代理一致);stop 停止,uninstall 移除`,
1084
- );
1120
+ console.log(` 日志 : ${winLogPath(name)}`);
1121
+ console.log(` 说明 : 崩溃后 10 秒自动重启(看门狗);stop 停止,uninstall 移除`);
1085
1122
  console.log(` 管理 : pi-web-ui server status|restart|stop|uninstall`);
1086
1123
  console.log(` 提示 : pi-web-ui server shortcut 可在桌面创建「一键启动」图标`);
1087
1124
  }
1088
1125
 
1089
1126
  function uninstallWindows(opts) {
1090
1127
  const name = opts.name ?? "pi-web-ui";
1128
+ winRunKeyDelete(name);
1129
+ // 历史计划任务(旧版本 install 可能注册过)
1091
1130
  if (winTaskExists(name)) {
1092
1131
  run("schtasks", ["/Delete", "/TN", name, "/F"], { ignoreError: true });
1093
1132
  }
1094
- for (const f of [winCmdPath(name), winPs1Path(name), winTaskXmlPath(name)]) {
1133
+ // 运行中的实例(服务或快捷方式启动,均记录 PID 文件)
1134
+ stopWinInstance(name);
1135
+ for (const f of [winCmdPath(name), winPs1Path(name), winVbsPath(name), winTaskXmlPath(name)]) {
1095
1136
  if (existsSync(f)) rmSync(f);
1096
1137
  }
1097
- // 快捷方式启动的隐藏实例
1098
- const pid = winReadPid(name);
1099
- if (pid && pidAlive(pid)) {
1100
- run("taskkill", ["/PID", String(pid), "/T", "/F"], {
1101
- ignoreError: true,
1102
- silent: true,
1103
- });
1104
- }
1105
1138
  removeShortcut(name);
1106
- console.log(`🗑 已卸载 ${name}(计划任务已删除,不再自启)`);
1139
+ console.log(`🗑 已卸载 ${name}(登录自启已移除,不再开机自启)`);
1107
1140
  console.log(`🗑 已移除桌面快捷方式`);
1108
1141
  }
1109
1142
 
@@ -1123,7 +1156,7 @@ function controlPath(opts) {
1123
1156
  ? resolve(process.env.PI_WEB_DATA_DIR)
1124
1157
  : join(homedir(), ".pi-web");
1125
1158
  return isWin
1126
- ? `\\\\.\\pipe\\pi-web-ui-${String(opts.port ?? process.env.PORT ?? "8787")}`
1159
+ ? `\\\\.\\pipe\\pi-web-ui-${effectivePort(opts)}`
1127
1160
  : join(dir, "pi-web-ui.sock");
1128
1161
  }
1129
1162
 
@@ -1259,81 +1292,78 @@ function controlService(action, opts) {
1259
1292
  }
1260
1293
 
1261
1294
  if (isWin) {
1262
- const exists = winTaskExists(name);
1295
+ const installed = winRunKeyInstalled(name);
1296
+ const legacy = !installed && winTaskExists(name); // 旧版计划任务安装(未迁移)
1263
1297
 
1264
1298
  if (action === "status") {
1265
1299
  const pid = winReadPid(name);
1266
1300
  const instAlive = pid && pidAlive(pid);
1267
- if (!exists) {
1268
- console.log(`${name}: 未安装(运行 pi-web-ui server install 安装)`);
1269
- if (instAlive) console.log(` 快捷方式实例 : 运行中 (PID ${pid})`);
1301
+ if (legacy) {
1302
+ console.log(`${name}: 旧版计划任务安装(未迁移)`);
1303
+ console.log(` 提示 : 重新执行 server install 可迁移到登录自启模式(删除任务,无需管理员)`);
1304
+ if (instAlive) console.log(` 实例 : 运行中 (PID ${pid})`);
1270
1305
  return;
1271
1306
  }
1272
- // Get-ScheduledTask outputs English state enums — locale-independent,
1273
- // unlike `schtasks /Query` tables on localized Windows.
1274
- const ps = spawnSync(
1275
- "powershell.exe",
1276
- [
1277
- "-NoProfile",
1278
- "-NonInteractive",
1279
- "-Command",
1280
- "$t=Get-ScheduledTask -TaskName '" +
1281
- name +
1282
- "' -ErrorAction SilentlyContinue;" +
1283
- "if(!$t){'NOT_INSTALLED';exit}" +
1284
- "$i=$t|Get-ScheduledTaskInfo;" +
1285
- "'State: '+$t.State;" +
1286
- "'LastRunTime: '+$i.LastRunTime;" +
1287
- "'LastTaskResult: '+$i.LastTaskResult",
1288
- ],
1289
- { encoding: "utf8" },
1290
- );
1291
- if (ps.status !== 0 || (ps.stdout ?? "").includes("NOT_INSTALLED")) {
1307
+ if (!installed) {
1292
1308
  console.log(`${name}: 未安装(运行 pi-web-ui server install 安装)`);
1309
+ if (instAlive) console.log(` 快捷方式实例 : 运行中 (PID ${pid})`);
1293
1310
  return;
1294
1311
  }
1295
- console.log(`${name}: 计划任务\n${(ps.stdout ?? "").trim()}`);
1296
- if (pid) {
1297
- if (instAlive) console.log(` 快捷方式实例 : 运行中 (PID ${pid})`);
1298
- else console.log(` 快捷方式实例 : 已退出 (PID ${pid})`);
1312
+ console.log(`${name}: 已安装(登录自启 · HKCU Run 键 · wscript 隐藏启动,无黑窗)`);
1313
+ if (instAlive) {
1314
+ console.log(` 运行状态 : 运行中 (PID ${pid})`);
1315
+ console.log(` 日志 : ${winLogPath(name)}`);
1316
+ } else {
1317
+ console.log(` 运行状态 : 未运行(pi-web-ui server start 启动)`);
1299
1318
  }
1300
1319
  return;
1301
1320
  }
1302
1321
 
1303
1322
  if (action === "start") {
1304
- if (!exists) fail(`${name} 不存在,请先运行 pi-web-ui server install`);
1305
- run("schtasks", ["/Run", "/TN", name]);
1323
+ if (legacy) {
1324
+ run("schtasks", ["/Run", "/TN", name]);
1325
+ console.log(`✅ 已启动 ${name}(旧版计划任务,建议重新 server install 迁移)`);
1326
+ return;
1327
+ }
1328
+ if (!installed) fail(`${name} 不存在,请先运行 pi-web-ui server install`);
1329
+ const pid = winReadPid(name);
1330
+ if (pid && pidAlive(pid)) {
1331
+ console.log(`✅ ${name} 已在运行 (PID ${pid})`);
1332
+ return;
1333
+ }
1334
+ launchWinService(winVbsPath(name));
1306
1335
  console.log(`✅ 已启动 ${name}`);
1307
1336
  return;
1308
1337
  }
1309
1338
 
1310
1339
  if (action === "restart") {
1311
- if (!exists) fail(`${name} 不存在,请先运行 pi-web-ui server install`);
1312
- run("schtasks", ["/End", "/TN", name], {
1313
- ignoreError: true,
1314
- silent: true,
1315
- });
1316
- run("schtasks", ["/Run", "/TN", name]);
1340
+ if (legacy) {
1341
+ run("schtasks", ["/End", "/TN", name], {
1342
+ ignoreError: true,
1343
+ silent: true,
1344
+ });
1345
+ run("schtasks", ["/Run", "/TN", name]);
1346
+ console.log(`✅ 已重启 ${name}(旧版计划任务,建议重新 server install 迁移)`);
1347
+ return;
1348
+ }
1349
+ if (!installed) fail(`${name} 不存在,请先运行 pi-web-ui server install`);
1350
+ stopWinInstance(name);
1351
+ launchWinService(winVbsPath(name));
1317
1352
  console.log(`✅ 已重启 ${name}`);
1318
1353
  return;
1319
1354
  }
1320
1355
 
1321
1356
  if (action === "stop") {
1322
- run("schtasks", ["/End", "/TN", name], {
1323
- ignoreError: true,
1324
- silent: true,
1325
- });
1326
- const pid = winReadPid(name);
1327
- if (pid) {
1328
- if (pidAlive(pid)) {
1329
- run("taskkill", ["/PID", String(pid), "/T", "/F"], {
1330
- ignoreError: true,
1331
- silent: true,
1332
- });
1333
- console.log(`⏹ 已停止快捷方式实例 (PID ${pid})`);
1334
- }
1335
- rmSync(winPidFilePath(name), { force: true });
1357
+ if (legacy) {
1358
+ run("schtasks", ["/End", "/TN", name], {
1359
+ ignoreError: true,
1360
+ silent: true,
1361
+ });
1362
+ stopWinInstance(name);
1363
+ console.log(`⏹ 已停止 ${name}(旧版计划任务;重新 server install 可迁移到登录自启)`);
1364
+ return;
1336
1365
  }
1366
+ stopWinInstance(name);
1337
1367
  console.log(`⏹ 已停止 ${name}(自启保留;uninstall 移除)`);
1338
1368
  return;
1339
1369
  }
@@ -34,7 +34,7 @@
34
34
 
35
35
  <key>EnvironmentVariables</key>
36
36
  <dict>
37
- <key>PORT</key>
37
+ <key>PI_WEB_PORT</key>
38
38
  <string>8787</string>
39
39
  <key>PI_WEB_CWD</key>
40
40
  <string>/Users/YOUR_USER</string>
@@ -1,5 +1,5 @@
1
1
  # pi-web-ui behind nginx at a sub-path: http://<host>:83/pi/
2
- # Backend app: http://127.0.0.1:8787 (default PORT env)
2
+ # Backend app: http://127.0.0.1:8787 (default PI_WEB_PORT env)
3
3
  #
4
4
  # IMPORTANT: pi-web-ui 0.23+ checks the WebSocket Origin against the request
5
5
  # Host (hostname AND port). Every proxied location MUST forward the original
@@ -2,26 +2,9 @@
2
2
  <!--
3
3
  pi-web-ui Task Scheduler task — Windows auto-start at logon.
4
4
 
5
- The easy way (no admin needed, generates everything for you):
6
- npm i -g pi-web-ui
7
- pi-web-ui server install --port 8787 --cwd C:\path\to\project
8
- pi-web-ui server status | restart | stop | uninstall
9
-
10
- Manual install with this template (edit the paths below first):
11
- schtasks /Create /TN "pi-web-ui" /XML pi-web-ui-task.xml /F
12
- schtasks /Run /TN "pi-web-ui"
13
-
14
- Notes:
15
- - The task runs the PowerShell launcher the CLI generates at
16
- %APPDATA%\pi-web-ui\pi-web-ui.ps1 with -WindowStyle Hidden, so the
17
- server runs with no black console window (nothing to accidentally
18
- close/kill). The ps1 sets PORT/PI_WEB_CWD, cd's to the workspace,
19
- launches node, and appends output to %USERPROFILE%\pi-web-ui.log.
20
- Preview both generated files with: pi-web-ui server install --print
21
- - Save this file as UTF-16 LE (schtasks requires it; the CLI does this
22
- automatically when it writes the task XML).
23
- - LogonTrigger = starts when you log in, same as a launchd user agent.
24
- For boot-start without login, use Docker instead (see README).
5
+ pi-web-ui server install 默认使用「登录 Run + wscript 隐藏启动」(HKCU,无需管理员、
6
+ 无黑窗),并自动迁移旧版计划任务安装。本 XML 仅保留给需要手动注册计划任务的场景
7
+ (例如管理员环境),不是默认路径。
25
8
  -->
26
9
  <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
27
10
  <RegistrationInfo>
@@ -19,7 +19,7 @@ Type=simple
19
19
  User=YOUR_USER
20
20
  # The workspace the agent operates in (read/edit/bash/write)
21
21
  WorkingDirectory=/home/YOUR_USER
22
- Environment=PORT=8787
22
+ Environment=PI_WEB_PORT=8787
23
23
  # Point at your pi config dir if it's not the default ~/.pi/agent
24
24
  #Environment=PI_CODING_AGENT_DIR=/home/YOUR_USER/.pi/agent
25
25
  ExecStart=/usr/bin/pi-web-ui
@@ -2341,7 +2341,16 @@ export class ClientSession {
2341
2341
  this.stateStore.removeProject(this.clientId, path);
2342
2342
  await this.pushProjects();
2343
2343
  }
2344
- /** Permanently delete a persisted session transcript file (history list ✕). */
2344
+ /** Permanently delete a persisted session transcript file (history list ✕).
2345
+ *
2346
+ * Deleting the ACTIVE conversation's own transcript is allowed: the session
2347
+ * first switches away to the next-latest persisted chat (or a fresh blank
2348
+ * chat when no other history exists). If the displacement could not release
2349
+ * the file (streaming / open terminals / pending wake subscription /
2350
+ * conversation cap), the deletion is aborted with a notice instead of
2351
+ * yanking the file out of a live runtime. Background conversations still
2352
+ * block deletion outright.
2353
+ */
2345
2354
  async deleteSession(path) {
2346
2355
  try {
2347
2356
  const abs = resolve(path);
@@ -2356,13 +2365,57 @@ export class ClientSession {
2356
2365
  });
2357
2366
  return;
2358
2367
  }
2359
- // Refuse to pull the file out from under a live conversation.
2360
- for (const conv of this.convs.values()) {
2361
- if (conv.session.sessionFile === abs) {
2368
+ // A live conversation may hold the target transcript. A BACKGROUND
2369
+ // conversation must still block deletion outright, but when the ACTIVE
2370
+ // conversation holds it the request can be satisfied by switching away
2371
+ // first (next-latest history chat, or a fresh blank one) and letting
2372
+ // the displacement drop the old runtime.
2373
+ const holdsTarget = (conv) => {
2374
+ const file = conv.session.sessionFile;
2375
+ return file !== undefined && resolve(file) === abs;
2376
+ };
2377
+ const holder = [...this.convs.values()].find(holdsTarget);
2378
+ if (holder && holder.id !== this.activeId) {
2379
+ this.emit({
2380
+ type: "notice",
2381
+ level: "warning",
2382
+ text: "该对话正在后台运行,请先停止或关闭该对话再删除",
2383
+ });
2384
+ return;
2385
+ }
2386
+ if (holder) {
2387
+ // Same source the history panel uses (refreshSessions): newest first.
2388
+ const infos = await SessionManager.list(this.cwd);
2389
+ const next = infos
2390
+ .filter((s) => resolve(s.path) !== abs)
2391
+ .sort((a, b) => b.modified.getTime() - a.modified.getTime())[0];
2392
+ if (next)
2393
+ await this.switchSession(next.path);
2394
+ else
2395
+ await this.newChat();
2396
+ // displaceActive() may have RETAINED the old conversation as a
2397
+ // background run (streaming, open terminals, pending wake
2398
+ // subscription, conversation cap) — in every such case the file is
2399
+ // still held, so abort instead of yanking it from a live runtime.
2400
+ // Only a conversation that is genuinely still running in the
2401
+ // background (streaming / listed) keeps the "wait for it" notice;
2402
+ // a retained-but-idle hold means the switch itself failed (cap,
2403
+ // quiesce, runtime creation) — say that instead.
2404
+ const stillHeld = [...this.convs.values()].find(holdsTarget);
2405
+ if (stillHeld) {
2406
+ let stillRunning = stillHeld.listed;
2407
+ try {
2408
+ stillRunning = stillHeld.session.isStreaming || stillRunning;
2409
+ }
2410
+ catch {
2411
+ // session being replaced — keep the listed-flag fallback
2412
+ }
2362
2413
  this.emit({
2363
2414
  type: "notice",
2364
2415
  level: "warning",
2365
- text: "该对话正在使用中,请先切换到其他对话再删除",
2416
+ text: stillRunning
2417
+ ? "对话仍在后台运行,已停止删除;请等待其结束后再删除"
2418
+ : "未能切换到其他对话,已取消删除本次操作",
2366
2419
  });
2367
2420
  return;
2368
2421
  }
@@ -7,7 +7,7 @@
7
7
  * protocol defined in protocol.ts.
8
8
  *
9
9
  * Env:
10
- * PORT HTTP port (default 8787)
10
+ * PI_WEB_PORT HTTP port (default 8787; legacy PORT also honored)
11
11
  * PI_WEB_CWD workspace the agent operates in (default: process.cwd())
12
12
  * PI_WEB_DATA_DIR where per-client UI state is stored (client-state.json,
13
13
  * default: <home>/.pi-web). Chat sessions are NOT stored here — they live
@@ -37,7 +37,7 @@ import { ensureWindowsBash, windowsBashDir } from "./ensure-bash.js";
37
37
  import { listThemes, resolveThemeFile } from "./themes.js";
38
38
  import { PluginManager, resolvePluginClientFile } from "./plugins.js";
39
39
  import { McpBridge } from "./mcp-bridge.js";
40
- const PORT = Number(process.env.PORT ?? 8787);
40
+ const PORT = Number(process.env.PI_WEB_PORT ?? process.env.PORT ?? 8787);
41
41
  const CWD = resolve(process.env.PI_WEB_CWD ?? process.cwd());
42
42
  const DATA_DIR = resolve(process.env.PI_WEB_DATA_DIR ?? join(homedir(), ".pi-web"));
43
43
  /** Bind address. Default is loopback ONLY — the service is a local personal
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-ui",
3
- "version": "0.52.0",
3
+ "version": "0.53.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
  "author": {
@@ -48,7 +48,7 @@
48
48
  "scripts": {
49
49
  "prepublishOnly": "npm run build",
50
50
  "dev": "concurrently -k -n server,web -c blue,green \"npm:dev:server\" \"npm:dev:web\"",
51
- "dev:server": "cross-env PORT=8788 PI_WEB_ALLOW_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 node --watch --import tsx server/index.ts",
51
+ "dev:server": "cross-env PI_WEB_PORT=8788 PI_WEB_ALLOW_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 node --watch --import tsx server/index.ts",
52
52
  "dev:web": "vite --config web/vite.config.ts",
53
53
  "build": "npm run build:web && npm run build:server",
54
54
  "build:web": "vite build --config web/vite.config.ts",