pi-web-ui 0.2.0 → 0.2.2

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
@@ -72,7 +72,7 @@ PORT=9000 PI_WEB_CWD=/path/to/project pi-web-ui # custom port / workspace
72
72
  ```
73
73
 
74
74
  To run it in the background or auto-start on boot, use a system service —
75
- see [Deploy & auto-start on boot](#deploy--auto-start-on-boot) (systemd /
75
+ see [Deploy & auto-start on boot](#deploy--auto-start-on-boot) (systemd /
76
76
  launchd / Docker).
77
77
 
78
78
  The `pi-web-ui` command serves the built frontend and the WebSocket API from
@@ -85,6 +85,9 @@ wherever the package is installed — no repo checkout needed. It uses **your**
85
85
  - **Foreground**: press `Ctrl+C` in the terminal running it.
86
86
  - **systemd**: `sudo systemctl stop pi-web-ui`
87
87
  - **launchd**: `launchctl bootout gui/$(id -u)/com.xingshuyin.pi-web-ui`
88
+ - **Windows (scheduled task)**: `pi-web-ui server stop` (or
89
+ `schtasks /End /TN pi-web-ui`; stops the running instance, auto-start stays
90
+ until `server uninstall`)
88
91
  - **Docker**: `docker compose stop` (stop + remove the container: `docker compose down`)
89
92
 
90
93
  (Background processes should be managed by a system service, not `nohup` —
@@ -134,23 +137,28 @@ pi-web-ui server uninstall # remove the service entirely
134
137
  - **Linux** → systemd unit (auto-sudo): writes
135
138
  `/etc/systemd/system/pi-web-ui.service` and runs `systemctl enable --now`,
136
139
  logs via `journalctl -u pi-web-ui -f`.
140
+ - **Windows** → Task Scheduler (no admin): creates a user task that starts at
141
+ logon (same as a launchd agent). It runs a `.cmd` wrapper generated at
142
+ `%APPDATA%\pi-web-ui\pi-web-ui.cmd` (sets env, cd's to the workspace,
143
+ launches node, appends logs to `%USERPROFILE%\pi-web-ui.log`). The task XML
144
+ is saved next to it; restarts on failure.
137
145
  - Options: `--port` (default 8787 or `$PORT`), `--cwd` (default `$PI_WEB_CWD`
138
146
  or the current directory), `--data-dir` (sessions), `--name` (custom service
139
147
  name; on macOS the label is `com.xingshuyin.pi-web-ui`, custom names become
140
- `com.<name>.server`). `--print` previews the generated unit/plist without
141
- applying it.
148
+ `com.<name>.server`). `--print` previews the generated unit/plist/task files
149
+ without applying it.
142
150
  - Rerunning `install` with new options regenerates the config and restarts the
143
151
  service — that's how you change the port/cwd of an installed service.
144
152
 
145
153
  ## Configuration
146
154
 
147
- | Env var | Default | Description |
148
- | --- | --- | --- |
149
- | `PORT` | `8787` | HTTP/WebSocket port |
150
- | `PI_WEB_CWD` | server's cwd | The workspace directory the agent operates in (read/edit/bash/write) |
151
- | `PI_WEB_DATA_DIR` | `<cwd>/.pi-web` | Where per-client session dirs are stored |
152
- | `PI_WEB_INLINE_FILE_MAX` | `12288` (12KB) | Text attachments at or below this size are inlined into the model context; larger files are passed as path references and the model reads them on demand (saves tokens for small edits) |
153
- | `PI_CODING_AGENT_DIR` | `~/.pi/agent` | pi config dir (auth.json, models.json, skills, extensions) |
155
+ | Env var | Default | Description |
156
+ | -------------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
157
+ | `PORT` | `8787` | HTTP/WebSocket port |
158
+ | `PI_WEB_CWD` | server's cwd | The workspace directory the agent operates in (read/edit/bash/write) |
159
+ | `PI_WEB_DATA_DIR` | `<cwd>/.pi-web` | Where per-client session dirs are stored |
160
+ | `PI_WEB_INLINE_FILE_MAX` | `12288` (12KB) | Text attachments at or below this size are inlined into the model context; larger files are passed as path references and the model reads them on demand (saves tokens for small edits) |
161
+ | `PI_CODING_AGENT_DIR` | `~/.pi/agent` | pi config dir (auth.json, models.json, skills, extensions) |
154
162
 
155
163
  Example — point the agent at a project:
156
164
 
@@ -228,10 +236,10 @@ with three panes:
228
236
  `${pwd}` resolves to the agent's current working directory (the one shown in the chat
229
237
  view's file panel, changeable via set_cwd); `~` and relative paths also work. The `+`
230
238
  button at the top of the command panel creates a new entry.
231
-
232
- - **Middle the terminal**: each tab is a real PTY (your `$SHELL`); output streams live
233
- and you can type, Ctrl+C, resize, etc. exactly like a desktop terminal.
234
-
239
+ - **Middle — the terminal**: each tab is a real PTY (your `$SHELL` on
240
+ macOS/Linux; PowerShell or cmd.exe `$COMSPEC` on Windows); output
241
+ streams live and you can type, Ctrl+C, resize, etc. exactly like a desktop
242
+ terminal. Git Bash users on Windows get their `$SHELL` automatically.
235
243
  - **Right — 终端 (tabs)**: VSCode-style vertical tab strip. `+` opens a plain shell in the
236
244
  current directory. Closing a tab kills its process.
237
245
 
@@ -254,13 +262,13 @@ Server → client: `ready`, `snapshot` (full `UiState`), `tool_delta`, `notice`,
254
262
 
255
263
  ## Scripts
256
264
 
257
- | Script | What it does |
258
- | --- | --- |
259
- | `npm run dev` | server (tsx watch) + Vite dev server with WS proxy |
260
- | `npm run build` | type-check + build frontend and server |
261
- | `npm start` | run the production server (serves `web/dist`) |
262
- | `npm run typecheck` | `tsc --noEmit` for both server and web |
263
- | `node terminal-smoke-test.mjs` | WS-level terminal/commands protocol test (build first) |
265
+ | Script | What it does |
266
+ | ---------------------------------- | ------------------------------------------------------- |
267
+ | `npm run dev` | server (tsx watch) + Vite dev server with WS proxy |
268
+ | `npm run build` | type-check + build frontend and server |
269
+ | `npm start` | run the production server (serves`web/dist`) |
270
+ | `npm run typecheck` | `tsc --noEmit` for both server and web |
271
+ | `node terminal-smoke-test.mjs` | WS-level terminal/commands protocol test (build first) |
264
272
  | `node terminal-browser-test.mjs` | headless-browser E2E of the terminal view (build first) |
265
273
 
266
274
  ## Deploy & auto-start on boot
@@ -306,8 +314,36 @@ launchctl bootout gui/$(id -u)/com.xingshuyin.pi-web-ui # stop + remove auto-s
306
314
  # logs: /tmp/pi-web-ui.log, /tmp/pi-web-ui.err
307
315
  ```
