pi-web-ui 0.97.0 → 0.99.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 +126 -1
- package/README.md +3 -3
- package/README.zh-CN.md +3 -3
- package/bin/pi-web-ui.mjs +64 -23
- package/dist/server/agent-service.js +645 -147
- package/dist/server/approval-rules.js +5 -10
- package/dist/server/atomic-file.js +40 -0
- package/dist/server/attachments.js +22 -0
- package/dist/server/claim-store.js +5 -11
- package/dist/server/client-state.js +220 -23
- package/dist/server/compact-context-tool.js +3 -1
- package/dist/server/compacted-history.js +7 -5
- package/dist/server/composer-drafts.js +4 -6
- package/dist/server/control-socket.js +0 -40
- package/dist/server/delegate-mode.js +6 -12
- package/dist/server/dsh/dsh-agent-service.js +32 -30
- package/dist/server/dsh/dsh-client.js +45 -9
- package/dist/server/dsh/dsh-serialize.js +12 -10
- package/dist/server/dsh/dsh-sessions.js +20 -6
- package/dist/server/dsh/runtime/goal-rpc.mjs +14 -1
- package/dist/server/dsh/runtime/runtime-root.mjs +1 -1
- package/dist/server/ensure-bash.js +3 -5
- package/dist/server/eval-tool.js +30 -5
- package/dist/server/goal-review-gate.js +7 -20
- package/dist/server/goal-service.js +77 -10
- package/dist/server/index.js +83 -15
- package/dist/server/launch-origin.js +0 -4
- package/dist/server/locales.js +3 -5
- package/dist/server/lsp-tool.js +110 -14
- package/dist/server/markers/builtins/notify.js +4 -2
- package/dist/server/markers/builtins/rename.js +0 -1
- package/dist/server/markers/builtins/todo.js +6 -26
- package/dist/server/markers/marker.js +0 -15
- package/dist/server/markers/registry.js +0 -3
- package/dist/server/markers/store.js +0 -3
- package/dist/server/mcp-hot-reload.js +12 -5
- package/dist/server/model-admin.js +19 -18
- package/dist/server/model-enrich.js +2 -3
- package/dist/server/patch-tool.js +78 -28
- package/dist/server/permission-preset.js +25 -0
- package/dist/server/plan-manager.js +196 -10
- package/dist/server/plan-mode.js +58 -20
- package/dist/server/plugin-api-catalog.js +2 -0
- package/dist/server/plugin-catalog.js +6 -6
- package/dist/server/plugin-dom.js +3 -4
- package/dist/server/plugin-facilities.js +3 -5
- package/dist/server/plugin-grants.js +5 -8
- package/dist/server/plugin-permissions.js +4 -6
- package/dist/server/plugin-schedule.js +4 -7
- package/dist/server/plugin-updater.js +151 -38
- package/dist/server/plugins.js +16 -2
- package/dist/server/prompt-composer.js +12 -3
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/resolve-global-sdk.js +8 -0
- package/dist/server/schedule-agent-tool.js +5 -1
- package/dist/server/scheduler-tasks.js +46 -27
- package/dist/server/sdk-origin.js +27 -0
- package/dist/server/serialize.js +22 -9
- package/dist/server/slash-commands.js +16 -0
- package/dist/server/subagent-templates.js +4 -10
- package/dist/server/subagents.js +22 -11
- package/dist/server/terminals.js +24 -12
- package/dist/server/text-quote.js +59 -0
- package/dist/server/tool-manager.js +12 -3
- package/dist/server/tool-overrides.js +24 -0
- package/dist/server/update-check.js +69 -40
- package/extensions/webui.ts +15 -1
- package/package.json +31 -19
- package/plugin-sdk/client-utils.mjs +92 -0
- package/plugin-sdk/index.d.ts +21 -0
- package/plugin-sdk/index.mjs +2 -0
- package/plugins/catalog.json +7 -0
- package/web/dist/assets/{TerminalPanel-CITBqQrb.js → TerminalPanel-NerrJlTS.js} +1 -1
- package/web/dist/assets/index-BdMaGCcs.css +1 -0
- package/web/dist/assets/index-ByCBN4Kt.js +368 -0
- package/web/dist/index.html +2 -2
- package/web/dist/sw.js +2 -1
- package/web/public/sw.js +2 -1
- package/web/dist/assets/index-DwToli0J.css +0 -1
- package/web/dist/assets/index-oI7UKOqJ.js +0 -368
|
@@ -55,9 +55,6 @@ export function isReadonlyPromptSource(token) {
|
|
|
55
55
|
* SDK 默认拼装的完整提示词。 */
|
|
56
56
|
export const DEFAULT_PROMPT_TEMPLATE = PROMPT_TOKENS.map((t) => `{{${t}}}`).join("\n\n");
|
|
57
57
|
const TOKEN_RE = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
|
|
58
|
-
export function isKnownToken(name) {
|
|
59
|
-
return PROMPT_TOKENS.includes(name);
|
|
60
|
-
}
|
|
61
58
|
/** 模板里出现的全部 token(含未知名,供 UI 提示)。 */
|
|
62
59
|
export function collectTemplateTokens(template) {
|
|
63
60
|
const out = [];
|
|
@@ -258,3 +255,15 @@ export function estimatePromptTokens(text) {
|
|
|
258
255
|
const cjk = text.match(CJK_RE)?.length ?? 0;
|
|
259
256
|
return Math.ceil(cjk + (text.length - cjk) / 4);
|
|
260
257
|
}
|
|
258
|
+
/** 把「早前扩展改过的提示词」拆成 SDK 原始提示词 + 扩展的首尾增补。
|
|
259
|
+
* before_agent_start 是链式的:我们是最后一个 handler,返回 systemPrompt 等于
|
|
260
|
+
* forceSystemPrompt 整体替换,会把别的扩展(如注入 <invoked_skill> 的)写进去的
|
|
261
|
+
* 内容一起丢掉。只要原始提示词还原样嵌在里面(纯前置/后置增补),就能把增补摘出来
|
|
262
|
+
* 重新套回我们的渲染结果上。摘不出来(中间插入 / 被整体替换)时返回 pre/post 为空、
|
|
263
|
+
* core 为当前文本 —— 调用方退回今天的行为,绝不把整段提示词翻倍。 */
|
|
264
|
+
export function splitExtensionWrap(baseline, current) {
|
|
265
|
+
const at = baseline ? current.indexOf(baseline) : -1;
|
|
266
|
+
if (at === -1)
|
|
267
|
+
return { pre: "", core: current, post: "" };
|
|
268
|
+
return { pre: current.slice(0, at), core: baseline, post: current.slice(at + baseline.length) };
|
|
269
|
+
}
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
* 或前台启动时在 import 服务入口之前先 import 它)。
|
|
27
27
|
*/
|
|
28
28
|
import { registerHooks } from "node:module";
|
|
29
|
+
import { dirname } from "node:path";
|
|
29
30
|
import { pathToFileURL } from "node:url";
|
|
30
31
|
import { compareVersions, sdkCopies } from "./sdk-origin.js";
|
|
31
32
|
const PKG = "@earendil-works/pi-coding-agent";
|
|
@@ -40,6 +41,13 @@ export function pickGlobalSdk(copies, mode) {
|
|
|
40
41
|
if (BUNDLED_MODES.has((mode ?? "global").trim().toLowerCase()))
|
|
41
42
|
return null;
|
|
42
43
|
const bundled = copies[0];
|
|
44
|
+
// 显式透传的宿主 SDK 路径(如 extensions/webui.ts 宿主传递,issue #482):若不降级优先选用
|
|
45
|
+
if (process.env.PI_WEB_SDK_DIR) {
|
|
46
|
+
const hostCopy = copies.find((c) => dirname(c.path) === process.env.PI_WEB_SDK_DIR);
|
|
47
|
+
if (hostCopy && (!bundled || compareVersions(hostCopy.version, bundled.version) >= 0)) {
|
|
48
|
+
return hostCopy;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
43
51
|
// 只考虑祖先链上**严格更新**的副本,多份取版本最高的(旧的/同版本的都不折腾)。
|
|
44
52
|
let best = null;
|
|
45
53
|
for (const copy of copies.slice(1)) {
|
|
@@ -20,7 +20,7 @@ import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
|
20
20
|
import { Type } from "typebox";
|
|
21
21
|
import { pick } from "./i18n.js";
|
|
22
22
|
import { SCHEDULE_CANCEL_TOOL_NAME, SCHEDULE_LIST_TOOL_NAME, SCHEDULE_TASK_TOOL_NAME } from "./tool-manager.js";
|
|
23
|
-
import { computeNextFire, describeIntervalMs, SCHEDULER_MIN_INTERVAL_MS, } from "./scheduler-tasks.js";
|
|
23
|
+
import { computeNextFire, describeIntervalMs, SCHEDULER_MIN_INTERVAL_MS, SchedulerValidationError, } from "./scheduler-tasks.js";
|
|
24
24
|
import { parseCronSpec } from "./plugin-schedule.js";
|
|
25
25
|
/** 用户写法 → 调度规格。纯函数,可单测。
|
|
26
26
|
* 收:5 字段 cron("0 * * * *")、相对时间("in 30m" / "in 1h" / "in 2d")、
|
|
@@ -180,6 +180,10 @@ export function makeScheduleTools(host, ownerConversationId, lang) {
|
|
|
180
180
|
});
|
|
181
181
|
}
|
|
182
182
|
catch (err) {
|
|
183
|
+
const L = getLang();
|
|
184
|
+
if (err instanceof SchedulerValidationError) {
|
|
185
|
+
return text(L === "zh" ? err.messageZh : err.messageEn);
|
|
186
|
+
}
|
|
183
187
|
return text(String(err?.message ?? err));
|
|
184
188
|
}
|
|
185
189
|
const L = getLang();
|
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
* 定时策略:单个 10s ticker 检查到期任务(cron 下次触发 / 间隔毫秒),
|
|
11
11
|
* running 集合防重叠;catchUp "once" 在启动时补跑一次漏掉的触发。
|
|
12
12
|
*/
|
|
13
|
-
import {
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { nextCronFire, parseCronSpec } from "./plugin-schedule.js";
|
|
16
|
+
import { writeJsonAtomicSync } from "./atomic-file.js";
|
|
16
17
|
export const SCHEDULER_FILE = "scheduler-tasks.json";
|
|
17
18
|
/** 持久间隔底线 60s(与 host.schedule 持久任务同口径,防 token 烧穿)。 */
|
|
18
19
|
export const SCHEDULER_MIN_INTERVAL_MS = 60_000;
|
|
@@ -30,50 +31,72 @@ function randomTaskId() {
|
|
|
30
31
|
s += abc[Math.floor(Math.random() * abc.length)];
|
|
31
32
|
return `task-${s}`;
|
|
32
33
|
}
|
|
33
|
-
|
|
34
|
+
export class SchedulerValidationError extends Error {
|
|
35
|
+
code;
|
|
36
|
+
messageZh;
|
|
37
|
+
messageEn;
|
|
38
|
+
constructor(code, messageZh, messageEn) {
|
|
39
|
+
super(messageZh);
|
|
40
|
+
this.code = code;
|
|
41
|
+
this.messageZh = messageZh;
|
|
42
|
+
this.messageEn = messageEn;
|
|
43
|
+
this.name = "SchedulerValidationError";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** 归一化 + 校验用户输入;非法抛 SchedulerValidationError。纯函数,可单测。 */
|
|
34
47
|
export function normalizeSchedulerInput(input, now = Date.now()) {
|
|
35
48
|
const idRaw = String(input.id ?? "").trim();
|
|
36
49
|
const id = idRaw || randomTaskId();
|
|
37
|
-
if (!SCHEDULER_ID_RE.test(id))
|
|
38
|
-
throw new
|
|
50
|
+
if (!SCHEDULER_ID_RE.test(id)) {
|
|
51
|
+
throw new SchedulerValidationError("invalid_id", "任务 id 非法(只收字母/数字/下划线/连字符,≤64 字)", "Invalid task id (letters/numbers/underscores/hyphens only, <= 64 chars)");
|
|
52
|
+
}
|
|
39
53
|
const name = String(input.name ?? "")
|
|
40
54
|
.trim()
|
|
41
55
|
.slice(0, 80);
|
|
42
|
-
if (!name)
|
|
43
|
-
throw new
|
|
56
|
+
if (!name) {
|
|
57
|
+
throw new SchedulerValidationError("empty_name", "任务名称不能为空", "Task name cannot be empty");
|
|
58
|
+
}
|
|
44
59
|
const description = String(input.description ?? "")
|
|
45
60
|
.trim()
|
|
46
61
|
.slice(0, 500);
|
|
47
62
|
const cwd = String(input.cwd ?? "").trim();
|
|
48
|
-
if (!cwd)
|
|
49
|
-
throw new
|
|
63
|
+
if (!cwd) {
|
|
64
|
+
throw new SchedulerValidationError("empty_cwd", "执行目标项目(cwd)不能为空", "Target project directory (cwd) cannot be empty");
|
|
65
|
+
}
|
|
50
66
|
const kind = input.kind === "interval" ? "interval" : "cron";
|
|
51
67
|
let spec;
|
|
52
68
|
if (kind === "cron") {
|
|
53
69
|
spec = String(input.spec ?? "")
|
|
54
70
|
.trim()
|
|
55
71
|
.replace(/\s+/g, " ");
|
|
56
|
-
if (!parseCronSpec(spec))
|
|
57
|
-
throw new
|
|
72
|
+
if (!parseCronSpec(spec)) {
|
|
73
|
+
throw new SchedulerValidationError("invalid_cron", "cron 表达式非法(要 5 字段:分 时 日 月 周,如 0 9 * * *)", "Invalid cron expression (requires 5 fields: min hour day month weekday, e.g. 0 9 * * *)");
|
|
74
|
+
}
|
|
58
75
|
}
|
|
59
76
|
else {
|
|
60
77
|
const ms = Math.floor(Number(input.spec));
|
|
61
|
-
if (!Number.isFinite(ms) || ms <= 0)
|
|
62
|
-
throw new
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
78
|
+
if (!Number.isFinite(ms) || ms <= 0) {
|
|
79
|
+
throw new SchedulerValidationError("invalid_interval", "间隔毫秒数非法", "Invalid interval milliseconds");
|
|
80
|
+
}
|
|
81
|
+
if (ms < SCHEDULER_MIN_INTERVAL_MS) {
|
|
82
|
+
throw new SchedulerValidationError("interval_too_short", `间隔太短(最短 ${SCHEDULER_MIN_INTERVAL_MS / 1000}s,防 token 烧穿)`, `Interval too short (minimum ${SCHEDULER_MIN_INTERVAL_MS / 1000}s)`);
|
|
83
|
+
}
|
|
84
|
+
if (ms > SCHEDULER_MAX_INTERVAL_MS) {
|
|
85
|
+
throw new SchedulerValidationError("interval_too_long", "间隔太长(最长 30 天)", "Interval too long (maximum 30 days)");
|
|
86
|
+
}
|
|
67
87
|
spec = String(ms);
|
|
68
88
|
}
|
|
69
89
|
const prompt = String(input.prompt ?? "").trim();
|
|
70
|
-
if (!prompt)
|
|
71
|
-
throw new
|
|
72
|
-
|
|
73
|
-
|
|
90
|
+
if (!prompt) {
|
|
91
|
+
throw new SchedulerValidationError("empty_prompt", "触发指令(prompt)不能为空", "Trigger instruction (prompt) cannot be empty");
|
|
92
|
+
}
|
|
93
|
+
if (prompt.length > 8000) {
|
|
94
|
+
throw new SchedulerValidationError("prompt_too_long", "触发指令超长(>8000 字),请裁剪后重试", "Trigger instruction too long (>8000 characters), please shorten and retry");
|
|
95
|
+
}
|
|
74
96
|
const model = String(input.model ?? "").trim();
|
|
75
|
-
if (model && !model.includes("/"))
|
|
76
|
-
throw new
|
|
97
|
+
if (model && !model.includes("/")) {
|
|
98
|
+
throw new SchedulerValidationError("invalid_model", "模型格式非法(应为 provider/id)", "Invalid model format (expected provider/id)");
|
|
99
|
+
}
|
|
77
100
|
const thinkingLevel = String(input.thinkingLevel ?? "").trim();
|
|
78
101
|
const catchUp = input.catchUp === "once" ? "once" : "skip";
|
|
79
102
|
const conversationId = String(input.conversationId ?? "")
|
|
@@ -267,14 +290,10 @@ export class SchedulerStore {
|
|
|
267
290
|
}
|
|
268
291
|
save() {
|
|
269
292
|
try {
|
|
270
|
-
mkdirSync(this.dataDir, { recursive: true });
|
|
271
|
-
const file = schedulerFile(this.dataDir);
|
|
272
|
-
const tmp = `${file}.tmp-${process.pid}`;
|
|
273
293
|
const tasks = {};
|
|
274
294
|
for (const [id, t] of this.tasks)
|
|
275
295
|
tasks[id] = t;
|
|
276
|
-
|
|
277
|
-
renameSync(tmp, file);
|
|
296
|
+
writeJsonAtomicSync(schedulerFile(this.dataDir), { v: 1, tasks });
|
|
278
297
|
}
|
|
279
298
|
catch (err) {
|
|
280
299
|
console.error("[scheduler] persist failed:", err);
|
|
@@ -37,6 +37,33 @@ export function sdkCopies(fromFile = import.meta.url) {
|
|
|
37
37
|
break;
|
|
38
38
|
dir = parent;
|
|
39
39
|
}
|
|
40
|
+
// 显式透传的宿主 SDK 路径(如 extensions/webui.ts 宿主传递,issue #482)
|
|
41
|
+
if (process.env.PI_WEB_SDK_DIR) {
|
|
42
|
+
const pj = join(process.env.PI_WEB_SDK_DIR, "package.json");
|
|
43
|
+
if (existsSync(pj) && !out.some((c) => c.path === pj)) {
|
|
44
|
+
try {
|
|
45
|
+
const version = JSON.parse(readFileSync(pj, "utf8")).version;
|
|
46
|
+
if (typeof version === "string" && version) {
|
|
47
|
+
out.push({ path: pj, version });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch { }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// 宿主 Pi Node 路径探测(~/.local/share/pi-node/current/...,issue #482)
|
|
54
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
55
|
+
if (home) {
|
|
56
|
+
const hostPj = join(home, ".local", "share", "pi-node", "current", "lib", "node_modules", PKG, "package.json");
|
|
57
|
+
if (existsSync(hostPj) && !out.some((c) => c.path === hostPj)) {
|
|
58
|
+
try {
|
|
59
|
+
const version = JSON.parse(readFileSync(hostPj, "utf8")).version;
|
|
60
|
+
if (typeof version === "string" && version) {
|
|
61
|
+
out.push({ path: hostPj, version });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch { }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
40
67
|
return out;
|
|
41
68
|
}
|
|
42
69
|
/** 点分版本号比较(够用即可:只比数字段,缺位当 0)。 */
|
package/dist/server/serialize.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
const
|
|
3
|
-
const
|
|
1
|
+
import { splitQuotedPrompt } from "./text-quote.js";
|
|
2
|
+
export const TEXT_CAP = 200_000;
|
|
3
|
+
export const TOOL_OUTPUT_CAP = 100_000;
|
|
4
|
+
export const ARGS_CAP = 20_000;
|
|
4
5
|
/**
|
|
5
6
|
* toolResult 里单张图片进快照的上限(dataUrl 字符数 ≈ 1.5MB 二进制)。
|
|
6
7
|
* 视口/元素截图随便过;超大整页截图回落成占位文本(模型侧不受影响 —— 图它
|
|
@@ -17,10 +18,11 @@ const TOOL_RESULT_IMAGE_MAX = 8;
|
|
|
17
18
|
* 前端还得写容错);工具作者应自己封顶(见 present-files-tool.ts 的摘录预算)。
|
|
18
19
|
*/
|
|
19
20
|
const TOOL_DETAILS_CAP = 64_000;
|
|
20
|
-
function truncate(s, cap) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
export function truncate(s, cap) {
|
|
22
|
+
const str = typeof s === "string" ? s : String(s ?? "");
|
|
23
|
+
if (str.length <= cap)
|
|
24
|
+
return { text: str, truncated: false };
|
|
25
|
+
return { text: `${str.slice(0, cap)}\n\n… [truncated]`, truncated: true };
|
|
24
26
|
}
|
|
25
27
|
/**
|
|
26
28
|
* SDK/工具的图片块 → 前端可直接 <img> 的 UiImageBlock。
|
|
@@ -204,13 +206,24 @@ export function serializeMessage(m, seq) {
|
|
|
204
206
|
}
|
|
205
207
|
const id = uiMessageId(m, seq);
|
|
206
208
|
switch (m.role) {
|
|
207
|
-
case "user":
|
|
209
|
+
case "user": {
|
|
210
|
+
const quotes = [];
|
|
211
|
+
const raw = typeof m.content === "string" ? [{ type: "text", text: m.content }] : m.content;
|
|
212
|
+
const content = raw.map((block) => {
|
|
213
|
+
if (block.type !== "text")
|
|
214
|
+
return block;
|
|
215
|
+
const parsed = splitQuotedPrompt(block.text);
|
|
216
|
+
quotes.push(...parsed.quotes);
|
|
217
|
+
return { ...block, text: parsed.text };
|
|
218
|
+
});
|
|
208
219
|
return {
|
|
209
220
|
id,
|
|
210
221
|
role: "user",
|
|
211
|
-
content: serializeUserContent(
|
|
222
|
+
content: serializeUserContent(content),
|
|
223
|
+
...(quotes.length ? { details: { quotes } } : {}),
|
|
212
224
|
timestamp: m.timestamp,
|
|
213
225
|
};
|
|
226
|
+
}
|
|
214
227
|
case "assistant":
|
|
215
228
|
return {
|
|
216
229
|
id,
|
|
@@ -38,6 +38,13 @@ export const NATIVE_COMMANDS = [
|
|
|
38
38
|
argumentHint: "<路径>",
|
|
39
39
|
argumentHintEn: "<path>",
|
|
40
40
|
},
|
|
41
|
+
{
|
|
42
|
+
name: "plan",
|
|
43
|
+
description: "开启或关闭计划模式(/plan [on|off])",
|
|
44
|
+
descriptionEn: "Toggle plan mode (/plan [on|off])",
|
|
45
|
+
argumentHint: "[on|off]",
|
|
46
|
+
argumentHintEn: "[on|off]",
|
|
47
|
+
},
|
|
41
48
|
{
|
|
42
49
|
name: "thinking",
|
|
43
50
|
description: "设置思考强度",
|
|
@@ -249,6 +256,15 @@ export class SlashCommandsService {
|
|
|
249
256
|
await this.host.setCwd(args);
|
|
250
257
|
}
|
|
251
258
|
return true;
|
|
259
|
+
case "plan": {
|
|
260
|
+
const trimmed = args.trim().toLowerCase();
|
|
261
|
+
const current = this.host.getPlanMode ? this.host.getPlanMode() : false;
|
|
262
|
+
const next = trimmed === "on" ? true : trimmed === "off" ? false : !current;
|
|
263
|
+
if (this.host.setPlanMode) {
|
|
264
|
+
await this.host.setPlanMode(next);
|
|
265
|
+
}
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
252
268
|
case "thinking": {
|
|
253
269
|
const ALIAS = {
|
|
254
270
|
off: "off",
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
* systemPromptEn 为英文(en 用,缺失回落中文);调用方用 pickTemplateDescription/
|
|
20
20
|
* pickTemplatePrompt 按语言选用。
|
|
21
21
|
*/
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
22
|
+
import { readFileSync } from "node:fs";
|
|
23
|
+
import { writeJsonAtomicSync } from "./atomic-file.js";
|
|
24
24
|
/**
|
|
25
25
|
* 思考强度取值:与 SDK 的 THINKING_LEVEL_OPTIONS(前端 ModelThinking 的 THINKING_VALUES)
|
|
26
26
|
* 一致。SDK 未从包根导出该常量,这里照抄一份作输入校验(写错的值一律当未配置)。
|
|
@@ -608,10 +608,7 @@ export class SubagentTemplatesStore {
|
|
|
608
608
|
}
|
|
609
609
|
persist() {
|
|
610
610
|
try {
|
|
611
|
-
|
|
612
|
-
const tmp = `${this.filePath}.${process.pid}.tmp`;
|
|
613
|
-
writeFileSync(tmp, JSON.stringify(this.templates, null, 2) + "\n");
|
|
614
|
-
renameSync(tmp, this.filePath);
|
|
611
|
+
writeJsonAtomicSync(this.filePath, this.templates);
|
|
615
612
|
}
|
|
616
613
|
catch {
|
|
617
614
|
// best effort
|
|
@@ -644,10 +641,7 @@ export class SubagentTemplatesStore {
|
|
|
644
641
|
saveSeeded(names) {
|
|
645
642
|
this.seededNames = names;
|
|
646
643
|
try {
|
|
647
|
-
|
|
648
|
-
const tmp = `${this.seededPath()}.tmp`;
|
|
649
|
-
writeFileSync(tmp, JSON.stringify([...names].sort(), null, 2));
|
|
650
|
-
renameSync(tmp, this.seededPath());
|
|
644
|
+
writeJsonAtomicSync(this.seededPath(), [...names].sort());
|
|
651
645
|
}
|
|
652
646
|
catch {
|
|
653
647
|
// best effort
|
package/dist/server/subagents.js
CHANGED
|
@@ -31,14 +31,22 @@ import { pick } from "./i18n.js";
|
|
|
31
31
|
/**
|
|
32
32
|
* subagent_wait_all 的最长阻塞时间:必须短暂低于工具看门狗(默认 20 分钟,
|
|
33
33
|
* PI_WEB_TOOL_TIMEOUT_MS 可调),否则看门狗会先中止整个会话而不是让 wait
|
|
34
|
-
* 干净地超时返回。取 0.8
|
|
35
|
-
*
|
|
34
|
+
* 干净地超时返回。取 0.8×看门狗(默认 ~16min,支持 PI_WEB_SUBAGENT_WAIT_CAP_SECONDS 覆盖),
|
|
35
|
+
* 兜底不低于 5s。
|
|
36
36
|
*/
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
37
|
+
export function computeWaitCapMs(watchdogMs) {
|
|
38
|
+
const customSecs = Number(process.env.PI_WEB_SUBAGENT_WAIT_CAP_SECONDS);
|
|
39
|
+
if (Number.isFinite(customSecs) && customSecs > 0) {
|
|
40
|
+
return Math.max(Math.floor(customSecs * 1000), 5_000);
|
|
41
|
+
}
|
|
42
|
+
const envWatchdog = Number(process.env.PI_WEB_TOOL_TIMEOUT_MS);
|
|
43
|
+
const watchdog = typeof watchdogMs === "number" && Number.isFinite(watchdogMs) && watchdogMs > 0
|
|
44
|
+
? watchdogMs
|
|
45
|
+
: Number.isFinite(envWatchdog) && envWatchdog > 0
|
|
46
|
+
? envWatchdog
|
|
47
|
+
: 20 * 60_000;
|
|
48
|
+
return Math.max(Math.floor(watchdog * 0.8), 5_000);
|
|
49
|
+
}
|
|
42
50
|
/** 子代理是否已到终态(运行结束、被中止或出错)。wait 工具据此判断
|
|
43
51
|
* 是否可以取结果;streaming=false 即可(error/canceled 都在快照里带标记)。 */
|
|
44
52
|
export function isSubagentTerminal(r) {
|
|
@@ -93,8 +101,8 @@ export function makeSubagentTool(host, lang, selfConvId) {
|
|
|
93
101
|
details,
|
|
94
102
|
});
|
|
95
103
|
const currentWaitCapMs = typeof host.getWatchdogTimeoutMs === "function"
|
|
96
|
-
?
|
|
97
|
-
:
|
|
104
|
+
? computeWaitCapMs(host.getWatchdogTimeoutMs())
|
|
105
|
+
: computeWaitCapMs();
|
|
98
106
|
const capSeconds = Math.floor(currentWaitCapMs / 1000);
|
|
99
107
|
return defineTool({
|
|
100
108
|
name: SUBAGENT_TOOL_NAME,
|
|
@@ -361,7 +369,10 @@ export function makeSubagentTool(host, lang, selfConvId) {
|
|
|
361
369
|
}
|
|
362
370
|
const allDone = Array.from(wanted).every((id) => {
|
|
363
371
|
const r = host.getSubagent(id);
|
|
364
|
-
|
|
372
|
+
// #488:getSubagent 返回 undefined = 已被移出(强行关闭/收尾清理)
|
|
373
|
+
// ——视为终态,与下方报告段「已移出或不存在」口径一致;否则等待
|
|
374
|
+
// 集合含被移出子代理时 allDone 恒 false,必然白等满 timeoutMs。
|
|
375
|
+
return r === undefined || isSubagentTerminal(r);
|
|
365
376
|
});
|
|
366
377
|
if (allDone)
|
|
367
378
|
break;
|
|
@@ -534,5 +545,5 @@ function promptRemaining(remaining, lang = "en") {
|
|
|
534
545
|
if (remaining.length === 0)
|
|
535
546
|
return "";
|
|
536
547
|
const pendingIds = remaining.map(shortId).join(", ");
|
|
537
|
-
return pick(lang, `\n未完成:${pendingIds}。可再次调用
|
|
548
|
+
return pick(lang, `\n未完成:${pendingIds}。可再次调用 subagent(action="wait_all")继续等待耗时任务,或使用 steer 补充指令 / stop 中止。`, `\nPending: ${pendingIds}. You may call subagent (action="wait_all") again to continue waiting for long tasks, or use steer to add instructions / stop to abort.`, "subagents.wait.pending", { pendingIds: pendingIds });
|
|
538
549
|
}
|
package/dist/server/terminals.js
CHANGED
|
@@ -1580,28 +1580,35 @@ function cleanBashOutput(raw) {
|
|
|
1580
1580
|
/** 一次性 bash 终端序号:非持久调用每次新建一个终端,进程结束后退出、输出
|
|
1581
1581
|
* 保留在 history 供查阅——故每调用独立 id,避免复用覆盖旧输出。 */
|
|
1582
1582
|
let oneShotBashSeq = 0;
|
|
1583
|
+
/** head / tail 的运行时上限,与 bash 工具参数 schema 的 maximum 5000 一致
|
|
1584
|
+
* (agent-service.ts)——schema 只影响提示不拦运行时(issue #462)。 */
|
|
1585
|
+
const MAX_HEAD_TAIL_LINES = 5000;
|
|
1583
1586
|
/** 应用 head / tail 参数到输出顶层行(替代 `| head` / `| tail` 管道——管道会
|
|
1584
1587
|
* 缓冲输出、让可见终端全程哑火,还容易白白触发静默解阻)。两者同时给时先
|
|
1585
1588
|
* 截头再截尾。 */
|
|
1586
1589
|
export function applyHeadTail(text, head, tail, lang = "en") {
|
|
1590
|
+
// 入口钳到 schema 上限:此前只判 >0,传 1e9 会把整段缓冲灌进单条工具结果
|
|
1591
|
+
// 并持久化进转录。
|
|
1592
|
+
const headN = head && head > 0 ? Math.min(Math.floor(head), MAX_HEAD_TAIL_LINES) : 0;
|
|
1593
|
+
const tailN = tail && tail > 0 ? Math.min(Math.floor(tail), MAX_HEAD_TAIL_LINES) : 0;
|
|
1587
1594
|
// 只对真实数据行切片;省略提示行单独存,最后再包回输出,避免提示行在
|
|
1588
1595
|
// head+tail 组合时被当成数据行参与第二次截取(导致尾部少截一行)。
|
|
1589
1596
|
let data = text.split("\n");
|
|
1590
1597
|
let headNote = null;
|
|
1591
1598
|
let tailNote = null;
|
|
1592
|
-
if (
|
|
1593
|
-
const n = data.length -
|
|
1599
|
+
if (headN && data.length > headN) {
|
|
1600
|
+
const n = data.length - headN;
|
|
1594
1601
|
headNote = pick(lang, `…(后 ${n} 行已省略)`, `…[${n} lines omitted below]…`, "terminals.headtail.omitted.below", {
|
|
1595
1602
|
n,
|
|
1596
1603
|
});
|
|
1597
|
-
data = data.slice(0,
|
|
1604
|
+
data = data.slice(0, headN);
|
|
1598
1605
|
}
|
|
1599
|
-
if (
|
|
1600
|
-
const n = data.length -
|
|
1606
|
+
if (tailN && data.length > tailN) {
|
|
1607
|
+
const n = data.length - tailN;
|
|
1601
1608
|
tailNote = pick(lang, `…(前 ${n} 行已省略)`, `…[${n} lines omitted above]…`, "terminals.headtail.omitted.above", {
|
|
1602
1609
|
n,
|
|
1603
1610
|
});
|
|
1604
|
-
data = data.slice(-
|
|
1611
|
+
data = data.slice(-tailN);
|
|
1605
1612
|
}
|
|
1606
1613
|
const parts = [];
|
|
1607
1614
|
if (tailNote)
|
|
@@ -1889,7 +1896,9 @@ lang, options) {
|
|
|
1889
1896
|
}),
|
|
1890
1897
|
execute: async (_id, p) => {
|
|
1891
1898
|
const lang = getLang();
|
|
1892
|
-
const
|
|
1899
|
+
const safeCols = Math.max(2, Math.min(500, Math.floor(p.cols ?? 120)));
|
|
1900
|
+
const safeRows = Math.max(2, Math.min(200, Math.floor(p.rows ?? 40)));
|
|
1901
|
+
const info = terminals.create(p.terminalId, p.cwd ?? cwd, safeCols, safeRows, cwd, p.terminalId);
|
|
1893
1902
|
const infoJson = JSON.stringify(info);
|
|
1894
1903
|
if (!info)
|
|
1895
1904
|
throw new Error(pick(lang, `创建终端失败:${p.terminalId}`, `Failed to create terminal: ${p.terminalId}`, "terminals.create.failed", { "p.terminalId": p.terminalId }));
|
|
@@ -1977,10 +1986,12 @@ lang, options) {
|
|
|
1977
1986
|
}),
|
|
1978
1987
|
execute: async (_id, p, signal) => {
|
|
1979
1988
|
const lang = getLang();
|
|
1980
|
-
const cursor = p.cursor ?? 0;
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1989
|
+
const cursor = Math.max(0, Math.floor(p.cursor ?? 0));
|
|
1990
|
+
const waitMs = p.waitMs !== undefined ? Math.max(0, Math.min(120_000, Math.floor(p.waitMs))) : undefined;
|
|
1991
|
+
const maxBytes = Math.max(1, Math.min(100_000, Math.floor(p.maxBytes ?? 20000)));
|
|
1992
|
+
if (waitMs)
|
|
1993
|
+
await terminals.waitForOutput(p.terminalId, cursor, waitMs, signal);
|
|
1994
|
+
const read = terminals.read(p.terminalId, cursor, maxBytes);
|
|
1984
1995
|
if (!read)
|
|
1985
1996
|
throw new Error(pick(lang, `终端不存在:${p.terminalId}`, `Terminal not found: ${p.terminalId}`, "terminals.read.not.found", { "p.terminalId": p.terminalId }));
|
|
1986
1997
|
return result(JSON.stringify(read), read);
|
|
@@ -2019,7 +2030,8 @@ lang, options) {
|
|
|
2019
2030
|
return result(JSON.stringify({ applicable: false, reason: why }), { applicable: false });
|
|
2020
2031
|
}
|
|
2021
2032
|
const cursor = p.cursor ?? terminals.endCursor(p.terminalId) ?? 0;
|
|
2022
|
-
const
|
|
2033
|
+
const maxWait = Math.max(100, Math.min(600_000, Math.floor(p.maxWaitMs ?? 300_000)));
|
|
2034
|
+
const wait = await terminals.waitForCompletion(p.terminalId, maxWait, signal, cursor);
|
|
2023
2035
|
const read = terminals.read(p.terminalId, cursor, 20_000);
|
|
2024
2036
|
const outputTail = read?.data ? stripAnsi(read.data).slice(-4000) : "";
|
|
2025
2037
|
return result(JSON.stringify({ ...wait, outputTail }), {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** 校验引用元数据,保留原文的换行和缩进。 */
|
|
2
|
+
export function readTextQuote(value) {
|
|
3
|
+
if (!value || typeof value !== "object")
|
|
4
|
+
return null;
|
|
5
|
+
const q = value;
|
|
6
|
+
if (typeof q.text !== "string" ||
|
|
7
|
+
!q.text.trim() ||
|
|
8
|
+
typeof q.messageId !== "string" ||
|
|
9
|
+
!q.messageId ||
|
|
10
|
+
typeof q.role !== "string" ||
|
|
11
|
+
!q.role)
|
|
12
|
+
return null;
|
|
13
|
+
return {
|
|
14
|
+
text: q.text,
|
|
15
|
+
messageId: q.messageId,
|
|
16
|
+
role: q.role,
|
|
17
|
+
...(typeof q.sessionId === "string" && q.sessionId ? { sessionId: q.sessionId } : {}),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/** JSON 字符串保留原文边界,引用中的换行不参与外层标记解析。 */
|
|
21
|
+
export function formatTextQuote(quote) {
|
|
22
|
+
return `<quoted-text>\n${JSON.stringify(quote)}\n</quoted-text>`;
|
|
23
|
+
}
|
|
24
|
+
export function parseTextQuote(text) {
|
|
25
|
+
const match = /^<quoted-text>\n([^\n]*)\n<\/quoted-text>$/.exec(text);
|
|
26
|
+
if (!match)
|
|
27
|
+
return null;
|
|
28
|
+
try {
|
|
29
|
+
return readTextQuote(JSON.parse(match[1]));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function formatQuotedPrompt(text, quotes) {
|
|
36
|
+
return [text, ...quotes.map(formatTextQuote)].join("\n\n");
|
|
37
|
+
}
|
|
38
|
+
/** 排队项把引用与提问一起保存;展示时只拆分末尾的完整引用块。 */
|
|
39
|
+
export function splitQuotedPrompt(value) {
|
|
40
|
+
let text = value;
|
|
41
|
+
const quotes = [];
|
|
42
|
+
while (true) {
|
|
43
|
+
const match = /(?:^|\n\n)(<quoted-text>\n[^\n]*\n<\/quoted-text>)$/.exec(text);
|
|
44
|
+
const quote = match ? parseTextQuote(match[1]) : null;
|
|
45
|
+
if (!match || !quote)
|
|
46
|
+
break;
|
|
47
|
+
quotes.unshift(quote);
|
|
48
|
+
text = text.slice(0, match.index);
|
|
49
|
+
}
|
|
50
|
+
return { text, quotes };
|
|
51
|
+
}
|
|
52
|
+
export function messageTextWithQuotes(message) {
|
|
53
|
+
const text = message.content
|
|
54
|
+
.map((block) => (block.type === "text" && typeof block.text === "string" ? block.text : ""))
|
|
55
|
+
.join("\n");
|
|
56
|
+
const values = message.details?.quotes;
|
|
57
|
+
const quotes = Array.isArray(values) ? values.map(readTextQuote).filter((q) => q !== null) : [];
|
|
58
|
+
return formatQuotedPrompt(text, quotes);
|
|
59
|
+
}
|
|
@@ -345,10 +345,19 @@ export function applyAgentToolsGating(session, disabled, preset) {
|
|
|
345
345
|
const allNames = session.getAllTools().map((t) => t.name);
|
|
346
346
|
const names = new Set(allNames);
|
|
347
347
|
for (const t of AGENT_TOOL_CATALOG) {
|
|
348
|
-
if (off.has(t.name))
|
|
349
|
-
|
|
350
|
-
|
|
348
|
+
if (off.has(t.name)) {
|
|
349
|
+
// issue #481: 若第三方扩展注册了同名工具(如 nicobailon/pi-subagents 的 subagent),
|
|
350
|
+
// 关闭内置工具时允许扩展工具透传,不从活跃名单删除
|
|
351
|
+
const hasExtensionTool = Boolean(session?.extensionRunner
|
|
352
|
+
?.getAllRegisteredTools?.()
|
|
353
|
+
?.some((tool) => tool.definition?.name === t.name));
|
|
354
|
+
if (!hasExtensionTool) {
|
|
355
|
+
names.delete(t.name);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
351
359
|
names.add(t.name);
|
|
360
|
+
}
|
|
352
361
|
}
|
|
353
362
|
for (const core of CORE_BUILTIN_TOOL_NAMES) {
|
|
354
363
|
if (off.has(core))
|
|
@@ -57,3 +57,27 @@ export function installToolOverrides(session, specs) {
|
|
|
57
57
|
session._refreshToolRegistry();
|
|
58
58
|
return [...names];
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* 同步 subagent 工具与第三方扩展的同名工具共存(issue #481):
|
|
62
|
+
* 当第三方扩展注册了同名 subagent 时,若用户在设置中关闭/禁用了内置 subagent,
|
|
63
|
+
* 则自动从 session._customTools 中移除内置 subagent,让扩展的同名工具透传生效;
|
|
64
|
+
* 重新启用时则重新注入内置 subagent。
|
|
65
|
+
*/
|
|
66
|
+
export function syncSubagentOverride(session, subagentDisabled, builtinSubagentTool) {
|
|
67
|
+
if (!Array.isArray(session._customTools) || typeof session._refreshToolRegistry !== "function")
|
|
68
|
+
return;
|
|
69
|
+
const hasExtensionTool = Boolean(extensionToolDefinition(session, "subagent"));
|
|
70
|
+
if (!hasExtensionTool)
|
|
71
|
+
return;
|
|
72
|
+
const idx = session._customTools.findIndex((t) => t.name === "subagent");
|
|
73
|
+
if (subagentDisabled) {
|
|
74
|
+
if (idx !== -1) {
|
|
75
|
+
session._customTools = session._customTools.filter((t) => t.name !== "subagent");
|
|
76
|
+
session._refreshToolRegistry();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
else if (idx === -1 && builtinSubagentTool) {
|
|
80
|
+
session._customTools = [builtinSubagentTool(), ...session._customTools];
|
|
81
|
+
session._refreshToolRegistry();
|
|
82
|
+
}
|
|
83
|
+
}
|