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 +33 -2
- package/dist/server/agent-service.js +90 -23
- package/dist/server/dsh/dsh-agent-service.js +21 -8
- package/dist/server/index.js +84 -17
- package/dist/server/terminals.js +23 -0
- package/dist/server/update-check.js +22 -0
- package/package.json +1 -1
- package/themes/dark-teal.css +4 -2
- package/web/dist/assets/{index-DePnXpq-.js → index-CYuoen3I.js} +1 -1
- package/web/dist/index.html +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,7 +8,28 @@
|
|
|
8
8
|
每个版本的内容按"实际合入该版本发布的提交"归档(以 `package.json` 的 version 变更提交为准),
|
|
9
9
|
而不是按提交日期聚类——连续快速发布的 patch 版本以此为准最准确。
|
|
10
10
|
|
|
11
|
-
## [
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
1813
|
-
|
|
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
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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:
|
|
6456
|
-
// the previous process shut down
|
|
6457
|
-
//
|
|
6458
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
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
|
}
|
package/dist/server/index.js
CHANGED
|
@@ -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
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
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
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
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"));
|
package/dist/server/terminals.js
CHANGED
|
@@ -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.
|
|
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": {
|
package/themes/dark-teal.css
CHANGED
|
@@ -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
|
|