308
316
 
309
- Both templates use `KeepAlive`/`Restart=on-failure` so the server survives
310
- crashes, and start at login/boot automatically.
317
+ ### Windows Task Scheduler
318
+
319
+ Easiest path (no admin, generates everything):
320
+
321
+ ```bat
322
+ npm i -g pi-web-ui
323
+ pi-web-ui server install --port 8787 --cwd C:\path\to\project
324
+ pi-web-ui server status
325
+ pi-web-ui server restart
326
+ pi-web-ui server stop :: stop the running instance (auto-start stays)
327
+ pi-web-ui server uninstall :: remove the task entirely
328
+ ```
329
+
330
+ What it does: writes `%APPDATA%\pi-web-ui\pi-web-ui.cmd` (a `.cmd` wrapper
331
+ that sets `PORT`/`PI_WEB_CWD`, cd's to the workspace, launches node and
332
+ appends output to `%USERPROFILE%\pi-web-ui.log`) plus the Task Scheduler XML,
333
+ then registers a **logon** task (`schtasks /Create /XML` — the task runs when
334
+ you log in, same as a launchd agent; no admin needed). Preview both generated
335
+ files without installing: `pi-web-ui server install --print`.
336
+
337
+ Manual alternative with `deploy/pi-web-ui-task.xml`: edit the paths, save the
338
+ file as **UTF-16 LE** (schtasks requires it), then
339
+ `schtasks /Create /TN "pi-web-ui" /XML pi-web-ui-task.xml /F` and
340
+ `schtasks /Run /TN "pi-web-ui"`.
341
+
342
+ > **Boot-start without login?** A logon task needs an interactive session, just
343
+ > like a launchd agent. For headless/always-on Windows use Docker (see above).
344
+
345
+ The three templates use `KeepAlive` / `Restart=on-failure` / `RestartOnFailure`
346
+ so the server survives crashes, and start at login/boot automatically.
311
347
 
