pi-web-ui 0.95.0 → 0.96.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/CHANGELOG.md +42 -1
- package/bin/pi-web-ui.mjs +159 -31
- package/dist/server/agent-service.js +880 -240
- package/dist/server/approval-rules.js +56 -10
- package/dist/server/attachment-store.js +21 -4
- package/dist/server/attachments.js +30 -3
- package/dist/server/bg-servers.js +45 -7
- package/dist/server/claim-files-tool.js +4 -8
- package/dist/server/claim-store.js +4 -2
- package/dist/server/client-state.js +63 -14
- package/dist/server/compact-context-tool.js +6 -5
- package/dist/server/composer-drafts.js +9 -0
- package/dist/server/control-socket.js +57 -28
- package/dist/server/conversation-read-tool.js +7 -17
- package/dist/server/dangling-tools.js +72 -5
- package/dist/server/delegate-task.js +25 -28
- package/dist/server/dsh/dsh-agent-service.js +19 -6
- package/dist/server/edit-soft-tool.js +14 -11
- package/dist/server/eval-tool.js +67 -14
- package/dist/server/file-archives.js +16 -5
- package/dist/server/files-service.js +30 -11
- package/dist/server/goal-service.js +141 -19
- package/dist/server/hashline-engine.js +59 -21
- package/dist/server/host-guard.js +94 -0
- package/dist/server/i18n.js +3 -3
- package/dist/server/index.js +306 -78
- package/dist/server/lsp-tool.js +752 -60
- package/dist/server/mcp-bridge.js +47 -3
- package/dist/server/model-admin.js +94 -34
- package/dist/server/office-parse.js +96 -30
- package/dist/server/patch-tool.js +9 -15
- package/dist/server/permission-preset.js +25 -0
- package/dist/server/plan-manager.js +11 -1
- package/dist/server/plugin-catalog-sync.js +36 -19
- package/dist/server/plugin-catalog.js +11 -4
- package/dist/server/plugin-facilities.js +92 -21
- package/dist/server/plugin-llm.js +71 -65
- package/dist/server/plugin-project.js +70 -2
- package/dist/server/plugin-updater.js +115 -12
- package/dist/server/plugins.js +205 -44
- package/dist/server/present-files-tool.js +9 -12
- package/dist/server/process-utils.js +4 -0
- package/dist/server/prompt-composer.js +9 -0
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/read-tool.js +69 -30
- package/dist/server/resolve-global-sdk.js +30 -16
- package/dist/server/schedule-agent-tool.js +12 -15
- package/dist/server/sdk-origin.js +18 -2
- package/dist/server/serialize.js +108 -21
- package/dist/server/settings-service.js +4 -2
- package/dist/server/skill-tool.js +5 -7
- package/dist/server/subagent-templates.js +51 -0
- package/dist/server/subagents.js +48 -72
- package/dist/server/terminals.js +181 -69
- package/dist/server/tool-manager.js +24 -7
- package/dist/server/tool-overrides.js +59 -0
- package/dist/server/update-check.js +28 -1
- package/dist/server/uploads.js +17 -2
- package/dist/server/wait-subscription-scan.js +18 -21
- package/dist/server/ws-client-id.js +29 -0
- package/dist/server/ws-pending-queue.js +60 -0
- package/extensions/webui.ts +60 -2
- package/package.json +4 -2
- package/plugins/catalog.json +20 -0
- package/themes/aetheris.css +457 -0
- package/themes/zhupi-dark.css +658 -0
- package/themes/zhupi.css +711 -0
- package/web/dist/assets/{TerminalPanel-DlGBF2MD.js → TerminalPanel-DDChcYOh.js} +1 -1
- package/web/dist/assets/index-Do9RgJC3.js +366 -0
- package/web/dist/assets/index-DryOsILO.css +1 -0
- package/web/dist/assets/{markdown-eUQn_o9D.js → markdown-DXwnfD9T.js} +1 -1
- package/web/dist/index.html +3 -3
- package/web/dist/assets/index-BXnCQL9p.js +0 -364
- package/web/dist/assets/index-RpIGFyi_.css +0 -1
package/dist/server/terminals.js
CHANGED
|
@@ -24,7 +24,7 @@ import "./patch-node-pty.js";
|
|
|
24
24
|
import { spawn } from "node-pty";
|
|
25
25
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
26
26
|
import { Type } from "typebox";
|
|
27
|
-
import {
|
|
27
|
+
import { pick } from "./i18n.js";
|
|
28
28
|
/** Location of the command list for a project: <workspaceRoot>/.pi/commands.json */
|
|
29
29
|
export function commandsFilePath(workspaceRoot) {
|
|
30
30
|
return join(workspaceRoot, ".pi", "commands.json");
|
|
@@ -147,8 +147,10 @@ export function terminalIdleNotifyLines() {
|
|
|
147
147
|
/** 哨兵行:命令执行完后由 shell 打印,携带真实退出码。正则只匹配数字,
|
|
148
148
|
* 因此不会误匹配回显里的 printf 格式串 `[pi-exit:%s]`。 */
|
|
149
149
|
const BASH_SENTINEL_RE = /\[pi-exit:(\d+)\]/g;
|
|
150
|
+
/** bash 阻塞循环 collected 的累积上限:超限丢最旧一半(见 bash 工具执行循环)。 */
|
|
151
|
+
const BASH_COLLECT_MAX = 2 * 1024 * 1024;
|
|
150
152
|
/**
|
|
151
|
-
*
|
|
153
|
+
* 把任意命令(含多行脚本)构造成一行交互 shell 命令:执行 + 捕获退出码。
|
|
152
154
|
*
|
|
153
155
|
* 单行很关键:整行先被 shell 完整解析再执行,命令中途读 stdin 也不会吃掉
|
|
154
156
|
* 后续哨兵;也避开交互 shell 的 bracketed-paste 对多行输入的特殊处理。
|
|
@@ -156,15 +158,6 @@ const BASH_SENTINEL_RE = /\[pi-exit:(\d+)\]/g;
|
|
|
156
158
|
*/
|
|
157
159
|
export function buildTerminalBashLine(command, tailFile) {
|
|
158
160
|
const trimmed = command.replace(/\s+$/, "");
|
|
159
|
-
let body = trimmed;
|
|
160
|
-
if (trimmed.includes("\n")) {
|
|
161
|
-
body = `eval $'${trimmed
|
|
162
|
-
.replace(/\\/g, "\\\\")
|
|
163
|
-
.replace(/'/g, "\\'")
|
|
164
|
-
.replace(/\r/g, "\\r")
|
|
165
|
-
.replace(/\n/g, "\\n")
|
|
166
|
-
.replace(/\t/g, "\\t")}'`;
|
|
167
|
-
}
|
|
168
161
|
// 退出码取【第一个】命令(真正干活的那个)而非管道末尾命令:`head`/`grep`/`tail`
|
|
169
162
|
// 在管道末尾会把退出码吞成自己的(head 恒 0、grep 无命恒 1)。`${PIPESTATUS:-$?}`
|
|
170
163
|
// 在 bash 里取 PIPESTATUS[0](首命令),busybox ash/dash 无 PIPESTATUS 时退化为 `$?`。
|
|
@@ -172,12 +165,84 @@ export function buildTerminalBashLine(command, tailFile) {
|
|
|
172
165
|
// 留下的 141 不是真失败,而是“按要求截断=成功”。只有恰好 141 才转 0,真失败照报。
|
|
173
166
|
// tailFile:`cmd > log 2>&1 | tail -N`——拆掉 tail 后 stdout 进文件、终端为空;
|
|
174
167
|
// 在哨兵前补一个 `tail -N log` 让模型看到日志尾部,退出码仍是底层命令的。
|
|
175
|
-
// eslint-disable-next-line no-useless-escape -- \$? must reach the shell literally
|
|
176
|
-
const rcGuard = `__pi_rc=\${PIPESTATUS:-\$?}; [ "$__pi_rc" -eq 141 ] && __pi_rc=0`;
|
|
177
168
|
const tailPart = tailFile
|
|
178
169
|
? `; tail -n ${Math.max(1, Math.floor(tailFile.lines))} -- '${tailFile.file.replace(/'/g, `'\\''`)}'`
|
|
179
170
|
: "";
|
|
180
|
-
|
|
171
|
+
// eslint-disable-next-line no-useless-escape -- \$? must reach the shell literally
|
|
172
|
+
const sentinel = `__pi_rc=\${PIPESTATUS:-\$?}; [ "$__pi_rc" -eq 141 ] && __pi_rc=0${tailPart}; printf '\\n[pi-exit:%s]\\n' "$__pi_rc"`;
|
|
173
|
+
if (trimmed.includes("\n")) {
|
|
174
|
+
// 多行路径(行为保持):$'...' 转义后是一行物理输入,引号/换行全部字面化,
|
|
175
|
+
// 以 `'` 收尾——`; ` 同行拼接哨兵不会被尾注释/尾管道/续行吞掉。
|
|
176
|
+
const body = `eval $'${trimmed
|
|
177
|
+
.replace(/\\/g, "\\\\")
|
|
178
|
+
.replace(/'/g, "\\'")
|
|
179
|
+
.replace(/\r/g, "\\r")
|
|
180
|
+
.replace(/\n/g, "\\n")
|
|
181
|
+
.replace(/\t/g, "\\t")}'`;
|
|
182
|
+
return `${body}; ${sentinel}`;
|
|
183
|
+
}
|
|
184
|
+
// 单行路径:哨兵独占一行追加。行内 `; ` 拼接会被常见形态吞掉——
|
|
185
|
+
// 尾注释(`cmd # note; printf…` 整段进注释)、尾管道(`cmd |; …` 语法错误)。
|
|
186
|
+
// 独占一行后这两类安全;尾随续行符/未闭合引号仍会把哨兵行并入命令或吞进
|
|
187
|
+
// 字符串——这类形态无法安全续行,直接拒注入(调用方按无退出码模式执行并注明)。
|
|
188
|
+
if (sentinelUnsafeReason(trimmed))
|
|
189
|
+
return trimmed;
|
|
190
|
+
return `${trimmed}\n${sentinel}`;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* 判定命令能否安全追加「独占一行的退出码哨兵」;返回拒注入原因或 null(可注入)。
|
|
194
|
+
*
|
|
195
|
+
* - 尾随续行符 `\`:下一行被并入本命令,哨兵行成为命令的一部分。
|
|
196
|
+
* - 未闭合引号:哨兵行被吞进字符串字面量,永远不会作为代码执行。
|
|
197
|
+
* - 多行命令:经 `$'...'` 转义单行化后引号/换行全部字面化,总是安全 → null。
|
|
198
|
+
*
|
|
199
|
+
* 扫描按 POSIX 引号语义跟踪 `'`/`"`/`` ` `` 三种引号与转义;`$'...'`(ANSI-C
|
|
200
|
+
* 引号)内 `\'` 不闭合引号,也一并识别。
|
|
201
|
+
*/
|
|
202
|
+
export function sentinelUnsafeReason(command) {
|
|
203
|
+
const trimmed = command.replace(/\s+$/, "");
|
|
204
|
+
if (trimmed.includes("\n"))
|
|
205
|
+
return null;
|
|
206
|
+
let quote = null;
|
|
207
|
+
// 当前单引号是否 $' 开头(ANSI-C 引号,内含转义语义)
|
|
208
|
+
let ansiC = false;
|
|
209
|
+
let escaped = false;
|
|
210
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
211
|
+
const ch = trimmed[i];
|
|
212
|
+
if (quote) {
|
|
213
|
+
if (escaped) {
|
|
214
|
+
escaped = false;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
// POSIX 单引号内 `\` 是普通字符;ANSI-C/双引号/反引号内 `\` 转义下一字符
|
|
218
|
+
if (ch === "\\" && (quote !== "'" || ansiC)) {
|
|
219
|
+
escaped = true;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (ch === quote)
|
|
223
|
+
quote = null;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (escaped) {
|
|
227
|
+
// 引号外的转义:被转义字符原样跳过(`\"` 不开引号等)
|
|
228
|
+
escaped = false;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (ch === "\\") {
|
|
232
|
+
escaped = true;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (ch === "'" || ch === '"' || ch === "`") {
|
|
236
|
+
quote = ch;
|
|
237
|
+
ansiC = ch === "'" && i > 0 && trimmed[i - 1] === "$";
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (quote !== null)
|
|
241
|
+
return "unclosed_quote";
|
|
242
|
+
// 扫描结束仍在转义态 = 最后一个字符是引号外的 `\`(尾随续行符)
|
|
243
|
+
if (escaped)
|
|
244
|
+
return "trailing_backslash";
|
|
245
|
+
return null;
|
|
181
246
|
}
|
|
182
247
|
/** 顶层(引号/反引号/转义外)按 `|` 拆分的管道元素。 */
|
|
183
248
|
export function splitTopLevelPipes(cmd) {
|
|
@@ -230,6 +295,11 @@ function parseTailLines(rest) {
|
|
|
230
295
|
* - `tail -f` / `tail --follow`(长驻观察)也不拆。
|
|
231
296
|
*/
|
|
232
297
|
export function detectTrailingLimiter(command) {
|
|
298
|
+
// 含命令替换($() / 反引号)的命令保守跳过拆管:splitTopLevelPipes 只跟踪引号
|
|
299
|
+
// 与顶层 `|`,不解析括号嵌套——`cmd $(x | y)` 里替换内的管道会被误判为顶层
|
|
300
|
+
// 管道段,拆掉后直接破坏命令语义。保守正确优先,宁可少优化。
|
|
301
|
+
if (/\$\(|`/.test(command))
|
|
302
|
+
return null;
|
|
233
303
|
const parts = splitTopLevelPipes(command);
|
|
234
304
|
if (parts.length < 2)
|
|
235
305
|
return null;
|
|
@@ -1368,34 +1438,50 @@ export class TerminalManager {
|
|
|
1368
1438
|
* Kill the native PTY (issue #215:Windows ConPTY 关机死锁;issue #269:MSYS2 控制台耗尽死锁)。
|
|
1369
1439
|
*
|
|
1370
1440
|
* Windows 下:
|
|
1371
|
-
* 1. 若进程已经退出(entry.exited
|
|
1372
|
-
*
|
|
1373
|
-
* 2. 若进程尚未退出(!entry.exited
|
|
1374
|
-
*
|
|
1375
|
-
*
|
|
1376
|
-
*
|
|
1377
|
-
*
|
|
1441
|
+
* 1. 若进程已经退出(entry.exited):直接调 pty.kill() 释放 HPCON 句柄
|
|
1442
|
+
* (子进程已死、管道见 EOF,绝不会死锁);关机路径跳过,由 OS 回收。
|
|
1443
|
+
* 2. 若进程尚未退出(!entry.exited):先向 PTY 写入 \x03exit\r 优雅关闭(让
|
|
1444
|
+
* bash 正常触发清理钩子),300ms 后仍活着再调 pty.kill() 兜底——node-pty 在
|
|
1445
|
+
* Windows 上走 console-process-list 做**全树**清理。不再使用裸
|
|
1446
|
+
* process.kill(pid)(TerminateProcess):它只杀 ConPTY 附着的单一进程,
|
|
1447
|
+
* 且从拿到 pid 到调用之间隔着一个 PID 复用误杀窗口。
|
|
1448
|
+
* 3. 关机(shutdown=true):只做优雅写,跳过 pty.kill() 与 300ms 兜底,由 OS
|
|
1449
|
+
* 回收(保持 50ms 内退出的关机预算)。
|
|
1450
|
+
* 非 Windows 原样直调 pty.kill()(POSIX 下 shell 是直接子进程,pty.kill 足够,
|
|
1451
|
+
* 保持既有行为不做额外延迟)。
|
|
1378
1452
|
*/
|
|
1379
1453
|
killNative(entry, shutdown = false) {
|
|
1380
1454
|
if (process.platform === "win32") {
|
|
1381
|
-
if (
|
|
1382
|
-
|
|
1383
|
-
if (typeof pid === "number") {
|
|
1384
|
-
try {
|
|
1385
|
-
// 优雅关闭:先发 Ctrl+C,再发 exit\r
|
|
1386
|
-
entry.pty.write("\x03exit\r");
|
|
1387
|
-
}
|
|
1388
|
-
catch { }
|
|
1455
|
+
if (entry.exited) {
|
|
1456
|
+
if (!shutdown) {
|
|
1389
1457
|
try {
|
|
1390
|
-
|
|
1458
|
+
entry.pty.kill();
|
|
1391
1459
|
}
|
|
1392
1460
|
catch {
|
|
1393
1461
|
// already dead
|
|
1394
1462
|
}
|
|
1395
1463
|
}
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
// 优雅关闭:先发 Ctrl+C,再发 exit\r
|
|
1467
|
+
try {
|
|
1468
|
+
entry.pty.write("\x03exit\r");
|
|
1396
1469
|
}
|
|
1470
|
+
catch { }
|
|
1397
1471
|
if (shutdown)
|
|
1398
1472
|
return;
|
|
1473
|
+
const t = setTimeout(() => {
|
|
1474
|
+
if (entry.exited)
|
|
1475
|
+
return;
|
|
1476
|
+
try {
|
|
1477
|
+
entry.pty.kill();
|
|
1478
|
+
}
|
|
1479
|
+
catch {
|
|
1480
|
+
// already dead
|
|
1481
|
+
}
|
|
1482
|
+
}, 300);
|
|
1483
|
+
t.unref?.();
|
|
1484
|
+
return;
|
|
1399
1485
|
}
|
|
1400
1486
|
try {
|
|
1401
1487
|
entry.pty.kill();
|
|
@@ -1530,29 +1616,29 @@ export function makeTerminalBashTool(terminals, opts) {
|
|
|
1530
1616
|
return defineTool({
|
|
1531
1617
|
name: "bash",
|
|
1532
1618
|
label: "Run bash command",
|
|
1533
|
-
description:
|
|
1534
|
-
"persist=false (default
|
|
1535
|
-
"persist=true:
|
|
1536
|
-
"
|
|
1537
|
-
"
|
|
1538
|
-
"
|
|
1539
|
-
|
|
1540
|
-
promptSnippet: bilingual("run shell commands (persist=true keeps the terminal alive across calls)", "运行 shell 命令(persist=true 让终端跨调用保持存活)"),
|
|
1619
|
+
description: "Run a shell command in a visible terminal; returns full output and exit code. " +
|
|
1620
|
+
"persist=false (default): a fresh one-shot terminal per call; the shell exits after the command but its output stays viewable. " +
|
|
1621
|
+
"persist=true: runs in the persistent 'ai-bash' terminal — shell state (cd/venv/ssh) is retained across calls; " +
|
|
1622
|
+
"terminal_read/terminal_input/terminal_key observe or interact; terminal_wait blocks on backgrounded commands. " +
|
|
1623
|
+
"Run the bare command — never pipe through head/tail/more/less (output is returned complete; pipes hide live progress); use the head/tail params instead. " +
|
|
1624
|
+
"For interactive commands (REPLs, y/n prompts) set persist=true and drive them with terminal_input / terminal_key.",
|
|
1625
|
+
promptSnippet: "run shell commands (persist=true keeps the terminal alive across calls)",
|
|
1541
1626
|
parameters: Type.Object({
|
|
1542
|
-
command: Type.String({ description:
|
|
1543
|
-
timeout: Type.Optional(Type.Number({ description:
|
|
1627
|
+
command: Type.String({ description: "The shell command to run" }),
|
|
1628
|
+
timeout: Type.Optional(Type.Number({ description: "Optional timeout in seconds" })),
|
|
1544
1629
|
persist: Type.Optional(Type.Boolean({
|
|
1545
|
-
description:
|
|
1630
|
+
description: "true → run in the persistent 'ai-bash' terminal: shell state (cd/venv/ssh) is retained across calls. " +
|
|
1631
|
+
"false (default) → one-shot terminal that exits when the command finishes; output stays viewable.",
|
|
1546
1632
|
})),
|
|
1547
1633
|
head: Type.Optional(Type.Integer({
|
|
1548
1634
|
minimum: 1,
|
|
1549
1635
|
maximum: 5000,
|
|
1550
|
-
description:
|
|
1636
|
+
description: "Only return the FIRST N lines of output (like `| head -N`); prefer this over piping through head.",
|
|
1551
1637
|
})),
|
|
1552
1638
|
tail: Type.Optional(Type.Integer({
|
|
1553
1639
|
minimum: 1,
|
|
1554
1640
|
maximum: 5000,
|
|
1555
|
-
description:
|
|
1641
|
+
description: "Only return the LAST N lines of output (like `| tail -N`); prefer this over piping through tail.",
|
|
1556
1642
|
})),
|
|
1557
1643
|
}),
|
|
1558
1644
|
execute: async (_id, p, signal) => {
|
|
@@ -1614,8 +1700,16 @@ export function makeTerminalBashTool(terminals, opts) {
|
|
|
1614
1700
|
let collected = "";
|
|
1615
1701
|
let cursor = start;
|
|
1616
1702
|
let lastDataAt = Date.now();
|
|
1703
|
+
// 尾随续行符/未闭合引号的命令无法安全注入哨兵(哨兵行会被并入命令或
|
|
1704
|
+
// 吞进字符串):按无退出码模式执行,靠静默/超时/总时长解阻收尾并注明。
|
|
1705
|
+
const sentinelUnsafe = sentinelUnsafeReason(runCommand);
|
|
1706
|
+
const sentinelNote = sentinelUnsafe
|
|
1707
|
+
? pick(lang, `\n[注:命令以${sentinelUnsafe === "trailing_backslash" ? "续行符 \\" : "未闭合引号"}结尾,无法注入退出码哨兵——已按无退出码模式执行,拿不到真实 exit code。建议拆分成更简单的单行命令重试。]`, `\n[Note: the command ends with ${sentinelUnsafe === "trailing_backslash" ? "a line-continuation backslash" : "an unclosed quote"}; the exit-code sentinel could not be injected — it ran without exit-code detection. Prefer splitting it into simpler single-line commands.]`, "terminals.bash.nosentinel.note")
|
|
1708
|
+
: "";
|
|
1617
1709
|
// 标记「有哨兵命令在跑」:terminal_wait 据此区分等待与空闲。
|
|
1618
|
-
|
|
1710
|
+
// 无哨兵命令没有可等待的哨兵,标记反而误导 terminal_wait。
|
|
1711
|
+
if (!sentinelUnsafe)
|
|
1712
|
+
terminals.setSentinelPending(termId, true);
|
|
1619
1713
|
const inputErr = terminals.inputChecked(termId, buildTerminalBashLine(runCommand, tailFile) + "\r");
|
|
1620
1714
|
if (inputErr)
|
|
1621
1715
|
throw new Error(inputErr);
|
|
@@ -1633,6 +1727,12 @@ export function makeTerminalBashTool(terminals, opts) {
|
|
|
1633
1727
|
collected += read.data;
|
|
1634
1728
|
cursor = read.cursor;
|
|
1635
1729
|
lastDataAt = Date.now();
|
|
1730
|
+
// 长跑/刷屏命令的累积无上限会拖爆服务进程内存。哨兵判定只看尾部
|
|
1731
|
+
// 8KB(lastSentinel),超限丢最旧一半、保留近期输出即可;
|
|
1732
|
+
// 命令结束后的 cleanBashOutput 语义不受影响。
|
|
1733
|
+
if (collected.length > BASH_COLLECT_MAX) {
|
|
1734
|
+
collected = collected.slice(collected.length - BASH_COLLECT_MAX / 2);
|
|
1735
|
+
}
|
|
1636
1736
|
}
|
|
1637
1737
|
const m = lastSentinel(collected);
|
|
1638
1738
|
if (m) {
|
|
@@ -1654,15 +1754,15 @@ export function makeTerminalBashTool(terminals, opts) {
|
|
|
1654
1754
|
terminals.inputChecked(termId, "\x03");
|
|
1655
1755
|
closeOneShot();
|
|
1656
1756
|
const timeoutPartial = truncateMiddle(stripAnsi(collected), 4000);
|
|
1657
|
-
throw new Error(pick(lang, `Command timed out after ${p.timeout}s(已发 Ctrl+C;已有输出:${timeoutPartial}
|
|
1757
|
+
throw new Error(pick(lang, `Command timed out after ${p.timeout}s(已发 Ctrl+C;已有输出:${timeoutPartial})${sentinelNote}`, `Command timed out after ${p.timeout}s (sent Ctrl+C; partial output: ${timeoutPartial})${sentinelNote}`, "terminals.bash.timeout", { "p.timeout": p.timeout, timeoutPartial }));
|
|
1658
1758
|
}
|
|
1659
1759
|
// 静默解阻(仅持久终端):转后台 + 注册完成观察器,立即把控制权还给模型。
|
|
1660
1760
|
if (persist && idleMs > 0 && Date.now() - lastDataAt >= idleMs) {
|
|
1661
|
-
return backgroundResult(terminals, opts, runCommand, applyHeadTail(cleanBashOutput(collected), p.head, effectiveTail, lang), Math.round((Date.now() - lastDataAt) / 1000), lang, termId, persist, "idle");
|
|
1761
|
+
return backgroundResult(terminals, opts, runCommand, applyHeadTail(cleanBashOutput(collected), p.head, effectiveTail, lang) + sentinelNote, Math.round((Date.now() - lastDataAt) / 1000), lang, termId, persist, "idle");
|
|
1662
1762
|
}
|
|
1663
1763
|
// 总时长解阻(持久与一次性终端):前台运行达到上限,转入后台继续执行,避免卡死。
|
|
1664
1764
|
if (maxForegroundMs > 0 && Date.now() - startTime >= maxForegroundMs) {
|
|
1665
|
-
return backgroundResult(terminals, opts, runCommand, applyHeadTail(cleanBashOutput(collected), p.head, effectiveTail, lang), Math.round((Date.now() - startTime) / 1000), lang, termId, persist, "elapsed");
|
|
1765
|
+
return backgroundResult(terminals, opts, runCommand, applyHeadTail(cleanBashOutput(collected), p.head, effectiveTail, lang) + sentinelNote, Math.round((Date.now() - startTime) / 1000), lang, termId, persist, "elapsed");
|
|
1666
1766
|
}
|
|
1667
1767
|
}
|
|
1668
1768
|
}
|
|
@@ -1758,15 +1858,15 @@ export { TERMINAL_TOOL_NAMES } from "./tool-manager.js";
|
|
|
1758
1858
|
/** System-prompt guidance teaching the model WHEN to prefer the terminal tools
|
|
1759
1859
|
* over one-shot bash. Without it models almost never pick them — bash returns
|
|
1760
1860
|
* complete output in a single call, so it always wins on convenience. */
|
|
1761
|
-
export const TERMINAL_TOOLS_GUIDANCE = `Persistent interactive terminal tools are available (terminal_create / terminal_list / terminal_close / terminal_input / terminal_key / terminal_read / terminal_wait). The bash tool stays the DEFAULT for ordinary commands
|
|
1762
|
-
- The program is interactive or TUI-based (REPLs
|
|
1763
|
-
- You start a long-running server or watcher and want to
|
|
1764
|
-
- The user
|
|
1765
|
-
Liveness watchdog: terminals you touched
|
|
1861
|
+
export const TERMINAL_TOOLS_GUIDANCE = `Persistent interactive terminal tools are available (terminal_create / terminal_list / terminal_close / terminal_input / terminal_key / terminal_read / terminal_wait). The bash tool stays the DEFAULT for ordinary commands. Switch to bash({persist:true}) or the terminal tools when:
|
|
1862
|
+
- The program is interactive or TUI-based (REPLs, vim/htop, y/n installers, anything waiting on stdin): prefer bash({persist:true}), which runs it in the persistent 'ai-bash' terminal and returns immediately; then drive it with terminal_input / terminal_key / terminal_read on terminalId='ai-bash'.
|
|
1863
|
+
- You start a long-running server or watcher and want to watch its output (terminal_read with waitMs), send keys later (e.g. Ctrl+c via terminal_key), or block until a backgrounded command finishes without polling (terminal_wait).
|
|
1864
|
+
- The user asks you to work in the visible terminal panel.
|
|
1865
|
+
Liveness watchdog: terminals you touched are monitored — if one goes silent while you are working (default 15s), a system reminder prompts you to check it (terminal_read), answer its input prompt, or close it (terminal_close) if no longer needed.`;
|
|
1766
1866
|
/** Build the agent-facing persistent terminal tools for one conversation. */
|
|
1767
1867
|
export function makePersistentTerminalTools(terminals, cwd,
|
|
1768
|
-
/** per-call 返回文本的服务端语言(默认英文);工具 definition
|
|
1769
|
-
lang) {
|
|
1868
|
+
/** per-call 返回文本的服务端语言(默认英文);工具 definition 为纯英文。 */
|
|
1869
|
+
lang, options) {
|
|
1770
1870
|
const getLang = lang ?? (() => "en");
|
|
1771
1871
|
const result = (text, details = {}) => ({ content: [{ type: "text", text }], details });
|
|
1772
1872
|
const failIf = (error) => {
|
|
@@ -1777,11 +1877,13 @@ lang) {
|
|
|
1777
1877
|
defineTool({
|
|
1778
1878
|
name: "terminal_create",
|
|
1779
1879
|
label: "Create terminal",
|
|
1780
|
-
description:
|
|
1781
|
-
|
|
1880
|
+
description: "Create a named persistent interactive PTY. Interact via terminal_input / terminal_key, inspect output via terminal_read. " +
|
|
1881
|
+
"Prefer bash for one-shot commands; use this (or bash persist=true) for interactive/TUI programs (REPLs, vim/htop, y/n prompts) or long-running servers to observe or interrupt.",
|
|
1882
|
+
promptSnippet: "run interactive programs or long-running servers in a persistent visible PTY (multi-step: " +
|
|
1883
|
+
"create → input/key → read)",
|
|
1782
1884
|
parameters: Type.Object({
|
|
1783
|
-
terminalId: Type.String({ description:
|
|
1784
|
-
cwd: Type.Optional(Type.String({ description:
|
|
1885
|
+
terminalId: Type.String({ description: "Stable terminal name" }),
|
|
1886
|
+
cwd: Type.Optional(Type.String({ description: "Workspace-relative directory" })),
|
|
1785
1887
|
cols: Type.Optional(Type.Integer({ minimum: 2, maximum: 500 })),
|
|
1786
1888
|
rows: Type.Optional(Type.Integer({ minimum: 2, maximum: 200 })),
|
|
1787
1889
|
}),
|
|
@@ -1799,15 +1901,15 @@ lang) {
|
|
|
1799
1901
|
defineTool({
|
|
1800
1902
|
name: "terminal_list",
|
|
1801
1903
|
label: "List terminals",
|
|
1802
|
-
description:
|
|
1803
|
-
promptSnippet:
|
|
1904
|
+
description: "List all persistent PTY terminals owned by this conversation.",
|
|
1905
|
+
promptSnippet: "list persistent terminals",
|
|
1804
1906
|
parameters: Type.Object({}),
|
|
1805
1907
|
execute: async () => result(JSON.stringify(terminals.list()), terminals.list()),
|
|
1806
1908
|
}),
|
|
1807
1909
|
defineTool({
|
|
1808
1910
|
name: "terminal_close",
|
|
1809
1911
|
label: "Close terminal",
|
|
1810
|
-
description:
|
|
1912
|
+
description: "Close a persistent PTY and terminate its process tree.",
|
|
1811
1913
|
parameters: Type.Object({ terminalId: Type.String() }),
|
|
1812
1914
|
execute: async (_id, p) => {
|
|
1813
1915
|
const lang = getLang();
|
|
@@ -1822,10 +1924,16 @@ lang) {
|
|
|
1822
1924
|
defineTool({
|
|
1823
1925
|
name: "terminal_input",
|
|
1824
1926
|
label: "Send terminal input",
|
|
1825
|
-
description:
|
|
1927
|
+
description: "Send arbitrary text to a persistent PTY. Include newline when a command should be submitted.",
|
|
1826
1928
|
parameters: Type.Object({ terminalId: Type.String(), data: Type.String() }),
|
|
1827
1929
|
execute: async (_id, p) => {
|
|
1828
1930
|
const lang = getLang();
|
|
1931
|
+
if (options?.checkSafety && (p.data.includes("\n") || p.data.includes("\r"))) {
|
|
1932
|
+
const safety = options.checkSafety(p.data.trim());
|
|
1933
|
+
if (safety.blocked) {
|
|
1934
|
+
throw new Error(pick(lang, `【终端输入命令被安全规则阻断】${safety.reason ? ` 原因:${safety.reason}` : ""}`, `[Terminal command blocked by safety rule]${safety.reason ? ` Reason: ${safety.reason}` : ""}`, "terminals.command.blocked", { reason: safety.reason ?? "" }));
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1829
1937
|
failIf(terminals.inputChecked(p.terminalId, p.data));
|
|
1830
1938
|
// AI 发了输入 = 在等结果,重开一个静默纪元。
|
|
1831
1939
|
terminals.noteAgentActivity(p.terminalId);
|
|
@@ -1835,11 +1943,11 @@ lang) {
|
|
|
1835
1943
|
defineTool({
|
|
1836
1944
|
name: "terminal_key",
|
|
1837
1945
|
label: "Send terminal key",
|
|
1838
|
-
description:
|
|
1946
|
+
description: "Send Enter, Tab, arrows, function keys, or Ctrl/Alt combinations to a persistent PTY.",
|
|
1839
1947
|
parameters: Type.Object({
|
|
1840
1948
|
terminalId: Type.String(),
|
|
1841
1949
|
key: Type.String({
|
|
1842
|
-
description:
|
|
1950
|
+
description: "Enter, Tab, ArrowUp, c, etc.",
|
|
1843
1951
|
}),
|
|
1844
1952
|
modifiers: Type.Optional(Type.Object({
|
|
1845
1953
|
ctrl: Type.Optional(Type.Boolean()),
|
|
@@ -1858,7 +1966,9 @@ lang) {
|
|
|
1858
1966
|
defineTool({
|
|
1859
1967
|
name: "terminal_read",
|
|
1860
1968
|
label: "Read terminal output",
|
|
1861
|
-
description:
|
|
1969
|
+
description: "Read incremental output from a persistent PTY. " +
|
|
1970
|
+
"Keep the returned cursor and pass it on the next read; " +
|
|
1971
|
+
"optionally wait for new output or process exit.",
|
|
1862
1972
|
parameters: Type.Object({
|
|
1863
1973
|
terminalId: Type.String(),
|
|
1864
1974
|
cursor: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
@@ -1879,18 +1989,20 @@ lang) {
|
|
|
1879
1989
|
defineTool({
|
|
1880
1990
|
name: "terminal_wait",
|
|
1881
1991
|
label: "Wait for terminal command",
|
|
1882
|
-
description:
|
|
1883
|
-
|
|
1992
|
+
description: "Block until a command started through the BASH TOOL finishes (exit marker appears) or timeout — no polling. " +
|
|
1993
|
+
"Only applies to bash-tool commands; commands sent via terminal_input have no completion marker — use terminal_read(waitMs=…) for those. " +
|
|
1994
|
+
"Returns {finished, exitCode} plus output produced while waiting; finished=false means still running (call again).",
|
|
1995
|
+
promptSnippet: "block until a terminal's current command finishes (no polling)",
|
|
1884
1996
|
parameters: Type.Object({
|
|
1885
1997
|
terminalId: Type.String(),
|
|
1886
1998
|
cursor: Type.Optional(Type.Integer({
|
|
1887
1999
|
minimum: 0,
|
|
1888
|
-
description:
|
|
2000
|
+
description: "Ignore exit markers before this absolute offset (default: now)",
|
|
1889
2001
|
})),
|
|
1890
2002
|
maxWaitMs: Type.Optional(Type.Integer({
|
|
1891
2003
|
minimum: 100,
|
|
1892
2004
|
maximum: 600000,
|
|
1893
|
-
description:
|
|
2005
|
+
description: "Max wait in ms (default 300000)",
|
|
1894
2006
|
})),
|
|
1895
2007
|
}),
|
|
1896
2008
|
execute: async (_id, p, signal) => {
|
|
@@ -77,8 +77,10 @@ export const PATCH_TOOL_NAME = "patch";
|
|
|
77
77
|
export const LSP_TOOL_NAME = "lsp";
|
|
78
78
|
/** 旧工具名(持久化迁移用;新代码一律用 MARKERS_LIST_TOOL_NAME)。 */
|
|
79
79
|
export const LEGACY_MARKERS_LIST_TOOL_NAME = "markers_list";
|
|
80
|
-
/** 可开关的 Agent 工具总目录(共 27
|
|
81
|
-
*
|
|
80
|
+
/** 可开关的 Agent 工具总目录(共 27 个)。核心内置工具 bash/read/edit/write 不进
|
|
81
|
+
* 目录——目录条目 = OTHER_AGENT_TOOLS 自动渲染的设置行,而这四个在设置页
|
|
82
|
+
* 「核心工具」区单独开关(见 SettingsModal 的 CORE_BUILTIN_TOOL_NAMES 区块),
|
|
83
|
+
* 禁用名单同样接受它们(normalizeDisabledAgentTools)。 */
|
|
82
84
|
export const AGENT_TOOL_CATALOG = [
|
|
83
85
|
...TERMINAL_TOOL_NAMES.map((name) => ({
|
|
84
86
|
name,
|
|
@@ -248,13 +250,18 @@ export const AGENT_TOOL_CATALOG = [
|
|
|
248
250
|
offHintKey: "todoListOffHint",
|
|
249
251
|
},
|
|
250
252
|
];
|
|
253
|
+
/** SDK 核心内置工具名(可被门控显式禁用或被预设白名单过滤)。 */
|
|
254
|
+
export const CORE_BUILTIN_TOOL_NAMES = ["bash", "read", "edit", "write"];
|
|
255
|
+
export function isCoreBuiltinTool(name) {
|
|
256
|
+
return CORE_BUILTIN_TOOL_NAMES.includes(name);
|
|
257
|
+
}
|
|
251
258
|
const KNOWN_NAMES = new Set(AGENT_TOOL_CATALOG.map((t) => t.name));
|
|
252
259
|
/** 是否为本表登记的可开关工具(未知名一律 false,不抛错)。 */
|
|
253
260
|
export function isKnownAgentTool(name) {
|
|
254
261
|
return KNOWN_NAMES.has(name);
|
|
255
262
|
}
|
|
256
263
|
/** 归一化禁用名单:非数组回落默认(= 默认关的那些);数组则只保留已知工具名
|
|
257
|
-
*
|
|
264
|
+
* (去重;未知名丢弃,防旧文件/手写脏数据污染)。支持登记核心内置工具。 */
|
|
258
265
|
export function normalizeDisabledAgentTools(v) {
|
|
259
266
|
if (!Array.isArray(v))
|
|
260
267
|
return defaultDisabledAgentTools();
|
|
@@ -262,8 +269,9 @@ export function normalizeDisabledAgentTools(v) {
|
|
|
262
269
|
for (const x of v) {
|
|
263
270
|
// 旧名迁移:markers_list → todo_list(改名前已关闭的用户保持关闭)。
|
|
264
271
|
const name = x === LEGACY_MARKERS_LIST_TOOL_NAME ? MARKERS_LIST_TOOL_NAME : x;
|
|
265
|
-
if (typeof name === "string" && KNOWN_NAMES.has(name) && !out.includes(name))
|
|
272
|
+
if (typeof name === "string" && (KNOWN_NAMES.has(name) || isCoreBuiltinTool(name)) && !out.includes(name)) {
|
|
266
273
|
out.push(name);
|
|
274
|
+
}
|
|
267
275
|
}
|
|
268
276
|
return out;
|
|
269
277
|
}
|
|
@@ -317,21 +325,30 @@ export function setAgentToolsEnabled(session, names, on) {
|
|
|
317
325
|
}
|
|
318
326
|
}
|
|
319
327
|
/**
|
|
320
|
-
* 全量重放(创建会话 / reload 后 /
|
|
321
|
-
*
|
|
328
|
+
* 全量重放(创建会话 / reload 后 / 设置变更后调):按禁用名单把目录内工具与
|
|
329
|
+
* 核心内置工具(bash/read/edit/write,禁用名单接受它们)逐个加回或剔除;
|
|
330
|
+
* 基线之外的工具(插件工具等)原样不动。
|
|
322
331
|
* 支持传入 preset(预设 id),按预设白名单做二次过滤。
|
|
332
|
+
* 复原基线取 session.getAllTools()(全集,含被禁用的)——基线里没有的工具不会凭空发明。
|
|
323
333
|
* Session 未就绪时静默跳过(下次创建/reload 会再应用)。
|
|
324
334
|
*/
|
|
325
335
|
export function applyAgentToolsGating(session, disabled, preset) {
|
|
326
336
|
try {
|
|
327
337
|
const off = new Set(disabled);
|
|
328
|
-
const
|
|
338
|
+
const allNames = session.getAllTools().map((t) => t.name);
|
|
339
|
+
const names = new Set(allNames);
|
|
329
340
|
for (const t of AGENT_TOOL_CATALOG) {
|
|
330
341
|
if (off.has(t.name))
|
|
331
342
|
names.delete(t.name);
|
|
332
343
|
else
|
|
333
344
|
names.add(t.name);
|
|
334
345
|
}
|
|
346
|
+
for (const core of CORE_BUILTIN_TOOL_NAMES) {
|
|
347
|
+
if (off.has(core))
|
|
348
|
+
names.delete(core);
|
|
349
|
+
else if (allNames.includes(core))
|
|
350
|
+
names.add(core);
|
|
351
|
+
}
|
|
335
352
|
const filtered = filterToolsByPreset(names, preset);
|
|
336
353
|
session.setActiveToolsByName(filtered);
|
|
337
354
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tool-overrides.ts —— pi-web-ui 对 SDK 内置工具的覆盖(read / write / edit)如何与
|
|
3
|
+
* **第三方 pi 扩展提供的同名工具共存**。
|
|
4
|
+
*
|
|
5
|
+
* 为什么需要这个模块(问题):SDK 的注册表合并链是
|
|
6
|
+
* `allCustomTools = [...扩展注册的工具, ...customTools]` 再逐个 `definitionRegistry.set(name, …)`
|
|
7
|
+
* —— 后写赢 ⇒ **customTools 恒顶掉扩展的同名工具,与加载顺序无关、也没有开关**(见
|
|
8
|
+
* `dist/core/agent-session.js` 的 `_refreshToolRegistry()`)。而官方
|
|
9
|
+
* `docs/extensions.md` §Overriding Built-in Tools 明写扩展**可以**覆盖 `read` / `bash` /
|
|
10
|
+
* `powershell` / `edit` / `write` / `grep` / `find` / `ls` 并逐个列出了 `read`。
|
|
11
|
+
* pi-web-ui 过去把这三个覆盖直接塞进创建时的 `customTools` ⇒ 那句话在 `read`/`write`/`edit`
|
|
12
|
+
* 上失效:第三方扩展的同名工具**永远轮不到**,而且症状会伪装(覆盖层转发内置实现 ⇒
|
|
13
|
+
* 「读文件/图片/目录都正常,只有扩展特有的能力没有」,例如 `pi-better-edit` 的 read 不出锚、
|
|
14
|
+
* 它的 edit 随后一律 `E_UNKNOWN_ANCHOR`)。
|
|
15
|
+
*
|
|
16
|
+
* 做法:这三个覆盖**不再于创建时注册**。会话建好后按名字从**扩展注册表**
|
|
17
|
+
* (`session.extensionRunner.getAllRegisteredTools()`,不受 customTools 顶替影响)取扩展的同名
|
|
18
|
+
* 实现当基底,没有则用 pi-web-ui 自己的完整实现,再交给装饰器(`composeWith`)在其上叠加
|
|
19
|
+
* pi-web-ui 的能力(目录列条目 / 权限沙箱)。装饰器必须把**行为**委托给基底,只加自己那一层;
|
|
20
|
+
* 基底的 schema、描述、prompt 指引、渲染全部原样保留,扩展独有的参数(如 better-edit 的
|
|
21
|
+
* `windows`)照旧可用。
|
|
22
|
+
*
|
|
23
|
+
* 注入手法与 `plugins.ts` 的 `syncPluginToolsIntoSession` 相同:改 `session._customTools`
|
|
24
|
+
* 后调 `_refreshToolRegistry()`(SDK 改私有字段名即返回 null,调用方按「覆盖没装上」降级;
|
|
25
|
+
* `extensionRunner` 缺失时退化成「有扩展也当内置」=改动前的行为,不会更差)。
|
|
26
|
+
*
|
|
27
|
+
* 顺序:注入项排在既有 `_customTools` **之前** ⇒ pi-web-ui 插件注册的同名工具(也是
|
|
28
|
+
* customTools,比覆盖层后写)仍然是最后赢家,与改动前的相对顺序一致。
|
|
29
|
+
*/
|
|
30
|
+
/** 解析某个工具名在**扩展注册表**里的实现;没有扩展提供时返回 undefined。 */
|
|
31
|
+
export function extensionToolDefinition(session, name) {
|
|
32
|
+
const tools = session.extensionRunner?.getAllRegisteredTools?.();
|
|
33
|
+
return tools?.find((tool) => tool.definition?.name === name)?.definition;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* 把覆盖装进已建好的会话:逐个解析基底(扩展同名工具优先)→ 装饰 → 注入 → 刷新注册表。
|
|
37
|
+
* 返回注入的工具名;会话对象形状不符(SDK 改私有字段名)返回 null,调用方按「覆盖未生效」降级。
|
|
38
|
+
*/
|
|
39
|
+
export function installToolOverrides(session, specs) {
|
|
40
|
+
if (!Array.isArray(session._customTools) || typeof session._refreshToolRegistry !== "function")
|
|
41
|
+
return null;
|
|
42
|
+
const names = new Set(specs.map((s) => s.name));
|
|
43
|
+
const injected = [];
|
|
44
|
+
for (const spec of specs) {
|
|
45
|
+
const extensionTool = extensionToolDefinition(session, spec.name);
|
|
46
|
+
if (extensionTool) {
|
|
47
|
+
// 有扩展同名工具:只能叠(composeWith);没给装饰器就整个让给它 ——
|
|
48
|
+
// **绝不**用 fallback 顶掉它(那就又是本模块存在要防的那个问题)。
|
|
49
|
+
if (spec.composeWith)
|
|
50
|
+
injected.push(spec.composeWith(extensionTool));
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
injected.push(spec.fallback());
|
|
54
|
+
}
|
|
55
|
+
// 同名项先剔除再前置:重复调用幂等(不会层层叠加),且插件工具仍然后写赢。
|
|
56
|
+
session._customTools = [...injected, ...session._customTools.filter((t) => !names.has(t.name))];
|
|
57
|
+
session._refreshToolRegistry();
|
|
58
|
+
return [...names];
|
|
59
|
+
}
|
|
@@ -13,6 +13,7 @@ import { readdirSync, readFileSync, realpathSync, existsSync } from "node:fs";
|
|
|
13
13
|
import { delimiter, dirname, join } from "node:path";
|
|
14
14
|
import { promisify } from "node:util";
|
|
15
15
|
import { pick } from "./i18n.js";
|
|
16
|
+
import { sdkCopies } from "./sdk-origin.js";
|
|
16
17
|
const PI_CORE_PACKAGE = "@earendil-works/pi-coding-agent";
|
|
17
18
|
/** npm 官方源;用户在 <agentDir>/npm/.npmrc 里配了镜像/私有源时会被覆盖(issue #151)。 */
|
|
18
19
|
export const NPM_DEFAULT_REGISTRY = "https://registry.npmjs.org";
|
|
@@ -424,6 +425,32 @@ export function defaultProbePiCore() {
|
|
|
424
425
|
piCoreProbe = { at: now, version };
|
|
425
426
|
return version;
|
|
426
427
|
}
|
|
428
|
+
/**
|
|
429
|
+
* issue #321 — detect "a newer pi is installed on this machine than the one
|
|
430
|
+
* this process actually loaded". The panel's pi-core row probes the GLOBAL pi
|
|
431
|
+
* CLI (what `npm i -g` updates), but the server loads its own bundled copy
|
|
432
|
+
* (nested beats ancestor in Node resolution, issue #260) — so after upgrading
|
|
433
|
+
* the global pi the panel reads "up to date" while conversations still run the
|
|
434
|
+
* old SDK. That invisible split is exactly what #321 reports; this turns it
|
|
435
|
+
* into data the UI can explain.
|
|
436
|
+
*
|
|
437
|
+
* Two detectors, unioned (newest wins): the CLI probe (standard global
|
|
438
|
+
* install) and the ancestor-chain copies (fallback when the probe finds
|
|
439
|
+
* nothing — different npm prefix, bun, …). Pure — tests inject both.
|
|
440
|
+
* Returns null when nothing newer than `runningVersion` is found.
|
|
441
|
+
*/
|
|
442
|
+
export function detectPiSdkSplit(runningVersion, probePiCore = defaultProbePiCore, copies = sdkCopies()) {
|
|
443
|
+
let installed = probePiCore();
|
|
444
|
+
for (const copy of copies) {
|
|
445
|
+
if (compareVersions(copy.version, runningVersion) > 0 &&
|
|
446
|
+
(!installed || compareVersions(copy.version, installed) > 0)) {
|
|
447
|
+
installed = copy.version;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (!installed || compareVersions(installed, runningVersion) <= 0)
|
|
451
|
+
return null;
|
|
452
|
+
return { running: runningVersion, installed };
|
|
453
|
+
}
|
|
427
454
|
/**
|
|
428
455
|
* Fallback when the CLI probe yields nothing: the version of the vendored pi
|
|
429
456
|
* core copy in <agentDir>/npm/node_modules, or null if that is absent too.
|
|
@@ -646,7 +673,7 @@ gitCheck = defaultCheckGitExtension) {
|
|
|
646
673
|
* up-to-date ones, errors last. Stable within each bucket (Array.sort is
|
|
647
674
|
* stable) so registry order survives ties. */
|
|
648
675
|
export function sortUpdateItems(items) {
|
|
649
|
-
const kindRank = (k) => (k === "webui" ? 0 : k === "pi-core" ? 1 : 2);
|
|
676
|
+
const kindRank = (k) => (k === "webui" ? 0 : k === "pi-core" ? 1 : k === "plugin" ? 2 : 3);
|
|
650
677
|
return [...items].sort((a, b) => {
|
|
651
678
|
const ka = kindRank(a.kind);
|
|
652
679
|
const kb = kindRank(b.kind);
|