pi-web-ui 0.87.0 → 0.87.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/CHANGELOG.md CHANGED
@@ -8,7 +8,28 @@
8
8
  每个版本的内容按"实际合入该版本发布的提交"归档(以 `package.json` 的 version 变更提交为准),
9
9
  而不是按提交日期聚类——连续快速发布的 patch 版本以此为准最准确。
10
10
 
11
- ## [Unreleased]
11
+ ## [0.87.2] — 2026-09-16
12
+
13
+ ### Fixed
14
+
15
+ - **关机/重启不再永久挂起**(#172)—— `disposeAll()` 卡在僵死会话/PTY/挂起句柄时进程以前永远退不出,第二次 Ctrl+C 还被吞掉。现在关机有 5 秒看门狗兜底(超时强制退出,未完成不等待);关机中再收到信号立即按信号退出(130/143)而不是吞掉;先 `terminate` 全部 WS 半开连接 + `closeAllConnections` 再 `close`,死掉的浏览器页/发不完 body 的半开请求不再拖住退出。回归:`tests/shutdown-test.mjs`(win32 下跨进程 SIGINT 到不了 handler,直接 SKIP;ubuntu CI 正常跑),已进冒烟清单。
16
+ - **没动过的空 shell 不再钉住对话** —— 点开终端 tab 自动建的那个空 shell(一次都没敲过键盘/没跑过命令/agent 没碰过)以前算“还有存活终端”,切对话/✕ 关对话时被拦截或赖在运行列表里。现在 `TerminalManager` 新增 `countBlockingLive()`(只统计存活**且用过**的 PTY:`inputChecked` 成功输入与 `noteAgentActivity` 置位,`runCommand`/ai-bash 天生即用过),pi 与 DSH 两端的对话保留(`displaceActive`/`listed`)、关闭拦截、空闲回收、项目切换回收统一切到该口径;空 shell 切走/✕ 时随对话一起释放(`removeConversation` 里 `killAll`)。回归:`terminal-smoke-test` 新增 pristine/ai-bash 口径断言。
17
+
18
+ ## [0.87.1] — 2026-09-16
19
+
20
+ ### Added
21
+
22
+ - **重启服务不再杀死进行中的对话,回来自动续上**(#171)—— 以前升级/手工重启会把在跑的 run 静默杀死,用户得去历史对话里手动找回。现在关停与 `restart_service` 都会先把仍在 streaming 的会话记下来(含 session 文件);重连后首次 attach 自动逐个重开并发送一句「继续」,从持久化上下文接着跑;恢复完视图回到原来停的那条,后台续跑的不抢焦点。没有 session 文件的才回落为黄色提醒。pi 引擎独有(DSH 暂无此记录)。
23
+
24
+ - **语音输入本地模型新增 `small` 档** —— 2.44 亿参数(约 500MB 下载),中文同音字明显少于 base;代价是 CPU 转写比 base 慢 3~4 倍。设置 → 界面插件 → 语音输入 → 本地模型,切换后点 🎤 按提示安装即可(之前下的 base/tiny 留着不碍事)。
25
+
26
+ ### Fixed
27
+
28
+ - **`npm start` 带进来的环境变量不再污染子进程**(#169)—— `npm start` 会把 `npm_config_*` / `npm_package_*` / `npm_lifecycle_*` 导出给所有子进程,服务端拉起的 shell、`pi update` 等会继承到 `npm_config_allow_scripts`,npm 在项目级安装里直接拒绝(EALLOWSCRIPTS)。现在服务端启动时一次性 scrub,unit 文件不用动,所有 spawn 路径一次修好。
29
+ - **组件更新面板把“有更新的”排前面**(#170)—— 以前按 manifest 顺序列,可用的更新常被埋在一堆“已是最新”下面。现在服务端统一排序:pi-web-ui 与 pi-core 置顶(状态无关),然后有更新的、已最新的,最后是出错的;各客户端看到同一顺序。
30
+ - **语音输入“识别成功了还弹服务端录音”** —— 点「改用服务端录音」(或自动降级)时 `abort()` 激起的 `onend` 把已识别的半截文字又收尾一次:字进了输入框,录音浮层也弹了出来。现在切服务端途中立旗吞掉多余事件;自动降级时有字优先收尾(挂起的切换自动取消)、没字才进录音。
31
+ - **语音输入服务端转写永远为空** —— 录音分片收尾误用了声道平均的 `downmix`,把整段录音按时间“平均”成 128 个采样的糊发给 Whisper,只能回空。改成按时间轴拼接;模型本身没问题(之前 `ready:true` 是真的)。
32
+ - **dark-teal 主题的分体发送按钮阴影补上**(#168,社区)—— `.split-send` 漏进了发送/停止按钮的阴影规则,hover 光晕一并补齐。
12
33
 
13
34
  ## [0.87.0] — 2026-09-16
14
35
 
@@ -17,6 +38,13 @@
17
38
  - **插件市场支持「从目录同步」**(issue #165)—— 设置面板插件市场头部新增同步入口:填一个目录文档 URL(http(s))或本地绝对路径,一键同步可安装列表(走服务端现成的 `plugin_catalog_sync` 通道:同校验、同原子写盘;可选同步后安装全部条目 / 整体替换,逐条安装结果就地回显)。成功同步过的 URL 记在浏览器 localStorage(最近 8 个),一点即重同步。第三方仓库从此不需要再为同步专门发一个占位插件。
18
39
  - **`install --catalog <url>` 与 `PI_WEB_PLUGIN_CATALOG_URL`**(issue #165)—— headless/预置场景:CLI 从目录文档同步列表并逐条安装/更新(已安装默认跳过,`--force` 更新,`--replace` 整体替换,单条失败不中断整批;`--build` / `--no-build` 对逐条同样生效);服务端启动时若配了该环境变量则自动同步一次并安装,失败只告警不阻断启动。
19
40
 
41
+ - **插件特权 DOM 授权 + 宿主 API v7**(#159)—— 插件 bundle 与主应用同源,JS 层面拦不住它碰 `document`,真正的门禁只能放在 bundle 下发处:manifest `permissions` 含 `dom` 族的插件 bundle 默认 403,需用户在设置面板逐个授权(`<dataDir>/plugin-dom.json`,整机全局,授权后 epoch+1 让浏览器重拉);只挂载锚点范围 DOM 的 `dom:anchor` 免用户授权。`apiVersion` 高于宿主则拒绝激活并提示升级 pi-web-ui。
42
+ - **输入框 `@` 提及文件/目录**(#159)—— `@` 后直接搜工作区文件,点选即追加附件 chip(inline/reference/lines);`a@b` 这类邮箱不误触,中文无空格书写(`请看@文件`)也能触发。
43
+ - **右栏文件树右键操作** —— 文件/文件夹右键可新建文件/文件夹、重命名、复制/剪切/粘贴、创建副本、删除、上传文件,行尾按钮可复制名称/路径;文件夹右键「以项目打开」切换项目。右栏 `?` 帮助条同步写明全部手势。
44
+ - **内置服务商 OAuth 登录**(#159)—— 模型配置按服务商认证能力切换 OAuth 与 API Key 两套入口:设备验证码、交互提示、取消、重连恢复与登出,凭据隔离存放。
45
+ - **改 `mcp.json` 保存即生效**(#158)—— `fs.watch` 盯配置(防抖 300ms + 指纹比对,不支持时回落轮询):规格等价的服务器沿用原实例(改一个不连带重启其它),新增/变更的先启动成功才换入,坏配置只提示一次、不断掉在跑的工具;工具表变更实时推给已有会话与新建会话。另修 `protocolVersion` 解析(握手与热加载指纹口径一致)。
46
+ - **右栏直达用户目录与桌面** —— 文件树面包屑新增 🏠(用户主目录)与 🖥️(桌面,Linux 读 XDG user-dirs,中文环境认 `~/桌面`);旧服务不提供时按钮自动隐藏。
47
+
20
48
  - **DSH 引擎复刻 dsh-web 四模式 Agent 预设** —— standard(全功能)/ PTC(`run_code` 组合面)/ minimal(单持久 shell)/ cordis(组合创作),与官方同名录同语义:新对话下拉选择、空白会话可切换、首轮发言后锁定、默认预设在设置面板配置、自建预设(`$DSH_HOME/.agent-presets`)照常上架。自定义系统提示词改走独立 host section(standard/ptc/cordis 下发,minimal 按官方语义压住)。已知限制:自建组合里写裸包名的无法挂载(launcher 式 boot 的 baseUrl 所限);问卷/技能目录钩子改挂 agent scope(旧 host 写法在新版运行时已失效)。
21
49
 
22
50
  - **未发送的输入框草稿跨刷新/切换自动恢复**(issue #166,单中心文件方案)—— 以前刷新页面、切会话再回来,输入框里没发出去的字全丢。现在打字时前端每 2s 防抖 + 失焦/切会话即时把草稿存到服务端 `<dataDir>/composer-drafts.json`(按 sessionId 键入,每会话只留最新一条;`localStorage` 同步镜像一层,兜住崩溃和关闭页面的最后一击),切会话/`newChat`/刷新重连时的全量快照把草稿带回来(本地没动过才恢复,绝不覆盖正在打的字)。发送成功、会话删除即清,30 天未更新兜底清扫。增量快照不带草稿(无 60ms 热帧开销),不进 LLM 上下文、不污染历史搜索,pi 引擎独有(DSH 暂无)。
@@ -892,7 +920,10 @@ when?, children?}`,也收 `topbar` / `settings` 这类简写别名);宿主
892
920
  - 0.35.1(2026-08-27):编辑重问保留附件(#18)+ 全窗口拖放(#19)。
893
921
  - 0.29.0(2026-08-23):全局搜索弹窗(Ctrl+K)+ 消息列表惰性窗口化。
894
922
 
895
- [Unreleased]: https://github.com/xing-shuyin/pi-web-ui/compare/v0.86.2...main
923
+ [Unreleased]: https://github.com/xing-shuyin/pi-web-ui/compare/v0.87.1...main
924
+ [0.87.2]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.87.2
925
+ [0.87.1]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.87.1
926
+ [0.87.0]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.87.0
896
927
  [0.86.2]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.86.2
897
928
  [0.86.1]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.86.1
898
929
  [0.86.0]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.86.0
@@ -24,7 +24,7 @@ import { fileURLToPath } from "node:url";
24
24
  import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashToolDefinition, createLocalBashOperations, getAgentDir, SessionManager, VERSION, } from "@earendil-works/pi-coding-agent";
25
25
  import { Type } from "typebox";
26
26
  import { BgServerTracker } from "./bg-servers.js";
27
- import { checkAll as checkAllUpdates, collectTargets, compareVersions as compareSemver, resolveNpmRegistry, } from "./update-check.js";
27
+ import { checkAll as checkAllUpdates, collectTargets, compareVersions as compareSemver, resolveNpmRegistry, sortUpdateItems, } from "./update-check.js";
28
28
  import { hasActiveSubagentRun, hasPendingWaitSubscription, shouldRetainActive } from "./wait-subscription-scan.js";
29
29
  import { removeFirstOccurrence } from "./queue-utils.js";
30
30
  import { syncPluginToolsIntoSession } from "./plugins.js";
@@ -1800,26 +1800,78 @@ export class ClientSession {
1800
1800
  };
1801
1801
  }
1802
1802
  /** Summaries of conversations currently streaming — captured at shutdown
1803
- * so the next attach can tell the user their run was interrupted. */
1803
+ * so the next attach can reopen them and continue their runs. */
1804
1804
  streamingSummaries() {
1805
1805
  const out = [];
1806
1806
  for (const conv of this.convs.values()) {
1807
- if (conv.session.isStreaming)
1808
- out.push({ title: conv.title, cwd: conv.cwd });
1807
+ if (!conv.session.isStreaming)
1808
+ continue;
1809
+ let sessionFile;
1810
+ try {
1811
+ sessionFile = conv.session.sessionFile ?? undefined;
1812
+ }
1813
+ catch {
1814
+ sessionFile = undefined;
1815
+ }
1816
+ out.push({ title: conv.title, cwd: conv.cwd, sessionFile });
1809
1817
  }
1810
1818
  return out;
1811
1819
  }
1812
- /** Tell the user about runs lost to the last server restart (once). */
1813
- notifyInterrupted(list) {
1820
+ /** Reopen sessions interrupted by the last restart and continue them.
1821
+ *
1822
+ * For each record WITH a session file: reopen it (listed, keeps running
1823
+ * when displaced) and send a short Continue prompt so the run resumes
1824
+ * from the persisted context. Records WITHOUT a file fall back to the
1825
+ * warning notice. The conversation active before the resume is restored
1826
+ * at the end, so the user lands where they were and clicks whichever
1827
+ * resumed run they want — nothing steals the view.
1828
+ *
1829
+ * Called once on the first attach after a restart (fire-and-forget from
1830
+ * the attach path — each step is internally guarded and never throws). */
1831
+ async resumeInterrupted(list) {
1814
1832
  if (!list || list.length === 0)
1815
1833
  return;
1816
- const names = list.map((r) => `「${r.title}」(${r.cwd})`).join("、");
1817
- this.pendingNotices.push({
1818
- type: "notice",
1819
- level: "warning",
1820
- text: `上次服务重启时有 ${list.length} 个进行中的对话被中断:${names}。可在历史对话中恢复继续。`,
1821
- textEn: `${list.length} running conversation(s) were interrupted by the last restart: ${names}. Resume them from History.`,
1822
- });
1834
+ const resumable = list.filter((r) => r.sessionFile);
1835
+ const orphaned = list.filter((r) => !r.sessionFile);
1836
+ if (orphaned.length > 0) {
1837
+ const names = orphaned.map((r) => `「${r.title}」(${r.cwd})`).join("");
1838
+ this.emit({
1839
+ type: "notice",
1840
+ level: "warning",
1841
+ text: `上次服务重启时有 ${orphaned.length} 个进行中的对话被中断且无法自动恢复:${names}。可在历史对话中手动恢复继续。`,
1842
+ textEn: `${orphaned.length} running conversation(s) were interrupted by the last restart and could not be resumed automatically: ${names}. Resume them manually from History.`,
1843
+ });
1844
+ }
1845
+ if (resumable.length === 0)
1846
+ return;
1847
+ const continueText = this.getLang() === "zh" ? "继续" : "Continue";
1848
+ const restoreId = this.activeId;
1849
+ for (const r of resumable) {
1850
+ try {
1851
+ this.emit({
1852
+ type: "notice",
1853
+ level: "info",
1854
+ text: `上次服务重启中断了「${r.title}」,正在自动恢复并继续。`,
1855
+ textEn: `Restart interrupted "${r.title}" — reopening it and continuing automatically.`,
1856
+ });
1857
+ await this.switchSession(r.sessionFile);
1858
+ await this.prompt(continueText);
1859
+ }
1860
+ catch {
1861
+ // switchSession/prompt already surface failures as notices;
1862
+ // one bad session must not block the rest.
1863
+ }
1864
+ }
1865
+ // Hand the view back: the user lands where they were, resumed runs
1866
+ // keep going in the background list.
1867
+ try {
1868
+ if (restoreId && this.convs.has(restoreId) && restoreId !== this.activeId) {
1869
+ await this.switchConversation(restoreId);
1870
+ }
1871
+ }
1872
+ catch {
1873
+ /* staying on the last resumed session is a fine fallback */
1874
+ }
1823
1875
  }
1824
1876
  /** Add a socket to this client's broadcast set; flushes buffered startup notices. */
1825
1877
  attachSink(send) {
@@ -3220,7 +3272,7 @@ export class ClientSession {
3220
3272
  }
3221
3273
  try {
3222
3274
  const targets = collectTargets(this.agentDir, ClientSession.currentAppVersion());
3223
- const items = await checkAllUpdates(targets, undefined, () => this.getLang(), resolveNpmRegistry(this.agentDir));
3275
+ const items = sortUpdateItems(await checkAllUpdates(targets, undefined, () => this.getLang(), resolveNpmRegistry(this.agentDir)));
3224
3276
  this.updatesAllCache = { at: Date.now(), items };
3225
3277
  this.emit({ type: "update_status_all", items });
3226
3278
  }
@@ -4597,7 +4649,9 @@ export class ClientSession {
4597
4649
  wizardRunning: conv.wizardRunning,
4598
4650
  streaming: conv.session.isStreaming,
4599
4651
  compacting: conv.session.isCompacting,
4600
- openTerminals: conv.terminals.countLive(),
4652
+ // 只看“用过”的存活终端:没动过的空 shell(点开终端 tab 自动建的
4653
+ // 那个)不保留对话,切走即随对话释放(见 countBlockingLive)。
4654
+ openTerminals: conv.terminals.countBlockingLive(),
4601
4655
  listed: conv.listed,
4602
4656
  promptedSinceActive: conv.promptedSinceActive,
4603
4657
  hasActiveSubagentRun: () => hasActiveSubagentRun({ sessionId: conv.session.sessionFile }),
@@ -5032,7 +5086,8 @@ export class ClientSession {
5032
5086
  reviewing: conv.goal.reviewing,
5033
5087
  wizardRunning: conv.wizardRunning,
5034
5088
  streaming: false,
5035
- openTerminals: conv.terminals.countLive(),
5089
+ // 与 displaceActive 同口径:只看“用过”的存活终端。
5090
+ openTerminals: conv.terminals.countBlockingLive(),
5036
5091
  listed: false,
5037
5092
  promptedSinceActive: false,
5038
5093
  hasActiveSubagentRun: () => hasActiveSubagentRun({ sessionId: conv.session.sessionFile }),
@@ -5103,7 +5158,7 @@ export class ClientSession {
5103
5158
  });
5104
5159
  return;
5105
5160
  }
5106
- if (conv.terminals.countLive() > 0) {
5161
+ if (conv.terminals.countBlockingLive() > 0) {
5107
5162
  this.emit({
5108
5163
  type: "notice",
5109
5164
  level: "warning",
@@ -5112,6 +5167,8 @@ export class ClientSession {
5112
5167
  });
5113
5168
  return;
5114
5169
  }
5170
+ // 没动过的空 shell(点开终端 tab 自动建的那个)不拦截:随对话一起释放
5171
+ // (removeConversation 里 killAll)。
5115
5172
  if (shouldRetainActive({
5116
5173
  reviewing: conv.goal.reviewing,
5117
5174
  wizardRunning: conv.wizardRunning,
@@ -5364,7 +5421,8 @@ export class ClientSession {
5364
5421
  reviewing: conv.goal.reviewing,
5365
5422
  wizardRunning: conv.wizardRunning,
5366
5423
  streaming: false,
5367
- openTerminals: conv.terminals.countLive(),
5424
+ // 与 displaceActive 同口径:只看“用过”的存活终端。
5425
+ openTerminals: conv.terminals.countBlockingLive(),
5368
5426
  listed: false,
5369
5427
  promptedSinceActive: false,
5370
5428
  hasActiveSubagentRun: () => hasActiveSubagentRun({ sessionId: conv.session.sessionFile }),
@@ -6452,10 +6510,11 @@ export class AgentService {
6452
6510
  }
6453
6511
  }
6454
6512
  }
6455
- // First attach after a restart: report runs that were interrupted when
6456
- // the previous process shut down (consumed once, then cleared). Queue
6457
- // BEFORE attachSink so the notice rides the initial pending-notice flush.
6458
- cs.notifyInterrupted(this.stateStore.takeInterrupted(clientId));
6513
+ // First attach after a restart: reopen sessions that were streaming
6514
+ // when the previous process shut down and continue them (consumed
6515
+ // once, then cleared). Fire-and-forget AFTER attachSink + hooks:
6516
+ // resume emits directly to sinks and needs the owner guards.
6517
+ // Progress arrives over the socket as usual.
6459
6518
  cs.attachSink(send);
6460
6519
  // Forward hooks (set once by index.ts) to every session.
6461
6520
  cs.onQuit = this.onQuit;
@@ -6479,6 +6538,7 @@ export class AgentService {
6479
6538
  // notifyCwd 幂等去重;此后 set_cwd 成功时由 cs.onCwdChanged 继续驱动。
6480
6539
  cs.onCwdChanged = (abs, roots) => this.onClientCwdChanged?.(abs, roots);
6481
6540
  this.onClientCwdChanged?.(cs.cwd, cs.workspaceRoots);
6541
+ void cs.resumeInterrupted(this.stateStore.takeInterrupted(clientId));
6482
6542
  return cs;
6483
6543
  }
6484
6544
  /** 插件 AI 工具集合变化(注册/注销)时由 index.ts 触发:推送到所有客户端的全部会话。 */
@@ -6541,7 +6601,11 @@ export class AgentService {
6541
6601
  }
6542
6602
  return fallback;
6543
6603
  }
6544
- async disposeAll() {
6604
+ /** Snapshot still-streaming conversations for post-restart resume.
6605
+ * Called during graceful shutdown AND from the restart_service handler
6606
+ * (which exits without shutdown under systemd — without this, the
6607
+ * interrupted-run record would silently never be written there). */
6608
+ recordInterruptedRuns() {
6545
6609
  // Record still-streaming conversations BEFORE tearing anything down, so
6546
6610
  // the next attach can tell the user what was lost (SIGTERM / update).
6547
6611
  // eslint-disable-next-line unicorn/no-useless-spread -- snapshot: handlers may unsubscribe mid-emit
@@ -6556,6 +6620,9 @@ export class AgentService {
6556
6620
  // best effort — never block shutdown on bookkeeping
6557
6621
  }
6558
6622
  }
6623
+ }
6624
+ async disposeAll() {
6625
+ this.recordInterruptedRuns();
6559
6626
  const all = [...this.clients.values()];
6560
6627
  this.clients.clear();
6561
6628
  await Promise.all(all.map((cs) => cs.dispose()));
@@ -399,7 +399,8 @@ export class DshClientSession {
399
399
  continue;
400
400
  if (conv.isStreaming)
401
401
  continue;
402
- if (conv.terminals.list().length > 0)
402
+ // 只看“用过”的存活终端:没动过的空 shell 不阻止空闲回收。
403
+ if (conv.terminals.countBlockingLive() > 0)
403
404
  continue;
404
405
  const idle = now - conv.lastEventAt;
405
406
  const limit = conv.listed ? DshClientSession.CONV_RECLAIM_LISTED_IDLE_MS : DshClientSession.CONV_RECLAIM_IDLE_MS;
@@ -1402,7 +1403,7 @@ export class DshClientSession {
1402
1403
  }
1403
1404
  // 旧对话保留(listed 生命周期简化:不主动移除)。
1404
1405
  const prevModel = this.model;
1405
- active.listed = active.isStreaming || active.terminals.list().length > 0 || active.promptedSinceActive;
1406
+ active.listed = active.isStreaming || active.terminals.countBlockingLive() > 0 || active.promptedSinceActive;
1406
1407
  const conv = this.addConversation(`chat-${randomUUID().slice(0, 12)}`, this.cwd, false, preset);
1407
1408
  this.activeId = conv.id;
1408
1409
  this.model = prevModel;
@@ -1417,7 +1418,8 @@ export class DshClientSession {
1417
1418
  if (!this.convs.has(id) || id === this.activeId)
1418
1419
  return;
1419
1420
  const prev = this.conv;
1420
- prev.listed = prev.isStreaming || prev.terminals.list().length > 0 || prev.promptedSinceActive;
1421
+ // 只看“用过”的存活终端:没动过的空 shell 不钉住 listed(与 pi displaceActive 同口径)。
1422
+ prev.listed = prev.isStreaming || prev.terminals.countBlockingLive() > 0 || prev.promptedSinceActive;
1421
1423
  this.activeId = id;
1422
1424
  // 后台列表可能属于另一项目 → 切会话同时切工作区(与 pi 一致:文件树/
1423
1425
  // 会话历史/最近项目跟着走)。DSH 单 runtime 换 cwd → 异步重启。
@@ -2112,7 +2114,7 @@ export class DshClientSession {
2112
2114
  });
2113
2115
  return;
2114
2116
  }
2115
- if (conv.terminals.list().length > 0 && !force) {
2117
+ if (conv.terminals.countBlockingLive() > 0 && !force) {
2116
2118
  this.emit({
2117
2119
  type: "notice",
2118
2120
  level: "warning",
@@ -2121,6 +2123,7 @@ export class DshClientSession {
2121
2123
  });
2122
2124
  return;
2123
2125
  }
2126
+ // 没动过的空 shell(点开终端 tab 自动建的那个)不拦截:随对话一起释放。
2124
2127
  // force + active:先让出 active(切到其他对话或新建),再移除。
2125
2128
  if (id === this.activeId) {
2126
2129
  const other = [...this.convs.values()].find((c) => c.id !== id);
@@ -2188,7 +2191,8 @@ export class DshClientSession {
2188
2191
  }
2189
2192
  }
2190
2193
  const prev = this.conv;
2191
- prev.listed = prev.isStreaming || prev.terminals.list().length > 0 || prev.promptedSinceActive;
2194
+ // 只看“用过”的存活终端:没动过的空 shell 不钉住 listed(与 pi displaceActive 同口径)。
2195
+ prev.listed = prev.isStreaming || prev.terminals.countBlockingLive() > 0 || prev.promptedSinceActive;
2192
2196
  const conv = this.addConversation(sessionId, this.cwd, true);
2193
2197
  conv.fromDisk = true; // 磁盘回放 → prompt 时 fork
2194
2198
  this.activeId = conv.id;
@@ -3855,7 +3859,8 @@ export class DshClientSession {
3855
3859
  })
3856
3860
  .join("\n");
3857
3861
  const prev = this.conv;
3858
- prev.listed = prev.isStreaming || prev.terminals.list().length > 0 || prev.promptedSinceActive;
3862
+ // 只看“用过”的存活终端:没动过的空 shell 不钉住 listed(与 pi displaceActive 同口径)。
3863
+ prev.listed = prev.isStreaming || prev.terminals.countBlockingLive() > 0 || prev.promptedSinceActive;
3859
3864
  this.activeId = fresh.id;
3860
3865
  // 编辑后的提问本身在 prompt 里;历史作为附加上下文(首条 prompt)。
3861
3866
  const headText = contextNote.trim()
@@ -3933,7 +3938,10 @@ export class DshClientSession {
3933
3938
  // (与 pi 的 displaceActive 语义一致);空白的直接弃(不列)。
3934
3939
  const prev = this.conv;
3935
3940
  prev.listed =
3936
- prev.isStreaming || prev.terminals.list().length > 0 || prev.promptedSinceActive || prev.messages.length > 0;
3941
+ prev.isStreaming ||
3942
+ prev.terminals.countBlockingLive() > 0 ||
3943
+ prev.promptedSinceActive ||
3944
+ prev.messages.length > 0;
3937
3945
  // 新项目 → 新会话。
3938
3946
  this.activeId = this.addConversation(`web-${randomUUID().slice(0, 12)}`, abs, false).id;
3939
3947
  // 旧项目非活跃 conversation 回收(pi 的 displaceActive 语义:切走后
@@ -3948,7 +3956,8 @@ export class DshClientSession {
3948
3956
  continue;
3949
3957
  if (c.isStreaming)
3950
3958
  continue;
3951
- if (c.terminals.list().length > 0)
3959
+ // 只看“用过”的存活终端:没动过的空 shell 不阻止项目切换时的旧会话回收。
3960
+ if (c.terminals.countBlockingLive() > 0)
3952
3961
  continue;
3953
3962
  this.removeConversation(id);
3954
3963
  }
@@ -4228,4 +4237,8 @@ export class DshAgentService {
4228
4237
  }
4229
4238
  this.clients.clear();
4230
4239
  }
4240
+ /** dsh engine keeps no interrupted-run record — resume is pi-only. */
4241
+ recordInterruptedRuns() {
4242
+ /* no-op */
4243
+ }
4231
4244
  }
@@ -45,6 +45,20 @@ import { PluginInstaller } from "./plugin-installer.js";
45
45
  import { syncPluginCatalog } from "./plugin-catalog-sync.js";
46
46
  import { McpBridge } from "./mcp-bridge.js";
47
47
  import { createMcpHotReload } from "./mcp-hot-reload.js";
48
+ /** Strip npm-injected env (`npm start` exports `npm_config_*` / `npm_package_*` /
49
+ * `npm_lifecycle_*` into every child). Anything this server spawns — shells,
50
+ * `pi update` — would otherwise inherit `npm_config_allow_scripts`, which npm
51
+ * maps to its env config layer and rejects in project-scoped installs
52
+ * (EALLOWSCRIPTS). The unit keeps `npm ci && npm run build && npm start`;
53
+ * this is the single scrub point so no wrapper is needed. */
54
+ for (const k of Object.keys(process.env)) {
55
+ if (k === "npm_config_allow_scripts" ||
56
+ k.startsWith("npm_config_") ||
57
+ k.startsWith("npm_package_") ||
58
+ k.startsWith("npm_lifecycle_")) {
59
+ delete process.env[k];
60
+ }
61
+ }
48
62
  /** 从 CLI 参数中取 flag 值:支持 --flag value 与 --flag=value 两种写法。
49
63
  * 让 `node dist/server/index.js --host 0.0.0.0 --port 9000` 这类直接启动也能生效,
50
64
  * 而不只是经由 bin/pi-web-ui.mjs 的 env 转发。bin 仍是主入口,此处仅作兜底。 */
@@ -1209,9 +1223,18 @@ wss.on("connection", (ws) => {
1209
1223
  send({
1210
1224
  type: "notice",
1211
1225
  level: "info",
1212
- text: "正在重启服务…页面会在服务恢复后自动重连。",
1213
- textEn: "Restarting the service… this page reconnects once it is back.",
1226
+ text: "正在重启服务…页面会在服务恢复后自动重连,进行中的对话会自动恢复并继续。",
1227
+ textEn: "Restarting the service… this page reconnects once it is back; running conversations resume automatically.",
1214
1228
  });
1229
+ // Record streaming runs BEFORE exiting: under systemd this path
1230
+ // exits via process.exit without shutdown(), so without this the
1231
+ // post-restart resume would find nothing to continue.
1232
+ try {
1233
+ service.recordInterruptedRuns();
1234
+ }
1235
+ catch {
1236
+ /* best effort — never block the restart on bookkeeping */
1237
+ }
1215
1238
  // Let the notice (and this socket's backlog) flush before we go down.
1216
1239
  setTimeout(() => void scheduleQuit(), 400);
1217
1240
  break;
@@ -1732,22 +1755,66 @@ if (BOOT_CATALOG_URL) {
1732
1755
  // Local control socket (status / quiesce / unquiesce) — same data dir the
1733
1756
  // CLI uses, so `pi-web-ui server status|quiesce|unquiesce` just works.
1734
1757
  const stopControl = startControlServer({ service, dataDir: DATA_DIR, port: PORT });
1758
+ /**
1759
+ * Graceful shutdown budget (issue #172): disposeAll() can hang forever on a
1760
+ * stuck session runtime / PTY / pending handle, and the process would then
1761
+ * sit forever with no way out. The watchdog guarantees the process is gone
1762
+ * within this long no matter what — unref'd so a clean shutdown never
1763
+ * waits on it.
1764
+ */
1765
+ const SHUTDOWN_FORCE_EXIT_MS = 5000;
1735
1766
  let shuttingDown = false;
1736
- async function shutdown() {
1737
- if (shuttingDown)
1738
- return;
1767
+ /**
1768
+ * SIGINT / SIGTERM handler. A second signal while a shutdown is already
1769
+ * running exits immediately (130 = killed by SIGINT, 143 = SIGTERM) instead
1770
+ * of being swallowed by the shuttingDown guard — previously a hung first
1771
+ * shutdown made Ctrl+C look completely dead (issue #172).
1772
+ */
1773
+ async function shutdown(signal = "SIGINT") {
1774
+ if (shuttingDown) {
1775
+ console.log("\n再次收到中断信号,强制退出…");
1776
+ process.exit(signal === "SIGTERM" ? 143 : 130);
1777
+ }
1739
1778
  shuttingDown = true;
1740
1779
  console.log("\nshutting down…");
1741
- clearInterval(heartbeatTimer);
1742
- stopControl();
1743
- pluginMgr.dispose();
1744
- pluginInstaller.dispose();
1745
- mcpHotReload.dispose();
1746
- mcpBridge.dispose();
1747
- await service.disposeAll();
1748
- wss.close();
1749
- httpServer.close();
1750
- process.exit(0);
1780
+ const forceExitTimer = setTimeout(() => {
1781
+ console.error("shutdown 超时仍未完成,强制退出…");
1782
+ process.exit(1);
1783
+ }, SHUTDOWN_FORCE_EXIT_MS);
1784
+ forceExitTimer.unref();
1785
+ let code = 0;
1786
+ try {
1787
+ clearInterval(heartbeatTimer);
1788
+ stopControl();
1789
+ pluginMgr.dispose();
1790
+ pluginInstaller.dispose();
1791
+ mcpHotReload.dispose();
1792
+ mcpBridge.dispose();
1793
+ await service.disposeAll();
1794
+ // Don't let dead browsers hold the exit open: half-open WebSocket /
1795
+ // keep-alive HTTP connections (e.g. test clients killed without
1796
+ // closing) would otherwise keep close() from ever finishing — drop
1797
+ // them first so shutdown stays prompt.
1798
+ for (const ws of wss.clients) {
1799
+ try {
1800
+ ws.terminate();
1801
+ }
1802
+ catch {
1803
+ /* already gone */
1804
+ }
1805
+ }
1806
+ wss.close();
1807
+ httpServer.closeAllConnections();
1808
+ httpServer.close();
1809
+ }
1810
+ catch (err) {
1811
+ code = 1;
1812
+ console.error("shutdown 释放资源时出错:", err);
1813
+ }
1814
+ finally {
1815
+ clearTimeout(forceExitTimer);
1816
+ process.exit(code);
1817
+ }
1751
1818
  }
1752
- process.on("SIGINT", () => void shutdown());
1753
- process.on("SIGTERM", () => void shutdown());
1819
+ process.on("SIGINT", () => void shutdown("SIGINT"));
1820
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
@@ -863,6 +863,10 @@ export class TerminalManager {
863
863
  idleTimer: null,
864
864
  watches: [],
865
865
  agentBash,
866
+ // 有命令的终端(runCommand)与 ai-bash 天生就是“用过”的;裸 shell
867
+ // 从 pristine 开始,第一次输入/agent 触碰时才置位(见 inputChecked /
868
+ // noteAgentActivity)。
869
+ used: agentBash || command !== undefined,
866
870
  locale,
867
871
  };
868
872
  this.terms.set(id, entry);
@@ -901,6 +905,7 @@ export class TerminalManager {
901
905
  if (!entry || entry.exited)
902
906
  return;
903
907
  entry.agentTouched = true;
908
+ entry.used = true;
904
909
  entry.lastActivityAt = Date.now();
905
910
  this.armIdleWatch(entry);
906
911
  }
@@ -1065,6 +1070,21 @@ export class TerminalManager {
1065
1070
  n++;
1066
1071
  return n;
1067
1072
  }
1073
+ /** Count of LIVE terminals that were actually used (typed into / ran a
1074
+ * command / touched by the agent / ai-bash). Pristine shells — e.g. the
1075
+ * one auto-created when the user opens the terminal tab but never types
1076
+ * anything — do NOT count: they hold no foreground work and no shell
1077
+ * state worth protecting, so they neither retain the conversation nor
1078
+ * block its dismissal (they are killed together with the conversation).
1079
+ * Retained-output history never counts either (same as countLive).
1080
+ * 存活且“用过”的终端数——对话保留/关闭拦截只看这个口径。 */
1081
+ countBlockingLive() {
1082
+ let n = 0;
1083
+ for (const entry of this.terms.values())
1084
+ if (!entry.exited && entry.used)
1085
+ n++;
1086
+ return n;
1087
+ }
1068
1088
  emitList() {
1069
1089
  this.emit({ type: "terminal_list", terminals: this.list() });
1070
1090
  }
@@ -1116,10 +1136,13 @@ export class TerminalManager {
1116
1136
  if (!entry || entry.exited)
1117
1137
  return pick(this.lang?.() ?? "en", "终端不存在或进程已退出", "Terminal not found or its process has exited", "terminals.not.found.exited");
1118
1138
  // 已武装的纪元里任何人(含用户手动敲键盘)写了输入都算新活动,重置倒计时。
1139
+ // 成功的输入同时把终端标为“用过”(见 used):动过的 shell 才参与对话保留/
1140
+ // 关闭拦截,没动过的空 shell 不算。
1119
1141
  entry.lastActivityAt = Date.now();
1120
1142
  if (entry.idleTimer)
1121
1143
  this.armIdleWatch(entry);
1122
1144
  entry.pty.write(data);
1145
+ entry.used = true;
1123
1146
  return null;
1124
1147
  }
1125
1148
  key(id, key, modifiers = {}) {
@@ -407,3 +407,25 @@ registryConfig) {
407
407
  await Promise.all(Array.from({ length: Math.min(CONCURRENCY, targets.length) }, worker));
408
408
  return results;
409
409
  }
410
+ /** Display order for the Component Updates panel: pi-web-ui and pi-core
411
+ * pinned at the top regardless of state, then out-of-date packages, then
412
+ * up-to-date ones, errors last. Stable within each bucket (Array.sort is
413
+ * stable) so registry order survives ties. */
414
+ export function sortUpdateItems(items) {
415
+ const kindRank = (k) => (k === "webui" ? 0 : k === "pi-core" ? 1 : 2);
416
+ return [...items].sort((a, b) => {
417
+ const ka = kindRank(a.kind);
418
+ const kb = kindRank(b.kind);
419
+ if (ka !== kb)
420
+ return ka - kb;
421
+ const ea = a.error ? 1 : 0;
422
+ const eb = b.error ? 1 : 0;
423
+ if (ea !== eb)
424
+ return ea - eb;
425
+ const sa = a.upToDate ? 1 : 0;
426
+ const sb = b.upToDate ? 1 : 0;
427
+ if (sa !== sb)
428
+ return sa - sb;
429
+ return 0;
430
+ });
431
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-ui",
3
- "version": "0.87.0",
3
+ "version": "0.87.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
  "author": {
@@ -218,11 +218,13 @@
218
218
 
219
219
  /* 输入框发送\停止按钮阴影 */
220
220
  .inputbox .btn.send,
221
- .inputbox .btn.stop {
221
+ .inputbox .btn.stop,
222
+ .inputbox .split-send {
222
223
  box-shadow: 0px 2px 14px #5dc7b040;
223
224
  }
224
225
  .inputbox .btn.send:hover:not(:disabled),
225
- .inputbox .btn.stop:hover:not(:disabled) {
226
+ .inputbox .btn.stop:hover:not(:disabled),
227
+ .inputbox .split-send:hover {
226
228
  box-shadow: 0 8px 22px #5dc7b040;
227
229
  }
228
230