312
348
  ## License
313
349
 
package/bin/pi-web-ui.mjs CHANGED
@@ -10,9 +10,11 @@
10
10
  * pi-web-ui server start|stop|restart|status [选项]
11
11
  *
12
12
  * 系统服务:
13
- * - macOS → launchd 用户代理,label 默认 com.xingshuyin.pi-web-ui
14
- * (--name 自定义时 com.<name>.server),无需 sudo
15
- * - Linux → systemd 单元 <name>.service(/etc/systemd/system/,自动 sudo)
13
+ * - macOS → launchd 用户代理,label 默认 com.xingshuyin.pi-web-ui
14
+ * (--name 自定义时 com.<name>.server),无需 sudo
15
+ * - Linux → systemd 单元 <name>.service(/etc/systemd/system/,自动 sudo)
16
+ * - Windows → 计划任务(Task Scheduler / schtasks,登录后自启,无需管理员),
17
+ * 包装脚本与任务 XML 生成在 %APPDATA%\pi-web-ui\
16
18
  *
17
19
  * 环境变量(前台与系统服务均适用):PORT / PI_WEB_CWD / PI_WEB_DATA_DIR /
18
20
  * PI_CODING_AGENT_DIR。
@@ -59,6 +61,9 @@ server 选项:
59
61
  为 com.xingshuyin.pi-web-ui,自定义名时为 com.<name>.server)
60
62
  --print 只打印将生成的配置文件,不实际安装
61
63
 
64
+ 平台: macOS → launchd 用户代理 · Linux → systemd · Windows → 计划任务(schtasks)
65
+ (Windows 任务登录后自启、无需管理员;stop 停止,uninstall 移除)
66
+
62
67
  环境变量(前台与系统服务均适用):
63
68
  PORT / PI_WEB_CWD / PI_WEB_DATA_DIR / PI_CODING_AGENT_DIR
