pi-web-ui 0.94.1 → 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 +91 -2
- package/README.md +5 -6
- package/README.zh-CN.md +4 -5
- package/bin/pi-web-ui.mjs +910 -237
- package/dist/server/agent-service.js +2655 -231
- package/dist/server/approval-rules.js +596 -0
- package/dist/server/attachment-store.js +129 -0
- package/dist/server/attachments.js +92 -174
- 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 +67 -14
- package/dist/server/compact-context-tool.js +126 -0
- package/dist/server/composer-drafts.js +9 -0
- package/dist/server/context-budget.js +317 -0
- package/dist/server/control-socket.js +57 -28
- package/dist/server/conversation-read-tool.js +7 -17
- package/dist/server/dangling-tools.js +229 -0
- package/dist/server/delegate-task.js +25 -28
- package/dist/server/dsh/dsh-agent-service.js +69 -66
- package/dist/server/edit-soft-tool.js +84 -16
- package/dist/server/eval-tool.js +588 -0
- package/dist/server/file-archives.js +16 -5
- package/dist/server/files-service.js +65 -18
- package/dist/server/goal-service.js +371 -77
- package/dist/server/hashline-engine.js +703 -0
- package/dist/server/host-guard.js +94 -0
- package/dist/server/host-metrics.js +26 -2
- package/dist/server/i18n.js +3 -3
- package/dist/server/index.js +456 -80
- package/dist/server/lsp-tool.js +1371 -0
- package/dist/server/mcp-bridge.js +47 -3
- package/dist/server/model-admin.js +112 -32
- package/dist/server/office-parse.js +375 -0
- package/dist/server/patch-tool.js +85 -0
- package/dist/server/permission-preset.js +25 -0
- package/dist/server/plan-manager.js +114 -0
- package/dist/server/plugin-api-catalog.js +296 -0
- 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-install-spec.js +196 -0
- package/dist/server/plugin-installer.js +73 -0
- package/dist/server/plugin-llm.js +71 -65
- package/dist/server/plugin-manifest-validate.js +305 -0
- package/dist/server/plugin-project.js +117 -13
- package/dist/server/plugin-tool-guard.js +120 -0
- package/dist/server/plugin-updater.js +115 -12
- package/dist/server/plugins.js +950 -205
- package/dist/server/present-files-tool.js +9 -12
- package/dist/server/process-utils.js +16 -6
- 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/scheduler-tasks.js +6 -0
- package/dist/server/sdk-origin.js +18 -2
- package/dist/server/serialize.js +111 -14
- package/dist/server/settings-service.js +112 -3
- package/dist/server/skill-tool.js +5 -7
- package/dist/server/subagent-templates.js +51 -0
- package/dist/server/subagents.js +83 -66
- package/dist/server/terminals.js +248 -81
- package/dist/server/tool-approval.js +84 -0
- package/dist/server/tool-manager.js +237 -12
- 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/workspace-snapshot.js +113 -0
- 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 +5 -3
- package/plugin-sdk/README.md +25 -0
- package/plugin-sdk/index.d.ts +31 -38
- package/plugin-sdk/index.mjs +35 -19
- package/plugins/catalog.json +40 -0
- package/themes/aetheris.css +457 -0
- package/themes/ayu-light.css +6 -6
- package/themes/catppuccin-latte.css +6 -6
- package/themes/claude-code-dark.css +144 -0
- package/themes/codex.css +6 -6
- package/themes/everforest-light.css +6 -6
- package/themes/geist.css +6 -6
- package/themes/gruvbox-light.css +6 -6
- package/themes/kanagawa-lotus.css +6 -6
- package/themes/rose-pine-dawn.css +6 -6
- package/themes/solarized-light.css +6 -6
- package/themes/vs-code-dark.css +146 -0
- package/themes/zhupi-dark.css +658 -0
- package/themes/zhupi.css +711 -0
- package/web/dist/assets/{TerminalPanel-MVoxpJOA.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-B3S9MxnN.css +0 -1
- package/web/dist/assets/index-DVLrHI2E.js +0 -364
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* attachment-store.ts — 基于 SHA-256 内容寻址(CAS)的附件存储。
|
|
3
|
+
*
|
|
4
|
+
* 借鉴 DSH 的 dsh-attachment 机制:
|
|
5
|
+
* - 图片与大二进制附件以 sha256 哈希落盘到 `<dataDir>/attachments/<hash>.<ext>`;
|
|
6
|
+
* - WebSocket 快照与会话转录中仅传递紧凑的 hash 引用与 URL;
|
|
7
|
+
* - 浏览器通过 `/api/attachment/:hash` 经 HTTP 访问,带永久强缓存(immutable);
|
|
8
|
+
* - 避免快照和消息列表随会话进行出现数兆字节 base64 的膨胀。
|
|
9
|
+
*/
|
|
10
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
11
|
+
import { mkdirSync, existsSync, readdirSync } from "node:fs";
|
|
12
|
+
import { readFile, rename, rm, writeFile, stat } from "node:fs/promises";
|
|
13
|
+
import { join, extname } from "node:path";
|
|
14
|
+
let storeDir = "";
|
|
15
|
+
/** 初始化附件存储目录(服务启动时调用)。 */
|
|
16
|
+
export function initAttachmentStore(dataDir) {
|
|
17
|
+
storeDir = join(dataDir, "attachments");
|
|
18
|
+
if (!existsSync(storeDir)) {
|
|
19
|
+
mkdirSync(storeDir, { recursive: true });
|
|
20
|
+
}
|
|
21
|
+
return storeDir;
|
|
22
|
+
}
|
|
23
|
+
const MIME_EXT_MAP = {
|
|
24
|
+
"image/png": ".png",
|
|
25
|
+
"image/jpeg": ".jpg",
|
|
26
|
+
"image/jpg": ".jpg",
|
|
27
|
+
"image/gif": ".gif",
|
|
28
|
+
"image/webp": ".webp",
|
|
29
|
+
"image/svg+xml": ".svg",
|
|
30
|
+
"image/bmp": ".bmp",
|
|
31
|
+
"application/pdf": ".pdf",
|
|
32
|
+
};
|
|
33
|
+
const EXT_MIME_MAP = {
|
|
34
|
+
".png": "image/png",
|
|
35
|
+
".jpg": "image/jpeg",
|
|
36
|
+
".jpeg": "image/jpeg",
|
|
37
|
+
".gif": "image/gif",
|
|
38
|
+
".webp": "image/webp",
|
|
39
|
+
".svg": "image/svg+xml",
|
|
40
|
+
".bmp": "image/bmp",
|
|
41
|
+
".pdf": "application/pdf",
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* 将二进制内容写入附件存储(内容寻址,幂等):
|
|
45
|
+
* 已存在同哈希文件则直接返回元数据,不重复写盘。
|
|
46
|
+
*/
|
|
47
|
+
export async function saveAttachment(buffer, mimeType = "application/octet-stream", preferredExt) {
|
|
48
|
+
if (!storeDir) {
|
|
49
|
+
throw new Error("AttachmentStore has not been initialized with initAttachmentStore(dataDir)");
|
|
50
|
+
}
|
|
51
|
+
const hash = createHash("sha256").update(buffer).digest("hex");
|
|
52
|
+
const ext = preferredExt || MIME_EXT_MAP[mimeType] || ".bin";
|
|
53
|
+
const fileName = `${hash}${ext}`;
|
|
54
|
+
const filePath = join(storeDir, fileName);
|
|
55
|
+
if (!existsSync(filePath)) {
|
|
56
|
+
// 原地 writeFile 有两个问题:a) 同 hash 并发写时两个 writeFileSync
|
|
57
|
+
// 交错会留下半个文件;b) 读方(/api/attachment/:hash)可能在写完前就命中。
|
|
58
|
+
// 因此先写私有 tmp 再 rename(同目录内原子生效)。POSIX rename 原子覆盖;
|
|
59
|
+
// Windows 目标已存在时报 EEXIST/EPERM —— 内容寻址同 hash 即同内容,
|
|
60
|
+
// 并发对手落盘视为成功。tmp 命名带 uuid,绝不与真实附件名冲突。
|
|
61
|
+
const tmpPath = `${filePath}.${randomUUID()}.tmp`;
|
|
62
|
+
try {
|
|
63
|
+
await writeFile(tmpPath, buffer);
|
|
64
|
+
await rename(tmpPath, filePath);
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
await rm(tmpPath, { force: true }).catch(() => { });
|
|
68
|
+
const code = err.code;
|
|
69
|
+
if (code !== "EEXIST" && code !== "EPERM")
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
hash,
|
|
75
|
+
size: buffer.length,
|
|
76
|
+
mimeType,
|
|
77
|
+
ext,
|
|
78
|
+
url: `/api/attachment/${hash}`,
|
|
79
|
+
filePath,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* 根据哈希查找已存储的附件文件路径及元数据。
|
|
84
|
+
*/
|
|
85
|
+
export async function findAttachment(hash) {
|
|
86
|
+
if (!storeDir || !existsSync(storeDir))
|
|
87
|
+
return null;
|
|
88
|
+
// 校验 hash 必须是 64 位十六进制字符串(防止目录遍历)
|
|
89
|
+
if (!/^[a-f0-9]{64}$/i.test(hash))
|
|
90
|
+
return null;
|
|
91
|
+
try {
|
|
92
|
+
const files = readdirSync(storeDir);
|
|
93
|
+
// 跳过原子写(tmp + rename)崩溃时可能残留的 .tmp —— 前缀同为 hash,
|
|
94
|
+
// 若被命中会拿到错误的后缀与 MIME。
|
|
95
|
+
const match = files.find((f) => f.startsWith(hash) && !f.endsWith(".tmp"));
|
|
96
|
+
if (!match)
|
|
97
|
+
return null;
|
|
98
|
+
const filePath = join(storeDir, match);
|
|
99
|
+
const st = await stat(filePath);
|
|
100
|
+
const ext = extname(match).toLowerCase();
|
|
101
|
+
const mimeType = EXT_MIME_MAP[ext] || "application/octet-stream";
|
|
102
|
+
return {
|
|
103
|
+
hash,
|
|
104
|
+
size: st.size,
|
|
105
|
+
mimeType,
|
|
106
|
+
ext,
|
|
107
|
+
url: `/api/attachment/${hash}`,
|
|
108
|
+
filePath,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* 读取附件二进制数据。
|
|
117
|
+
*/
|
|
118
|
+
export async function readAttachment(hash) {
|
|
119
|
+
const record = await findAttachment(hash);
|
|
120
|
+
if (!record)
|
|
121
|
+
return null;
|
|
122
|
+
try {
|
|
123
|
+
const buffer = await readFile(record.filePath);
|
|
124
|
+
return { buffer, mimeType: record.mimeType };
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -1,10 +1,23 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { sniffImageMime } from "./text-sniff.js";
|
|
2
2
|
import { saveUpload, uploadsRoot } from "./uploads.js";
|
|
3
3
|
import { isAbsoluteWirePath, wireToAbs } from "./files-service.js";
|
|
4
4
|
import { buildVisionBridgePrompt, findVisionModels, transcribeImages } from "./vision-bridge.js";
|
|
5
|
+
import { saveAttachment } from "./attachment-store.js";
|
|
5
6
|
/** 跨快照的视觉转写缓存:批次 hash(名称 + base64 头 + 提示词)→ 转写文本。
|
|
6
|
-
* 编辑重问重发相同图片不再重复耗视觉 token。进程级共享即可。
|
|
7
|
+
* 编辑重问重发相同图片不再重复耗视觉 token。进程级共享即可。
|
|
8
|
+
* 长驻进程下无界 Map 会随图片种类缓慢吃内存(单条转写可达数十 KB),
|
|
9
|
+
* 超过 VISION_BRIDGE_CACHE_MAX 按插入序 FIFO 淘汰最旧。 */
|
|
10
|
+
const VISION_BRIDGE_CACHE_MAX = 256;
|
|
7
11
|
const visionBridgeCache = new Map();
|
|
12
|
+
function cacheVisionTranscript(key, value) {
|
|
13
|
+
visionBridgeCache.set(key, value);
|
|
14
|
+
if (visionBridgeCache.size > VISION_BRIDGE_CACHE_MAX) {
|
|
15
|
+
// Map 迭代序 = 插入序,第一个 key 即最旧。
|
|
16
|
+
const oldest = visionBridgeCache.keys().next().value;
|
|
17
|
+
if (oldest !== undefined)
|
|
18
|
+
visionBridgeCache.delete(oldest);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
8
21
|
/** "provider/id" 解析;非法格式返回 null。 */
|
|
9
22
|
export function parseModelSpec(spec) {
|
|
10
23
|
if (!spec)
|
|
@@ -25,9 +38,6 @@ export async function buildAttachmentMessages(ctx, attachments) {
|
|
|
25
38
|
const { resolve, sep, relative, extname, basename } = await import("node:path");
|
|
26
39
|
const root = resolve(ctx.cwd);
|
|
27
40
|
const MAX_ATTACHMENT_BYTES = 200 * 1024;
|
|
28
|
-
// Files at or below this size are inlined; larger files are referenced by
|
|
29
|
-
// path only (the model reads them on demand — saves tokens for small edits).
|
|
30
|
-
const MAX_INLINE_BYTES = Number(process.env.PI_WEB_INLINE_FILE_MAX ?? 12 * 1024);
|
|
31
41
|
const IMAGE_EXT = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"]);
|
|
32
42
|
const MIME = {
|
|
33
43
|
".png": "image/png",
|
|
@@ -40,55 +50,30 @@ export async function buildAttachmentMessages(ctx, attachments) {
|
|
|
40
50
|
};
|
|
41
51
|
const out = [];
|
|
42
52
|
/** Push the aside for a raw uploaded file (fresh fileData or a restored
|
|
43
|
-
* uploadPath re-read from disk).
|
|
44
|
-
*
|
|
53
|
+
* uploadPath re-read from disk). Uploads are NEVER inlined: the model gets
|
|
54
|
+
* the persisted absolute path and reads it on demand with its read tool.
|
|
45
55
|
* `upload: true` marks the card as a restorable upload — the browser
|
|
46
56
|
* re-sends it by path when editing & re-asking a question. */
|
|
47
57
|
const pushUploadAside = (name, wirePath, buf) => {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
{
|
|
55
|
-
type: "text",
|
|
56
|
-
text: `\n<file path="${wirePath}">\n\`\`\`\n${decodeText(buf)}\n\`\`\`\n</file>`,
|
|
57
|
-
},
|
|
58
|
-
],
|
|
59
|
-
display: true,
|
|
60
|
-
details: {
|
|
61
|
-
name,
|
|
62
|
-
path: wirePath,
|
|
63
|
-
mode: "inline",
|
|
64
|
-
size: buf.length,
|
|
65
|
-
lines,
|
|
66
|
-
upload: true,
|
|
67
|
-
},
|
|
68
|
-
},
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
else {
|
|
72
|
-
out.push({
|
|
73
|
-
message: {
|
|
74
|
-
customType: "file",
|
|
75
|
-
content: [
|
|
76
|
-
{
|
|
77
|
-
type: "text",
|
|
78
|
-
text: `<file path="${wirePath}" size="${buf.length}" />`,
|
|
79
|
-
},
|
|
80
|
-
],
|
|
81
|
-
display: true,
|
|
82
|
-
details: {
|
|
83
|
-
name,
|
|
84
|
-
path: wirePath,
|
|
85
|
-
mode: "reference",
|
|
86
|
-
size: buf.length,
|
|
87
|
-
upload: true,
|
|
58
|
+
out.push({
|
|
59
|
+
message: {
|
|
60
|
+
customType: "file",
|
|
61
|
+
content: [
|
|
62
|
+
{
|
|
63
|
+
type: "text",
|
|
64
|
+
text: `<file path="${wirePath}" size="${buf.length}" />`,
|
|
88
65
|
},
|
|
66
|
+
],
|
|
67
|
+
display: true,
|
|
68
|
+
details: {
|
|
69
|
+
name,
|
|
70
|
+
path: wirePath,
|
|
71
|
+
mode: "reference",
|
|
72
|
+
size: buf.length,
|
|
73
|
+
upload: true,
|
|
89
74
|
},
|
|
90
|
-
}
|
|
91
|
-
}
|
|
75
|
+
},
|
|
76
|
+
});
|
|
92
77
|
};
|
|
93
78
|
// -- Vision bridge ------------------------------------------------------
|
|
94
79
|
// When the active model can't accept images (DeepSeek, GLM, …), pasted
|
|
@@ -221,7 +206,7 @@ export async function buildAttachmentMessages(ctx, attachments) {
|
|
|
221
206
|
systemPrompt: buildVisionBridgePrompt(ctx.settings.visionBridgePromptMode, ctx.settings.visionBridgePrompt, vLang),
|
|
222
207
|
lang: vLang,
|
|
223
208
|
});
|
|
224
|
-
|
|
209
|
+
cacheVisionTranscript(batchHash, transcript);
|
|
225
210
|
ctx.emit({
|
|
226
211
|
type: "notice",
|
|
227
212
|
level: "info",
|
|
@@ -244,8 +229,6 @@ export async function buildAttachmentMessages(ctx, attachments) {
|
|
|
244
229
|
}
|
|
245
230
|
}
|
|
246
231
|
}
|
|
247
|
-
/** Cap for reading a file in "lines" mode (selected slice is inlined). */
|
|
248
|
-
const MAX_LINES_READ_BYTES = 2 * 1024 * 1024;
|
|
249
232
|
for (const [idx, att] of attachments.entries()) {
|
|
250
233
|
// Quoted conversation (left-panel right-click / global-search quote):
|
|
251
234
|
// `path` is unused — the reference travels in conversationId (running
|
|
@@ -343,6 +326,16 @@ export async function buildAttachmentMessages(ctx, attachments) {
|
|
|
343
326
|
});
|
|
344
327
|
continue;
|
|
345
328
|
}
|
|
329
|
+
let attachmentUrl;
|
|
330
|
+
let attachmentHash;
|
|
331
|
+
try {
|
|
332
|
+
const rec = await saveAttachment(Buffer.from(raw, "base64"), mimeType);
|
|
333
|
+
attachmentUrl = rec.url;
|
|
334
|
+
attachmentHash = rec.hash;
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
/* CAS 保存失败不阻断流程 */
|
|
338
|
+
}
|
|
346
339
|
const transcript = bridgeTranscripts.get(idx);
|
|
347
340
|
if (transcript) {
|
|
348
341
|
// Bridged: the text-only main model can't see images, so it gets the
|
|
@@ -356,7 +349,12 @@ export async function buildAttachmentMessages(ctx, attachments) {
|
|
|
356
349
|
type: "text",
|
|
357
350
|
text: `\n<vision-bridge>\n${transcript}\n</vision-bridge>`,
|
|
358
351
|
},
|
|
359
|
-
{
|
|
352
|
+
{
|
|
353
|
+
type: "image",
|
|
354
|
+
data: raw,
|
|
355
|
+
mimeType,
|
|
356
|
+
...(attachmentUrl ? { source: { type: "url", url: attachmentUrl } } : {}),
|
|
357
|
+
},
|
|
360
358
|
],
|
|
361
359
|
display: true,
|
|
362
360
|
details: {
|
|
@@ -364,6 +362,8 @@ export async function buildAttachmentMessages(ctx, attachments) {
|
|
|
364
362
|
path: undefined,
|
|
365
363
|
mode: "bridged",
|
|
366
364
|
size: bytes,
|
|
365
|
+
attachmentUrl,
|
|
366
|
+
attachmentHash,
|
|
367
367
|
},
|
|
368
368
|
},
|
|
369
369
|
});
|
|
@@ -372,7 +372,14 @@ export async function buildAttachmentMessages(ctx, attachments) {
|
|
|
372
372
|
out.push({
|
|
373
373
|
message: {
|
|
374
374
|
customType: "file",
|
|
375
|
-
content: [
|
|
375
|
+
content: [
|
|
376
|
+
{
|
|
377
|
+
type: "image",
|
|
378
|
+
data: raw,
|
|
379
|
+
mimeType,
|
|
380
|
+
...(attachmentUrl ? { source: { type: "url", url: attachmentUrl } } : {}),
|
|
381
|
+
},
|
|
382
|
+
],
|
|
376
383
|
display: true,
|
|
377
384
|
details: {
|
|
378
385
|
name: att.name ?? "image.png",
|
|
@@ -380,6 +387,8 @@ export async function buildAttachmentMessages(ctx, attachments) {
|
|
|
380
387
|
path: undefined,
|
|
381
388
|
mode: "image",
|
|
382
389
|
size: bytes,
|
|
390
|
+
attachmentUrl,
|
|
391
|
+
attachmentHash,
|
|
383
392
|
},
|
|
384
393
|
},
|
|
385
394
|
});
|
|
@@ -388,9 +397,7 @@ export async function buildAttachmentMessages(ctx, attachments) {
|
|
|
388
397
|
// Raw uploaded file (base64) — no workspace path involved. The bytes are
|
|
389
398
|
// persisted under <dataDir>/uploads/<clientId>/ so the model can read
|
|
390
399
|
// them on demand with its read tool (absolute path, no traversal guard
|
|
391
|
-
// needed — the path is server-generated).
|
|
392
|
-
// so the model sees them immediately; everything else becomes a path
|
|
393
|
-
// reference.
|
|
400
|
+
// needed — the path is server-generated). Always a path reference.
|
|
394
401
|
if (att.fileData) {
|
|
395
402
|
const buf = Buffer.from(att.fileData, "base64");
|
|
396
403
|
const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
|
|
@@ -415,7 +422,22 @@ export async function buildAttachmentMessages(ctx, attachments) {
|
|
|
415
422
|
// Uploaded files live in a GLOBAL per-user dir (not inside the project
|
|
416
423
|
// or the per-client session store) so browsing a repo never picks up
|
|
417
424
|
// uploaded junk: <dataDir>/uploads/<clientId>/(保留期自动清理,见 uploads.ts)。
|
|
418
|
-
|
|
425
|
+
// saveUpload 对非法 clientId/displayName 抛错(消毒失败整条拒绝,绝不
|
|
426
|
+
// 落到别的目录)—— 这里报错跳过该附件,不让一条坏附件中断整条消息。
|
|
427
|
+
let saved;
|
|
428
|
+
try {
|
|
429
|
+
saved = saveUpload(ctx.clientId, att.name ?? "file", buf);
|
|
430
|
+
}
|
|
431
|
+
catch (err) {
|
|
432
|
+
ctx.emit({
|
|
433
|
+
type: "notice",
|
|
434
|
+
level: "error",
|
|
435
|
+
text: `上传文件保存失败,已跳过:${err.message}`,
|
|
436
|
+
textEn: `Failed to save uploaded file, skipped: ${err.message}`,
|
|
437
|
+
});
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
const { abs, displayName: safeName } = saved;
|
|
419
441
|
// Wire format: forward-slash absolute path (the read tool accepts
|
|
420
442
|
// absolute paths; Windows uses "C:/..." — safe inside the XML-ish tag).
|
|
421
443
|
const wirePath = abs.split(sep).join("/");
|
|
@@ -601,6 +623,7 @@ ${transcript}
|
|
|
601
623
|
});
|
|
602
624
|
continue;
|
|
603
625
|
}
|
|
626
|
+
/** Path-only aside — no file content is read or injected. */
|
|
604
627
|
const makeReference = () => ({
|
|
605
628
|
message: {
|
|
606
629
|
customType: "file",
|
|
@@ -614,36 +637,12 @@ ${transcript}
|
|
|
614
637
|
details: { name, path: rel, mode: "reference", size: stat.size },
|
|
615
638
|
},
|
|
616
639
|
});
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
{
|
|
624
|
-
type: "text",
|
|
625
|
-
text: `\n<file path="${rel}">\n\`\`\`\n${decodeText(buf)}\n\`\`\`\n</file>`,
|
|
626
|
-
},
|
|
627
|
-
],
|
|
628
|
-
display: true,
|
|
629
|
-
details: {
|
|
630
|
-
name,
|
|
631
|
-
path: rel,
|
|
632
|
-
mode: "inline",
|
|
633
|
-
size: stat.size,
|
|
634
|
-
lines,
|
|
635
|
-
},
|
|
636
|
-
},
|
|
637
|
-
};
|
|
638
|
-
};
|
|
639
|
-
// Reference mode is always honored and never reads the file.
|
|
640
|
-
if (att.mode === "reference") {
|
|
641
|
-
out.push(makeReference());
|
|
642
|
-
continue;
|
|
643
|
-
}
|
|
644
|
-
// Line-range mode: inline only the selected 1-based inclusive range.
|
|
645
|
-
// Reading is capped so a huge file can't exhaust memory even though
|
|
646
|
-
// the selected slice is small.
|
|
640
|
+
// Everything else is a PATH REFERENCE: the file content is never injected
|
|
641
|
+
// into the prompt (small files included) — the model reads what it needs
|
|
642
|
+
// with its own read tool (built-in truncation/pagination).
|
|
643
|
+
// Line-range mode adds the `lines` attribute so the model knows which
|
|
644
|
+
// slice the user picked and only reads that part.
|
|
645
|
+
// A legacy `mode: "inline"` (old client / persisted draft) lands here too.
|
|
647
646
|
if (att.mode === "lines") {
|
|
648
647
|
const range = att.lines;
|
|
649
648
|
if (!range || range.start < 1 || range.end < range.start) {
|
|
@@ -656,52 +655,13 @@ ${transcript}
|
|
|
656
655
|
out.push(makeReference());
|
|
657
656
|
continue;
|
|
658
657
|
}
|
|
659
|
-
if (stat.size > MAX_LINES_READ_BYTES) {
|
|
660
|
-
ctx.emit({
|
|
661
|
-
type: "notice",
|
|
662
|
-
level: "warning",
|
|
663
|
-
text: `文件过大,已改为仅引用:${att.path}`,
|
|
664
|
-
textEn: `File too large, switched to reference-only: ${att.path}`,
|
|
665
|
-
});
|
|
666
|
-
out.push(makeReference());
|
|
667
|
-
continue;
|
|
668
|
-
}
|
|
669
|
-
const buf = await fs.readFile(abs);
|
|
670
|
-
if (buf.includes(0)) {
|
|
671
|
-
ctx.emit({
|
|
672
|
-
type: "notice",
|
|
673
|
-
level: "warning",
|
|
674
|
-
text: `二进制文件已改为仅引用:${att.path}`,
|
|
675
|
-
textEn: `Binary file, switched to reference-only: ${att.path}`,
|
|
676
|
-
});
|
|
677
|
-
out.push(makeReference());
|
|
678
|
-
continue;
|
|
679
|
-
}
|
|
680
|
-
const parts = decodeText(buf).split("\n");
|
|
681
|
-
// A trailing newline yields an empty phantom line — drop it so line
|
|
682
|
-
// numbers match the preview panel.
|
|
683
|
-
if (parts.length > 0 && parts[parts.length - 1] === "")
|
|
684
|
-
parts.pop();
|
|
685
|
-
const start = Math.min(range.start, parts.length);
|
|
686
|
-
const end = Math.min(range.end, parts.length);
|
|
687
|
-
if (start < 1 || end < start) {
|
|
688
|
-
ctx.emit({
|
|
689
|
-
type: "notice",
|
|
690
|
-
level: "warning",
|
|
691
|
-
text: `选中行超出文件范围,已改为仅引用:${att.path}`,
|
|
692
|
-
textEn: `Selected lines out of range, switched to reference-only: ${att.path}`,
|
|
693
|
-
});
|
|
694
|
-
out.push(makeReference());
|
|
695
|
-
continue;
|
|
696
|
-
}
|
|
697
|
-
const selected = parts.slice(start - 1, end).join("\n");
|
|
698
658
|
out.push({
|
|
699
659
|
message: {
|
|
700
660
|
customType: "file",
|
|
701
661
|
content: [
|
|
702
662
|
{
|
|
703
663
|
type: "text",
|
|
704
|
-
text:
|
|
664
|
+
text: `<file path="${rel}" lines="${range.start}-${range.end}" size="${stat.size}" />`,
|
|
705
665
|
},
|
|
706
666
|
],
|
|
707
667
|
display: true,
|
|
@@ -710,57 +670,15 @@ ${transcript}
|
|
|
710
670
|
path: rel,
|
|
711
671
|
mode: "lines",
|
|
712
672
|
size: stat.size,
|
|
713
|
-
lines: end - start + 1,
|
|
714
|
-
startLine: start,
|
|
715
|
-
endLine: end,
|
|
673
|
+
lines: range.end - range.start + 1,
|
|
674
|
+
startLine: range.start,
|
|
675
|
+
endLine: range.end,
|
|
716
676
|
},
|
|
717
677
|
},
|
|
718
678
|
});
|
|
719
679
|
continue;
|
|
720
680
|
}
|
|
721
|
-
|
|
722
|
-
if (att.mode === "inline") {
|
|
723
|
-
if (stat.size > MAX_INLINE_BYTES) {
|
|
724
|
-
ctx.emit({
|
|
725
|
-
type: "notice",
|
|
726
|
-
level: "warning",
|
|
727
|
-
text: `文件过大,已改为仅引用:${att.path}`,
|
|
728
|
-
textEn: `File too large, switched to reference-only: ${att.path}`,
|
|
729
|
-
});
|
|
730
|
-
out.push(makeReference());
|
|
731
|
-
continue;
|
|
732
|
-
}
|
|
733
|
-
const buf = await fs.readFile(abs);
|
|
734
|
-
if (buf.includes(0)) {
|
|
735
|
-
ctx.emit({
|
|
736
|
-
type: "notice",
|
|
737
|
-
level: "warning",
|
|
738
|
-
text: `二进制文件已改为仅引用:${att.path}`,
|
|
739
|
-
textEn: `Binary file, switched to reference-only: ${att.path}`,
|
|
740
|
-
});
|
|
741
|
-
out.push(makeReference());
|
|
742
|
-
continue;
|
|
743
|
-
}
|
|
744
|
-
out.push(makeInline(buf));
|
|
745
|
-
continue;
|
|
746
|
-
}
|
|
747
|
-
// Auto: small files inline, large files reference by path.
|
|
748
|
-
if (stat.size > MAX_INLINE_BYTES) {
|
|
749
|
-
out.push(makeReference());
|
|
750
|
-
continue;
|
|
751
|
-
}
|
|
752
|
-
const buf = await fs.readFile(abs);
|
|
753
|
-
if (buf.includes(0)) {
|
|
754
|
-
ctx.emit({
|
|
755
|
-
type: "notice",
|
|
756
|
-
level: "warning",
|
|
757
|
-
text: `二进制文件已跳过(仅引用路径):${att.path}`,
|
|
758
|
-
textEn: `Binary file skipped (path referenced only): ${att.path}`,
|
|
759
|
-
});
|
|
760
|
-
out.push(makeReference());
|
|
761
|
-
continue;
|
|
762
|
-
}
|
|
763
|
-
out.push(makeInline(buf));
|
|
681
|
+
out.push(makeReference());
|
|
764
682
|
}
|
|
765
683
|
return out;
|
|
766
684
|
}
|
|
@@ -66,7 +66,13 @@ export function shouldTrackBackgroundServer(pid, parents, newPids, serverPid, na
|
|
|
66
66
|
export class BgServerTracker {
|
|
67
67
|
opts;
|
|
68
68
|
servers = new Map();
|
|
69
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* bash 工具开始执行前拍的监听端口快照(tool_execution_start 时设置)。
|
|
71
|
+
* 槽位存「in-flight Promise」而非已解析的 Map:并发 bash 同时开跑时不互相
|
|
72
|
+
* 覆盖(??= 保住最早的一份——更早的基线只会多算新增,落库有去重兜底),
|
|
73
|
+
* 也修掉 fire-and-forget 时序竞态(bash 先于快照返回结束 → trackAfterBash
|
|
74
|
+
* 读到 null 直接漏记,延迟解析的旧快照还会污染下一轮)。
|
|
75
|
+
*/
|
|
70
76
|
listenBefore = null;
|
|
71
77
|
refreshTimer = null;
|
|
72
78
|
constructor(opts) {
|
|
@@ -83,20 +89,21 @@ export class BgServerTracker {
|
|
|
83
89
|
this.refreshTimer = null;
|
|
84
90
|
}
|
|
85
91
|
}
|
|
86
|
-
/** tool_execution_start(bash)
|
|
92
|
+
/** tool_execution_start(bash):先记下「前」快照(快照在途时保留最早的一份)。 */
|
|
87
93
|
snapshotBefore() {
|
|
88
|
-
|
|
89
|
-
this.listenBefore = m;
|
|
90
|
-
});
|
|
94
|
+
this.listenBefore ??= snapshotListeningPorts();
|
|
91
95
|
}
|
|
92
96
|
/** After a bash tool run, wait briefly for background servers to bind,
|
|
93
97
|
* then diff the listening-port snapshot against the pre-run one and
|
|
94
98
|
* remember anything new — those are servers the agent left running. */
|
|
95
99
|
async trackAfterBash() {
|
|
96
|
-
|
|
100
|
+
// 读后清槽:await in-flight 快照(消除 fire-and-forget 的时序竞态),
|
|
101
|
+
// 并发 bash 共用这份最早的基线,各自结束都能对得上。
|
|
102
|
+
const beforePromise = this.listenBefore;
|
|
97
103
|
this.listenBefore = null;
|
|
98
|
-
if (!
|
|
104
|
+
if (!beforePromise)
|
|
99
105
|
return;
|
|
106
|
+
const before = await beforePromise;
|
|
100
107
|
await new Promise((r) => setTimeout(r, BG_BIND_WAIT_MS));
|
|
101
108
|
const after = await snapshotListeningPorts();
|
|
102
109
|
const fresh = [];
|
|
@@ -198,6 +205,22 @@ export class BgServerTracker {
|
|
|
198
205
|
await this.refresh();
|
|
199
206
|
this.push();
|
|
200
207
|
}
|
|
208
|
+
/** 杀前复核端口→pid 归属(与 refresh() 同一判定:端口与 pid 都对得上才算我们的)。
|
|
209
|
+
* 缓存的 pid 可能已过期——原进程退出后端口被无关进程复用时,按缓存 pid 直接
|
|
210
|
+
* killPidTree 会误伤无辜进程树。不符的条目剔除并推送,绝不对其开杀。 */
|
|
211
|
+
async verifyBeforeKill() {
|
|
212
|
+
const now = await snapshotListeningPorts();
|
|
213
|
+
let changed = false;
|
|
214
|
+
// eslint-disable-next-line unicorn/no-useless-spread -- snapshot: handlers may unsubscribe mid-emit
|
|
215
|
+
for (const [port, v] of [...this.servers]) {
|
|
216
|
+
if (now.get(port) !== v.pid) {
|
|
217
|
+
this.servers.delete(port);
|
|
218
|
+
changed = true;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (changed)
|
|
222
|
+
this.push();
|
|
223
|
+
}
|
|
201
224
|
/** Kill ONE background server (by port); returns whether anything was killed. */
|
|
202
225
|
async killOne(port) {
|
|
203
226
|
const entry = this.servers.get(port);
|
|
@@ -211,6 +234,18 @@ export class BgServerTracker {
|
|
|
211
234
|
this.opts.flushSnapshot();
|
|
212
235
|
return false;
|
|
213
236
|
}
|
|
237
|
+
await this.verifyBeforeKill();
|
|
238
|
+
const verified = this.servers.get(port);
|
|
239
|
+
if (!verified || verified.pid !== entry.pid) {
|
|
240
|
+
this.opts.emit({
|
|
241
|
+
type: "notice",
|
|
242
|
+
level: "warning",
|
|
243
|
+
text: `端口 ${port} 已不在原进程(原 pid ${entry.pid})监听,已跳过并刷新列表`,
|
|
244
|
+
textEn: `Port ${port} is no longer held by the original process (pid ${entry.pid}); skipped and list refreshed`,
|
|
245
|
+
});
|
|
246
|
+
this.opts.flushSnapshot();
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
214
249
|
killPidTree(entry.pid);
|
|
215
250
|
this.servers.delete(port);
|
|
216
251
|
this.push();
|
|
@@ -225,6 +260,9 @@ export class BgServerTracker {
|
|
|
225
260
|
}
|
|
226
261
|
/** Kill every background server the agent started; returns the freed ports. */
|
|
227
262
|
async killAll() {
|
|
263
|
+
if (this.servers.size === 0)
|
|
264
|
+
return [];
|
|
265
|
+
await this.verifyBeforeKill();
|
|
228
266
|
if (this.servers.size === 0)
|
|
229
267
|
return [];
|
|
230
268
|
const killed = [];
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// ---------------------------------------------------------------------------
|
|
13
13
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
14
14
|
import { Type } from "typebox";
|
|
15
|
-
import {
|
|
15
|
+
import { pick } from "./i18n.js";
|
|
16
16
|
import { CLAIM_FILES_TOOL_NAME } from "./tool-manager.js";
|
|
17
17
|
import { CLAIM_TTL_MS, resolveClaimPath } from "./claim-store.js";
|
|
18
18
|
/** 工具名(唯一定义在 tool-manager.ts,本模块 re-export 供单测沿用,模式同 present-files-tool.ts)。 */
|
|
@@ -30,13 +30,9 @@ export function makeClaimFilesTool(host, lang) {
|
|
|
30
30
|
return defineTool({
|
|
31
31
|
name: CLAIM_FILES_TOOL_NAME,
|
|
32
32
|
label: "Claim files to avoid parallel conflicts",
|
|
33
|
-
description:
|
|
34
|
-
"
|
|
35
|
-
"Claims expire after ~30 min idle (
|
|
36
|
-
"Paths must stay inside the project directory. Only pi-engine conversations can claim (no customTool surface on DSH).", "声明你要改哪些文件,让同项目的并行对话绕行(纯建议,不拦任何编辑)。" +
|
|
37
|
-
"action=claim 把路径记到本对话名下(先到先得;别人已认领的会告诉你,不抢占);action=release 放掉你自己的(不给 paths = 全放);action=list 看本项目的认领表。" +
|
|
38
|
-
"认领闲置约 30 分钟过期(你每次发 prompt 自动续期),对话关闭时释放。" +
|
|
39
|
-
"路径必须在项目目录内。只有 pi 引擎对话能认领(DSH 无 customTool 注册面)。"),
|
|
33
|
+
description: "Declare files you are about to edit so parallel runs in the same project steer clear (advisory only — never blocks edits). " +
|
|
34
|
+
"claim reserves paths for THIS conversation (first wins; conflicts reported, not overridden); release frees yours (no paths = all yours); list shows the project's claim table. " +
|
|
35
|
+
"Claims expire after ~30 min idle (your prompts refresh them) and release when the conversation closes. Paths must stay inside the project.",
|
|
40
36
|
promptSnippet: "claim files you are about to edit (advisory, first-wins) so parallel runs steer clear",
|
|
41
37
|
parameters: Type.Object({
|
|
42
38
|
action: Type.Optional(Type.String({ description: 'claim = reserve paths; release = free yours; list = show table. Default "list".' })),
|
|
@@ -212,7 +212,8 @@ export class ClaimStore {
|
|
|
212
212
|
save() {
|
|
213
213
|
try {
|
|
214
214
|
mkdirSync(dirname(this.file), { recursive: true });
|
|
215
|
-
|
|
215
|
+
// tmp 带进程唯一后缀:同机多实例(多 worktree/多开)写同一文件不互踩
|
|
216
|
+
const tmp = `${this.file}.${process.pid}.tmp`;
|
|
216
217
|
writeFileSync(tmp, JSON.stringify({ version: CLAIMS_FILE_VERSION, tables: Object.fromEntries(this.tables) }));
|
|
217
218
|
renameSync(tmp, this.file);
|
|
218
219
|
}
|
|
@@ -280,7 +281,8 @@ export async function mergeTouchSidecar(sessionFile, fresh) {
|
|
|
280
281
|
return;
|
|
281
282
|
try {
|
|
282
283
|
const merged = unionTouchLists(readTouchSidecar(sessionFile) ?? [], fresh).slice(0, SIDECAR_MAX_ENTRIES);
|
|
283
|
-
|
|
284
|
+
// tmp 带进程唯一后缀(同 client-state.ts 惯例):避免并发进程互踩
|
|
285
|
+
const tmp = `${sidecarPath(sessionFile)}.${process.pid}.tmp`;
|
|
284
286
|
writeFileSync(tmp, JSON.stringify({ version: 1, updatedAt: Date.now(), files: merged }));
|
|
285
287
|
renameSync(tmp, sidecarPath(sessionFile));
|
|
286
288
|
}
|