64
69
  `;
@@ -146,6 +151,7 @@ async function startForeground(opts) {
146
151
 
147
152
  const isMac = process.platform === "darwin";
148
153
  const isLinux = process.platform === "linux";
154
+ const isWin = process.platform === "win32";
149
155
 
150
156
  function uid() {
151
157
  try {
@@ -155,7 +161,7 @@ function uid() {
155
161
  }
156
162
  }
157
163
 
158
- /** launchd label / systemd unit name for a service name. */
164
+ /** launchd label / systemd unit name / Windows task name for a service name. */
159
165
  function serviceLabel(name) {
160
166
  if (isMac) {
161
167
  return name === "pi-web-ui"
@@ -178,6 +184,110 @@ function systemdUnitPath(name) {
178
184
  return `/etc/systemd/system/${name}.service`;
179
185
  }
180
186
 
187
+ /** Windows: per-user config dir (%APPDATA%\pi-web-ui) holding the .cmd wrapper + task XML. */
188
+ function winServiceDir() {
189
+ return join(
190
+ process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"),
191
+ "pi-web-ui",
192
+ );
193
+ }
194
+
195
+ function winCmdPath(name) {
196
+ return join(winServiceDir(), `${name}.cmd`);
197
+ }
198
+
199
+ function winTaskXmlPath(name) {
200
+ return join(winServiceDir(), `${name}.xml`);
201
+ }
202
+
203
+ function winLogPath() {
204
+ return join(homedir(), "pi-web-ui.log");
205
+ }
206
+
207
+ /** True when a scheduled task with this name exists (schtasks exits 0). */
208
+ function winTaskExists(name) {
209
+ return (
210
+ spawnSync("schtasks", ["/Query", "/TN", name], { stdio: "ignore" })
211
+ .status === 0
212
+ );
213
+ }
214
+
215
+ /** Build the .cmd wrapper the scheduled task runs (set env → cd → launch node → log). */
216
+ function buildWinCmd(cwd, env, logPath) {
217
+ const sets = Object.entries(env)
218
+ .map(([k, v]) => `set "${k}=${v}"`)
219
+ .join("\r\n");
220
+ return [
221
+ "@echo off",
222
+ "rem Generated by: pi-web-ui server install (rerun to change)",
223
+ sets,
224
+ `cd /d "${cwd}"`,
225
+ `"${NODE}" "${SERVER_ENTRY}" >> "${logPath}" 2>&1`,
226
+ "",
227
+ ].join("\r\n");
228
+ }
229
+
230
+ /**
231
+ * Build the Task Scheduler XML for a user task: LogonTrigger (starts at logon,
232
+ * like a launchd agent — no admin needed), InteractiveToken, auto-restart on
233
+ * failure. The task runs the .cmd wrapper via cmd.exe.
234
+ */
235
+ function buildWinTaskXml(cmdPath, cwd) {
236
+ const cmdExe = join(
237
+ process.env.SystemRoot ?? "C:\\Windows",
238
+ "System32",
239
+ "cmd.exe",
240
+ );
241
+ return `<?xml version="1.0" encoding="UTF-16"?>
242
+ <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
243
+ <RegistrationInfo>
244
+ <Description>pi-web-ui — web chat for the pi coding agent (auto-start at logon)</Description>
245
+ </RegistrationInfo>
246
+ <Triggers>
247
+ <LogonTrigger>
248
+ <Enabled>true</Enabled>
249
+ </LogonTrigger>
250
+ </Triggers>
251
+ <Principals>
252
+ <Principal id="Author">
253
+ <LogonType>InteractiveToken</LogonType>
254
+ <RunLevel>LeastPrivilege</RunLevel>
255
+ </Principal>
256
+ </Principals>
257
+ <Settings>
258
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
259
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
260
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
261
+ <AllowHardTerminate>true</AllowHardTerminate>
262
+ <StartWhenAvailable>false</StartWhenAvailable>
263
+ <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
264
+ <IdleSettings>
265
+ <StopOnIdleEnd>false</StopOnIdleEnd>
266
+ <RestartOnIdle>false</RestartOnIdle>
267
+ </IdleSettings>
268
+ <AllowStartOnDemand>true</AllowStartOnDemand>
269
+ <Enabled>true</Enabled>
270
+ <Hidden>false</Hidden>
271
+ <RunOnlyIfIdle>false</RunOnlyIfIdle>
272
+ <WakeToRun>false</WakeToRun>
273
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
274
+ <Priority>7</Priority>
275
+ <RestartOnFailure>
276
+ <Interval>PT1M</Interval>
277
+ <Count>3</Count>
278
+ </RestartOnFailure>
279
+ </Settings>
280
+ <Actions Context="Author">
281
+ <Exec>
282
+ <Command>${esc(cmdExe)}</Command>
283
+ <Arguments>/c ""${esc(cmdPath)}""</Arguments>
284
+ <WorkingDirectory>${esc(cwd)}</WorkingDirectory>
285
+ </Exec>
286
+ </Actions>
287
+ </Task>
288
+ `;
289
+ }
290
+
181
291
  function esc(s) {
182
292
  return s
183
293
  .replace(/&/g, "&amp;")
@@ -288,8 +398,10 @@ function serviceEnv(port, cwd, dataDir) {
288
398
  const env = {
289
399
  PORT: port,
290
400
  PI_WEB_CWD: cwd,
291
- PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
292
401
  };
402
+ // Interactive Windows tasks inherit the user's PATH; only systemd/launchd
403
+ // run with a minimal environment that needs an explicit PATH.
404
+ if (!isWin) env.PATH = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
293
405
  if (dataDir) env.PI_WEB_DATA_DIR = dataDir;
294
406
  return env;
295
407
  }
@@ -363,6 +475,52 @@ function uninstallSystemd(opts) {
363
475
  console.log(`🗑 已卸载 ${name}.service(不再开机自启)`);
364
476
  }
365
477
 
478
+ function installWindows(opts) {
479
+ const { name, port, cwd, dataDir } = serviceOptions(opts);
480
+ const env = serviceEnv(port, cwd, dataDir);
481
+ const cmdPath = winCmdPath(name);
482
+ const xmlPath = winTaskXmlPath(name);
483
+ const cmd = buildWinCmd(cwd, env, winLogPath());
484
+ const xml = buildWinTaskXml(cmdPath, cwd);
485
+ if (opts.print) {
486
+ console.log(`# ${cmdPath}\n${cmd}`);
487
+ console.log(`# ${xmlPath}\n${xml}`);
488
+ return;
489
+ }
490
+ mkdirSync(dirname(cmdPath), { recursive: true });
491
+ writeFileSync(cmdPath, cmd);
492
+ // schtasks /Create /XML requires a UTF-16 file (with BOM).
493
+ writeFileSync(xmlPath, "\uFEFF" + xml, "utf16le");
494
+ if (winTaskExists(name)) {
495
+ run("schtasks", ["/Delete", "/TN", name, "/F"], {
496
+ ignoreError: true,
497
+ silent: true,
498
+ });
499
+ }
500
+ run("schtasks", ["/Create", "/TN", name, "/XML", xmlPath, "/F"]);
501
+ run("schtasks", ["/Run", "/TN", name], { ignoreError: true });
502
+ console.log(`✅ 已安装并启动计划任务 ${name}`);
503
+ console.log(` 端口 : ${port}`);
504
+ console.log(` 目录 : ${cwd}`);
505
+ console.log(` 访问 : http://localhost:${port}`);
506
+ console.log(` 日志 : ${winLogPath()}`);
507
+ console.log(
508
+ ` 说明 : 登录后自启(与 launchd 用户代理一致);stop 停止,uninstall 移除`,
509
+ );
510
+ console.log(` 管理 : pi-web-ui server status|restart|stop|uninstall`);
511
+ }
512
+
513
+ function uninstallWindows(opts) {
514
+ const name = opts.name ?? "pi-web-ui";
515
+ if (winTaskExists(name)) {
516
+ run("schtasks", ["/Delete", "/TN", name, "/F"], { ignoreError: true });
517
+ }
518
+ for (const f of [winCmdPath(name), winTaskXmlPath(name)]) {
519
+ if (existsSync(f)) rmSync(f);
520
+ }
521
+ console.log(`🗑 已卸载 ${name}(计划任务已删除,不再自启)`);
522
+ }
523
+
366
524
  function controlService(action, opts) {
367
525
  const name = opts.name ?? "pi-web-ui";
368
526
 
@@ -430,7 +588,74 @@ function controlService(action, opts) {
430
588
  return;
431
589
  }
432
590
 
433
- fail(`不支持的系统服务平台: ${process.platform}(仅 macOS / Linux)`);
591
+ if (isWin) {
592
+ const exists = winTaskExists(name);
593
+
594
+ if (action === "status") {
595
+ if (!exists) {
596
+ console.log(`${name}: 未安装(运行 pi-web-ui server install 安装)`);
597
+ return;
598
+ }
599
+ // Get-ScheduledTask outputs English state enums — locale-independent,
600
+ // unlike `schtasks /Query` tables on localized Windows.
601
+ const ps = spawnSync(
602
+ "powershell.exe",
603
+ [
604
+ "-NoProfile",
605
+ "-NonInteractive",
606
+ "-Command",
607
+ "$t=Get-ScheduledTask -TaskName '" +
608
+ name +
609
+ "' -ErrorAction SilentlyContinue;" +
610
+ "if(!$t){'NOT_INSTALLED';exit}" +
611
+ "$i=$t|Get-ScheduledTaskInfo;" +
612
+ "'State: '+$t.State;" +
613
+ "'LastRunTime: '+$i.LastRunTime;" +
614
+ "'LastTaskResult: '+$i.LastTaskResult",
615
+ ],
616
+ { encoding: "utf8" },
617
+ );
618
+ if (ps.status !== 0 || (ps.stdout ?? "").includes("NOT_INSTALLED")) {
619
+ console.log(`${name}: 未安装(运行 pi-web-ui server install 安装)`);
620
+ return;
621
+ }
622
+ console.log(`${name}: 计划任务\n${(ps.stdout ?? "").trim()}`);
623
+ return;
624
+ }
625
+
626
+ if (action === "start") {
627
+ if (!exists) fail(`${name} 不存在,请先运行 pi-web-ui server install`);
628
+ run("schtasks", ["/Run", "/TN", name]);
629
+ console.log(`✅ 已启动 ${name}`);
630
+ return;
631
+ }
632
+
633
+ if (action === "restart") {
634
+ if (!exists) fail(`${name} 不存在,请先运行 pi-web-ui server install`);
635
+ run("schtasks", ["/End", "/TN", name], {
636
+ ignoreError: true,
637
+ silent: true,
638
+ });
639
+ run("schtasks", ["/Run", "/TN", name]);
640
+ console.log(`✅ 已重启 ${name}`);
641
+ return;
642
+ }
643
+
644
+ if (action === "stop") {
645
+ run("schtasks", ["/End", "/TN", name], {
646
+ ignoreError: true,
647
+ silent: true,
648
+ });
649
+ console.log(`⏹ 已停止 ${name}(自启保留;uninstall 移除)`);
650
+ return;
651
+ }
652
+
653
+ fail(`未知操作: ${action}`);
654
+ }
655
+
656
+ fail(
657
+ `不支持的系统服务平台: ${process.platform}(仅 macOS / Linux / Windows)`,
658
+ );
434
659
  }
435
660
 
436
661
  async function serverCmd(argv) {
@@ -454,8 +679,10 @@ async function serverCmd(argv) {
454
679
  installLaunchd(opts);
455
680
  } else if (isLinux) {
456
681
  installSystemd(opts);
682
+ } else if (isWin) {
683
+ installWindows(opts);
457
684
  } else {
458
- fail(`不支持的系统服务平台: ${process.platform}(仅 macOS / Linux)`);
685
+ fail(`不支持的系统服务平台: ${process.platform}`);
459
686
  }
460
687
  break;
461
688
  }
@@ -464,8 +691,10 @@ async function serverCmd(argv) {
464
691
  uninstallLaunchd(opts);
465
692
  } else if (isLinux) {
466
693
  uninstallSystemd(opts);
694
+ } else if (isWin) {
695
+ uninstallWindows(opts);
467
696
  } else {
468
- fail(`不支持的系统服务平台: ${process.platform}(仅 macOS / Linux)`);
697
+ fail(`不支持的系统服务平台: ${process.platform}`);
469
698
  }
470
699
  break;
471
700
  }
@@ -0,0 +1,69 @@
1
+ <?xml version="1.0" encoding="UTF-16"?>
2
+ <!--
3
+ pi-web-ui Task Scheduler task — Windows auto-start at logon.
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 points at the .cmd wrapper the CLI generates at
16
+ %APPDATA%\pi-web-ui\pi-web-ui.cmd (it sets PORT/PI_WEB_CWD, cd's to the
17
+ workspace, launches node, and appends output to %USERPROFILE%\pi-web-ui.log).
18
+ Preview both generated files with: pi-web-ui server install --print
19
+ - Save this file as UTF-16 LE (schtasks requires it; the CLI does this
20
+ automatically when it writes the task XML).
21
+ - LogonTrigger = starts when you log in, same as a launchd user agent.
22
+ For boot-start without login, use Docker instead (see README).
23
+ -->
24
+ <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
25
+ <RegistrationInfo>
26
+ <Description>pi-web-ui — web chat for the pi coding agent (auto-start at logon)</Description>
27
+ </RegistrationInfo>
28
+ <Triggers>
29
+ <LogonTrigger>
30
+ <Enabled>true</Enabled>
31
+ </LogonTrigger>
32
+ </Triggers>
33
+ <Principals>
34
+ <Principal id="Author">
35
+ <LogonType>InteractiveToken</LogonType>
36
+ <RunLevel>LeastPrivilege</RunLevel>
37
+ </Principal>
38
+ </Principals>
39
+ <Settings>
40
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
41
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
42
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
43
+ <AllowHardTerminate>true</AllowHardTerminate>
44
+ <StartWhenAvailable>false</StartWhenAvailable>
45
+ <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
46
+ <IdleSettings>
47
+ <StopOnIdleEnd>false</StopOnIdleEnd>
48
+ <RestartOnIdle>false</RestartOnIdle>
49
+ </IdleSettings>
50
+ <AllowStartOnDemand>true</AllowStartOnDemand>
51
+ <Enabled>true</Enabled>
52
+ <Hidden>false</Hidden>
53
+ <RunOnlyIfIdle>false</RunOnlyIfIdle>
54
+ <WakeToRun>false</WakeToRun>
55
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
56
+ <Priority>7</Priority>
57
+ <RestartOnFailure>
58
+ <Interval>PT1M</Interval>
59
+ <Count>3</Count>
60
+ </RestartOnFailure>
61
+ </Settings>
62
+ <Actions Context="Author">
63
+ <Exec>
64
+ <Command>C:\Windows\System32\cmd.exe</Command>
65
+ <Arguments>/c ""%APPDATA%\pi-web-ui\pi-web-ui.cmd""</Arguments>
66
+ <WorkingDirectory>%USERPROFILE%</WorkingDirectory>
67
+ </Exec>
68
+ </Actions>
69
+ </Task>
@@ -12,7 +12,7 @@
12
12
  import { existsSync } from "node:fs";
13
13
  import { join } from "node:path";
14
14
  import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, getAgentDir, SessionManager, } from "@earendil-works/pi-coding-agent";
15
- import { serializeMessage, serializeStreamingMessage } from "./serialize.js";
15
+ import { serializeMessage, serializeStreamingMessage, } from "./serialize.js";
16
16
  import { loadCommands, saveCommandsFile, TerminalManager, } from "./terminals.js";
17
17
  const SNAPSHOT_INTERVAL_MS = 60;
18
18
  const WIDGET_REFRESH_MS = 2000;
@@ -286,7 +286,20 @@ export class ClientSession {
286
286
  snapshotTimer = null;
287
287
  sessionsTimer = null;
288
288
  version = 0;
289
- seq = 0;
289
+ /**
290
+ * Stable per-message ids: assigned once per (role, timestamp) so snapshot ids
291
+ * don't change every 60ms — changing ids would remount the whole list in
292
+ * React (collapse open thinking blocks, reset scroll, jank on long chats).
293
+ */
294
+ msgIds = new Map();
295
+ nextMsgId = 1;
296
+ /** Serialized UiMessage cache — object-reference-stable across snapshots, so
297
+ * the frontend's React.memo can skip unchanged messages entirely. */
298
+ uiMessageCache = new Map();
299
+ /** Reused when the message set didn't change, keeping state.messages
300
+ * reference-stable so the frontend can memoize derived maps. */
301
+ lastMessagesSig = "";
302
+ lastMessagesArray = [];
290
303
  queueSteering = 0;
291
304
  queueFollowUp = 0;
292
305
  disposed = false;
@@ -434,6 +447,25 @@ export class ClientSession {
434
447
  void this.pushSessions();
435
448
  }, 800);
436
449
  }
450
+ /** Serialize a persisted message with a STABLE id + cached object reference. */
451
+ serializeCached(m) {
452
+ const key = m.role === "toolResult"
453
+ ? `t:${m.toolCallId}`
454
+ : `${m.role}:${m.timestamp}`;
455
+ let n = this.msgIds.get(key);
456
+ if (n === undefined) {
457
+ n = this.nextMsgId++;
458
+ this.msgIds.set(key, n);
459
+ }
460
+ const cacheKey = `${key}#${n}`;
461
+ const cached = this.uiMessageCache.get(cacheKey);
462
+ if (cached)
463
+ return cached;
464
+ const msg = serializeMessage(m, n);
465
+ if (msg)
466
+ this.uiMessageCache.set(cacheKey, msg);
467
+ return msg;
468
+ }
437
469
  snapshot() {
438
470
  const state = this.session.agent.state;
439
471
  const model = state.model;
@@ -461,14 +493,22 @@ export class ClientSession {
461
493
  catch {
462
494
  // stats are best-effort
463
495
  }
496
+ const rawMessages = state.messages
497
+ .map((m) => this.serializeCached(m))
498
+ .filter((m) => m !== null);
499
+ // Reuse the previous array when nothing changed: the element objects are
500
+ // cached (reference-stable) anyway, and a stable array reference lets the
501
+ // frontend memoize derived maps instead of rebuilding them every 60ms.
502
+ const sig = rawMessages.map((m) => m.id).join("\u0001");
503
+ const messages = sig === this.lastMessagesSig ? this.lastMessagesArray : rawMessages;
504
+ this.lastMessagesSig = sig;
505
+ this.lastMessagesArray = rawMessages;
464
506
  return {
465
507
  clientId: this.clientId,
466
508
  cwd: this.cwd,
467
509
  sessionId: this.session.sessionId,
468
510
  sessionFile: this.session.sessionFile,
469
- messages: state.messages
470
- .map((m) => serializeMessage(m, ++this.seq))
471
- .filter((m) => m !== null),
511
+ messages,
472
512
  // The in-progress assistant message lives in state.streamingMessage
473
513
  // (the SDK only pushes it into state.messages at message_end). Surfacing
474
514
  // it here is what makes thinking + text stream into the browser at
@@ -101,8 +101,17 @@ export async function saveCommandsFile(workspaceRoot, commands) {
101
101
  return { path, error: `保存命令文件失败:${err.message}` };
102
102
  }
103
103
  }
104
- const SHELL = process.env.SHELL || "bash";
105
- const SHELL_ARGS = ["-i"];
104
+ const isWindows = process.platform === "win32";
105
+ /**
106
+ * Interactive shell for PTYs. Windows has no $SHELL: use Git Bash if the user
107
+ * has it, otherwise the console host from $COMSPEC (always set → cmd.exe).
108
+ * POSIX uses the user's login shell, falling back to bash.
109
+ */
110
+ const SHELL = isWindows
111
+ ? process.env.SHELL || process.env.COMSPEC || "powershell.exe"
112
+ : process.env.SHELL || "bash";
113
+ /** `-i` is POSIX-only; cmd.exe / powershell.exe start interactively on their own. */
114
+ const SHELL_ARGS = isWindows ? [] : ["-i"];
106
115
  /**
107
116
  * Owns one or more PTYs for a client. All output is forwarded as
108
117
  * `terminal_output` messages through the provided emit (broadcast to every
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-ui",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Web chat interface for the pi coding agent, powered by the pi SDK (@earendil-works/pi-coding-agent) — one-command run, Docker/systemd/launchd deployable",
5
5
  "license": "MIT",
6
6
  "type": "module",