paintress 0.5.1 → 0.6.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/README.md +32 -14
- package/README.zh-CN.md +29 -13
- package/dist/cli.js +467 -83
- package/dist/index.js +1 -1
- package/dist/mcp/server.js +1 -1
- package/dist/{render-CzpO1cOe.js → render-BSf62388.js} +268 -96
- package/dist/server-DZRNNWac.js +1599 -0
- package/package.json +1 -1
- package/skills/paintress/SKILL.md +45 -17
- package/ui/dist/assets/index-BFcvu7wr.css +1 -0
- package/ui/dist/assets/index-CEhK1JXE.js +8 -0
- package/ui/dist/assets/marked.esm-DZCVybOJ.js +72 -0
- package/ui/dist/index.html +2 -2
- package/dist/server-vPylVJZf.js +0 -854
- package/ui/dist/assets/index-Dyrrkh92.css +0 -1
- package/ui/dist/assets/index-kPX0SDa0.js +0 -8
package/dist/server-vPylVJZf.js
DELETED
|
@@ -1,854 +0,0 @@
|
|
|
1
|
-
import { A as genId, C as update, E as saveDoc, M as DocumentSchema, N as PEN_DOC_VERSION, O as validateDoc, S as remove, T as loadDoc, b as move, g as batch, k as collectIds, qt as VERSION, t as renderDocument, v as get, x as query, y as insert } from "./render-CzpO1cOe.js";
|
|
2
|
-
import { existsSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { homedir, tmpdir } from "node:os";
|
|
4
|
-
import { z } from "zod";
|
|
5
|
-
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
6
|
-
import { randomUUID } from "node:crypto";
|
|
7
|
-
import { chromium } from "playwright-core";
|
|
8
|
-
import { connect } from "node:net";
|
|
9
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
11
|
-
import { spawn } from "node:child_process";
|
|
12
|
-
// src/doc/create.ts
|
|
13
|
-
function createDoc(opts = {}) {
|
|
14
|
-
const boardId = opts.boardId ?? genId(/* @__PURE__ */ new Set());
|
|
15
|
-
const board = {
|
|
16
|
-
type: "frame",
|
|
17
|
-
id: boardId,
|
|
18
|
-
name: opts.name ?? "Board",
|
|
19
|
-
x: 0,
|
|
20
|
-
y: 0,
|
|
21
|
-
width: opts.width ?? 900,
|
|
22
|
-
height: opts.height ?? 600,
|
|
23
|
-
fill: opts.fill ?? "#FFFFFF"
|
|
24
|
-
};
|
|
25
|
-
const layout = opts.layout ?? "vertical";
|
|
26
|
-
if (layout !== "none") {
|
|
27
|
-
board["layout"] = layout;
|
|
28
|
-
board["gap"] = opts.gap ?? 0;
|
|
29
|
-
board["padding"] = opts.padding ?? 0;
|
|
30
|
-
}
|
|
31
|
-
return {
|
|
32
|
-
doc: DocumentSchema.parse({
|
|
33
|
-
version: PEN_DOC_VERSION,
|
|
34
|
-
fileToken: opts.fileToken ?? randomUUID(),
|
|
35
|
-
variables: {},
|
|
36
|
-
children: [board]
|
|
37
|
-
}),
|
|
38
|
-
boardId
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
// src/shot/screenshot.ts
|
|
42
|
-
/**
|
|
43
|
-
* 截图层:渲染 → 临时 HTML → headless Chromium → PNG(决策 3:两个消费者分开,AI 要 PNG)。
|
|
44
|
-
* 浏览器探测:PTR_BROWSER 环境变量 → 系统 chrome → playwright 浏览器缓存。
|
|
45
|
-
*/
|
|
46
|
-
function findBrowser() {
|
|
47
|
-
const env = process.env["PTR_BROWSER"];
|
|
48
|
-
if (env && existsSync(env)) return env;
|
|
49
|
-
for (const c of [
|
|
50
|
-
"/usr/bin/google-chrome",
|
|
51
|
-
"/usr/bin/google-chrome-stable",
|
|
52
|
-
"/usr/bin/chromium",
|
|
53
|
-
"/usr/bin/chromium-browser"
|
|
54
|
-
]) if (existsSync(c)) return c;
|
|
55
|
-
const cacheDir = join(homedir(), ".cache", "ms-playwright");
|
|
56
|
-
if (existsSync(cacheDir)) {
|
|
57
|
-
const dirs = readdirSync(cacheDir).filter((d) => d.startsWith("chromium-")).sort((a, b) => b.localeCompare(a));
|
|
58
|
-
for (const d of dirs) for (const rel of [
|
|
59
|
-
"chrome-linux/chrome",
|
|
60
|
-
"chrome-linux64/chrome",
|
|
61
|
-
"chrome-headless-shell-linux64/chrome-headless-shell"
|
|
62
|
-
]) {
|
|
63
|
-
const p = join(cacheDir, d, rel);
|
|
64
|
-
if (existsSync(p)) return p;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
69
|
-
function boardLabel(b) {
|
|
70
|
-
return b.name !== void 0 && b.name !== "" ? `${b.name}(id=${b.id})` : b.id;
|
|
71
|
-
}
|
|
72
|
-
/**
|
|
73
|
-
* 画板选择子:id 精确 → name 精确 → name/id 模糊(不区分大小写)。
|
|
74
|
-
* 模糊命中多个时报歧义并列出候选,不猜——猜错会让 AI 以为看到了另一页。
|
|
75
|
-
*/
|
|
76
|
-
function selectBoard(boards, query) {
|
|
77
|
-
if (boards.length === 0) throw new Error("设计稿没有画板(顶层节点为空),无法按画板截图");
|
|
78
|
-
const q = query.trim();
|
|
79
|
-
if (q === "") throw new Error("board 参数不能为空");
|
|
80
|
-
const byId = boards.filter((b) => b.id === q);
|
|
81
|
-
if (byId.length === 1) return byId[0];
|
|
82
|
-
const byName = boards.filter((b) => b.name === q);
|
|
83
|
-
if (byName.length === 1) return byName[0];
|
|
84
|
-
const lower = q.toLowerCase();
|
|
85
|
-
const fuzzy = boards.filter((b) => b.id.toLowerCase().includes(lower) || (b.name ?? "").toLowerCase().includes(lower));
|
|
86
|
-
if (fuzzy.length === 1) return fuzzy[0];
|
|
87
|
-
const all = boards.map(boardLabel).join("、");
|
|
88
|
-
if (fuzzy.length === 0) throw new Error(`没有匹配「${q}」的画板;可选:${all}`);
|
|
89
|
-
throw new Error(`「${q}」命中 ${fuzzy.length} 个画板,请写更精确的名字或 id:${fuzzy.map(boardLabel).join("、")}`);
|
|
90
|
-
}
|
|
91
|
-
/** 渲染并截图整张画布;指定 board 时只裁该画板(走同一套渲染与浏览器) */
|
|
92
|
-
async function screenshotPen(penPath, outPng, opts = {}) {
|
|
93
|
-
const browserPath = findBrowser();
|
|
94
|
-
if (!browserPath) throw new Error("未找到可用浏览器:安装 Chromium/Chrome,或设置 PTR_BROWSER 指向可执行文件");
|
|
95
|
-
const loaded = loadDoc(penPath);
|
|
96
|
-
const rendered = renderDocument(loaded.doc);
|
|
97
|
-
const target = opts.board !== void 0 ? selectBoard(rendered.boards, opts.board) : void 0;
|
|
98
|
-
if (target !== void 0 && (target.w <= 0 || target.h <= 0)) throw new Error(`画板 ${boardLabel(target)} 尺寸为 0(${target.w}×${target.h}),无法截图`);
|
|
99
|
-
const viewport = {
|
|
100
|
-
width: Math.max(rendered.canvas.width, target !== void 0 ? Math.ceil(target.x + target.w) : 0),
|
|
101
|
-
height: Math.max(rendered.canvas.height, target !== void 0 ? Math.ceil(target.y + target.h) : 0)
|
|
102
|
-
};
|
|
103
|
-
const clip = target !== void 0 ? {
|
|
104
|
-
x: target.x,
|
|
105
|
-
y: target.y,
|
|
106
|
-
width: Math.ceil(target.w),
|
|
107
|
-
height: Math.ceil(target.h)
|
|
108
|
-
} : {
|
|
109
|
-
x: 0,
|
|
110
|
-
y: 0,
|
|
111
|
-
width: rendered.canvas.width,
|
|
112
|
-
height: rendered.canvas.height
|
|
113
|
-
};
|
|
114
|
-
const tmpDir = mkdtempSync(join(tmpdir(), "paintress-shot-"));
|
|
115
|
-
const htmlPath = join(tmpDir, "page.html");
|
|
116
|
-
writeFileSync(htmlPath, rendered.html, "utf8");
|
|
117
|
-
try {
|
|
118
|
-
const browser = await chromium.launch({
|
|
119
|
-
executablePath: browserPath,
|
|
120
|
-
args: ["--no-sandbox", "--disable-dev-shm-usage"]
|
|
121
|
-
});
|
|
122
|
-
try {
|
|
123
|
-
const page = await browser.newPage({
|
|
124
|
-
viewport,
|
|
125
|
-
deviceScaleFactor: opts.scale ?? 2
|
|
126
|
-
});
|
|
127
|
-
await page.goto(`file://${htmlPath}`);
|
|
128
|
-
await page.waitForTimeout(opts.settleMs ?? 120);
|
|
129
|
-
await page.screenshot({
|
|
130
|
-
path: outPng,
|
|
131
|
-
clip
|
|
132
|
-
});
|
|
133
|
-
} finally {
|
|
134
|
-
await browser.close();
|
|
135
|
-
}
|
|
136
|
-
} finally {
|
|
137
|
-
rmSync(tmpDir, {
|
|
138
|
-
recursive: true,
|
|
139
|
-
force: true
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
if (statSync(outPng).size === 0) throw new Error("截图产物为空文件");
|
|
143
|
-
const result = {
|
|
144
|
-
out: outPng,
|
|
145
|
-
canvas: rendered.canvas,
|
|
146
|
-
warnings: rendered.warnings,
|
|
147
|
-
browser: browserPath
|
|
148
|
-
};
|
|
149
|
-
if (target !== void 0) result.board = {
|
|
150
|
-
id: target.id,
|
|
151
|
-
...target.name !== void 0 ? { name: target.name } : {},
|
|
152
|
-
x: target.x,
|
|
153
|
-
y: target.y,
|
|
154
|
-
width: target.w,
|
|
155
|
-
height: target.h
|
|
156
|
-
};
|
|
157
|
-
return result;
|
|
158
|
-
}
|
|
159
|
-
// src/web/protocol.ts
|
|
160
|
-
function encodeLine(msg) {
|
|
161
|
-
return `${JSON.stringify(msg)}\n`;
|
|
162
|
-
}
|
|
163
|
-
/** 逐行解析器:处理流式分片,容忍尾部不完整行 */
|
|
164
|
-
var LineDecoder = class {
|
|
165
|
-
buf = "";
|
|
166
|
-
push(chunk) {
|
|
167
|
-
this.buf += chunk;
|
|
168
|
-
const out = [];
|
|
169
|
-
for (;;) {
|
|
170
|
-
const idx = this.buf.indexOf("\n");
|
|
171
|
-
if (idx < 0) break;
|
|
172
|
-
const line = this.buf.slice(0, idx);
|
|
173
|
-
this.buf = this.buf.slice(idx + 1);
|
|
174
|
-
if (line.trim() !== "") out.push(line);
|
|
175
|
-
}
|
|
176
|
-
return out;
|
|
177
|
-
}
|
|
178
|
-
};
|
|
179
|
-
// src/web/paths.ts
|
|
180
|
-
/** 运行期文件位置:默认 ~/.paintress/;可用 PAINTRESS_HOME 覆盖(测试隔离用) */
|
|
181
|
-
function runtimeDir() {
|
|
182
|
-
const dir = process.env["PAINTRESS_HOME"] ?? join(homedir(), ".paintress");
|
|
183
|
-
mkdirSync(dir, {
|
|
184
|
-
recursive: true,
|
|
185
|
-
mode: 448
|
|
186
|
-
});
|
|
187
|
-
return dir;
|
|
188
|
-
}
|
|
189
|
-
function socketPath() {
|
|
190
|
-
return join(runtimeDir(), "web.sock");
|
|
191
|
-
}
|
|
192
|
-
function lockPath() {
|
|
193
|
-
return join(runtimeDir(), "web.lock");
|
|
194
|
-
}
|
|
195
|
-
function pidPath() {
|
|
196
|
-
return join(runtimeDir(), "web.pid");
|
|
197
|
-
}
|
|
198
|
-
/** 项目名:URL 安全(字母数字点下划线连字符),冲突时加短 hash 后缀 */
|
|
199
|
-
function sanitizeProjectName(name) {
|
|
200
|
-
const cleaned = name.replace(/[^A-Za-z0-9._-]/g, "-").replace(/^[.-]+/, "");
|
|
201
|
-
return cleaned === "" ? "project" : cleaned;
|
|
202
|
-
}
|
|
203
|
-
/**
|
|
204
|
-
* 由目录推导默认项目名:`父目录/自身`(适配 repos/<org>/<project> 结构)。
|
|
205
|
-
* 均为 URL 安全字符;保留两级可读性。
|
|
206
|
-
*/
|
|
207
|
-
function deriveProjectName(dir) {
|
|
208
|
-
const abs = resolve(dir);
|
|
209
|
-
const parent = basename(dirname(abs));
|
|
210
|
-
const self = basename(abs);
|
|
211
|
-
return sanitizeProjectName((parent && parent !== "/" ? `${parent}/${self}` : self).replace(/\//g, "-"));
|
|
212
|
-
}
|
|
213
|
-
/** 校验并解析注册目录:必须存在且是目录;返回真实路径(供穿越校验基准) */
|
|
214
|
-
function resolveProjectDir(dir) {
|
|
215
|
-
const abs = resolve(dir);
|
|
216
|
-
if (!existsSync(abs)) throw new Error(`目录不存在:${abs}`);
|
|
217
|
-
if (!statSync(abs).isDirectory()) throw new Error(`不是目录:${abs}`);
|
|
218
|
-
return {
|
|
219
|
-
root: abs,
|
|
220
|
-
rootReal: realpathSync(abs)
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
/**
|
|
224
|
-
* 路径穿越防护:把请求路径解析为真实绝对路径,并确认它仍在项目根内。
|
|
225
|
-
* 失败返回 null(调用方回 404,不泄露真实路径)。
|
|
226
|
-
*/
|
|
227
|
-
function safeJoin(projectRootReal, requestPath) {
|
|
228
|
-
if (requestPath.includes("\0")) return null;
|
|
229
|
-
const normalized = requestPath.replace(/^\/+/, "");
|
|
230
|
-
const target = resolve(join(projectRootReal, normalized));
|
|
231
|
-
const relStr = relative(projectRootReal, target);
|
|
232
|
-
if (relStr === "" || relStr.startsWith("..") || relStr.includes(`..${sep}`)) return null;
|
|
233
|
-
if (!existsSync(target)) return null;
|
|
234
|
-
let real;
|
|
235
|
-
try {
|
|
236
|
-
real = realpathSync(target);
|
|
237
|
-
} catch {
|
|
238
|
-
return null;
|
|
239
|
-
}
|
|
240
|
-
const relReal = relative(projectRootReal, real);
|
|
241
|
-
if (relReal.startsWith("..") || relReal === "") return null;
|
|
242
|
-
return real;
|
|
243
|
-
}
|
|
244
|
-
// src/web/client.ts
|
|
245
|
-
/** 控制面客户端:一次请求/响应,或保持长连接(attach 用) */
|
|
246
|
-
function isDaemonRunning() {
|
|
247
|
-
return existsSync(socketPath());
|
|
248
|
-
}
|
|
249
|
-
function connectSocket() {
|
|
250
|
-
return new Promise((resolve, reject) => {
|
|
251
|
-
const sock = connect(socketPath());
|
|
252
|
-
const onError = (err) => {
|
|
253
|
-
sock.destroy();
|
|
254
|
-
reject(/* @__PURE__ */ new Error(`无法连接 paintress web(${socketPath()}):${err.message}`));
|
|
255
|
-
};
|
|
256
|
-
sock.once("error", onError);
|
|
257
|
-
sock.once("connect", () => {
|
|
258
|
-
sock.off("error", onError);
|
|
259
|
-
sock.on("error", () => {});
|
|
260
|
-
resolve(sock);
|
|
261
|
-
});
|
|
262
|
-
});
|
|
263
|
-
}
|
|
264
|
-
/** 单次请求;超时默认 5s */
|
|
265
|
-
async function sendRequest(req, timeoutMs = 5e3) {
|
|
266
|
-
const sock = await connectSocket();
|
|
267
|
-
return new Promise((resolve, reject) => {
|
|
268
|
-
const decoder = new LineDecoder();
|
|
269
|
-
const timer = setTimeout(() => {
|
|
270
|
-
sock.destroy();
|
|
271
|
-
reject(/* @__PURE__ */ new Error(`请求超时(${timeoutMs}ms)`));
|
|
272
|
-
}, timeoutMs);
|
|
273
|
-
sock.on("data", (chunk) => {
|
|
274
|
-
for (const line of decoder.push(chunk.toString("utf8"))) {
|
|
275
|
-
let parsed;
|
|
276
|
-
try {
|
|
277
|
-
parsed = JSON.parse(line);
|
|
278
|
-
} catch {
|
|
279
|
-
continue;
|
|
280
|
-
}
|
|
281
|
-
clearTimeout(timer);
|
|
282
|
-
sock.end();
|
|
283
|
-
resolve(parsed);
|
|
284
|
-
return;
|
|
285
|
-
}
|
|
286
|
-
});
|
|
287
|
-
sock.on("close", () => {
|
|
288
|
-
clearTimeout(timer);
|
|
289
|
-
reject(/* @__PURE__ */ new Error("daemon 在响应前关闭了连接"));
|
|
290
|
-
});
|
|
291
|
-
sock.on("error", (err) => {
|
|
292
|
-
clearTimeout(timer);
|
|
293
|
-
reject(err);
|
|
294
|
-
});
|
|
295
|
-
sock.write(encodeLine(req));
|
|
296
|
-
});
|
|
297
|
-
}
|
|
298
|
-
/**
|
|
299
|
-
* 持久 attach:连接保持打开,进程退出时 OS 关闭连接 → daemon 立即感知。
|
|
300
|
-
* 这取代了 PID 轮询(PID 会被内核复用,不可靠)。
|
|
301
|
-
*/
|
|
302
|
-
async function attachPersistent(project) {
|
|
303
|
-
const sock = await connectSocket();
|
|
304
|
-
const decoder = new LineDecoder();
|
|
305
|
-
let closed = false;
|
|
306
|
-
const closeCbs = [];
|
|
307
|
-
return new Promise((resolve, reject) => {
|
|
308
|
-
const timer = setTimeout(() => {
|
|
309
|
-
sock.destroy();
|
|
310
|
-
reject(/* @__PURE__ */ new Error("attach 超时"));
|
|
311
|
-
}, 5e3);
|
|
312
|
-
sock.on("data", (chunk) => {
|
|
313
|
-
for (const line of decoder.push(chunk.toString("utf8"))) {
|
|
314
|
-
let parsed;
|
|
315
|
-
try {
|
|
316
|
-
parsed = JSON.parse(line);
|
|
317
|
-
} catch {
|
|
318
|
-
continue;
|
|
319
|
-
}
|
|
320
|
-
if (parsed.ok && "attached" in parsed) {
|
|
321
|
-
clearTimeout(timer);
|
|
322
|
-
resolve({
|
|
323
|
-
close: () => {
|
|
324
|
-
if (closed) return;
|
|
325
|
-
closed = true;
|
|
326
|
-
sock.end();
|
|
327
|
-
},
|
|
328
|
-
onClose: (cb) => closeCbs.push(cb)
|
|
329
|
-
});
|
|
330
|
-
} else if (!parsed.ok) {
|
|
331
|
-
clearTimeout(timer);
|
|
332
|
-
sock.destroy();
|
|
333
|
-
reject(new Error(parsed.error));
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
});
|
|
337
|
-
sock.on("close", () => {
|
|
338
|
-
closed = true;
|
|
339
|
-
for (const cb of closeCbs) cb();
|
|
340
|
-
});
|
|
341
|
-
sock.on("error", (err) => {
|
|
342
|
-
clearTimeout(timer);
|
|
343
|
-
reject(err);
|
|
344
|
-
});
|
|
345
|
-
sock.write(encodeLine({
|
|
346
|
-
kind: "attach",
|
|
347
|
-
project
|
|
348
|
-
}));
|
|
349
|
-
});
|
|
350
|
-
}
|
|
351
|
-
// src/mcp/tools.ts
|
|
352
|
-
function reqStr(args, key) {
|
|
353
|
-
const v = args[key];
|
|
354
|
-
if (typeof v !== "string" || v === "") throw new Error(`缺少必填参数:${key}`);
|
|
355
|
-
return v;
|
|
356
|
-
}
|
|
357
|
-
function optStr(args, key) {
|
|
358
|
-
const v = args[key];
|
|
359
|
-
return typeof v === "string" && v !== "" ? v : void 0;
|
|
360
|
-
}
|
|
361
|
-
function optNum(args, key) {
|
|
362
|
-
const v = args[key];
|
|
363
|
-
return typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
364
|
-
}
|
|
365
|
-
function optBool(args, key) {
|
|
366
|
-
const v = args[key];
|
|
367
|
-
return typeof v === "boolean" ? v : void 0;
|
|
368
|
-
}
|
|
369
|
-
function objArg(args, key) {
|
|
370
|
-
const v = args[key];
|
|
371
|
-
if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error(`参数 ${key} 必须是对象`);
|
|
372
|
-
return v;
|
|
373
|
-
}
|
|
374
|
-
/** 读:load 后执行,直接返回负载 */
|
|
375
|
-
function read(path, fn) {
|
|
376
|
-
return fn(loadDoc(path));
|
|
377
|
-
}
|
|
378
|
-
/**
|
|
379
|
-
* 把「本次写操作动到的节点 id」告知 daemon,供前端做变更高亮。
|
|
380
|
-
* 先转绝对路径:daemon 的 cwd 未必与调用方一致,不能让 daemon 替我们 resolve 相对路径。
|
|
381
|
-
* 非阻塞发送(写路径性能语义不变);socket 默认有 ref,进程不会在响应前提前退出。
|
|
382
|
-
*/
|
|
383
|
-
function notifyChanges(path, ids) {
|
|
384
|
-
if (ids.length === 0 || !isDaemonRunning()) return;
|
|
385
|
-
sendRequest({
|
|
386
|
-
kind: "changes",
|
|
387
|
-
path: resolve(path),
|
|
388
|
-
ids
|
|
389
|
-
}, 2e3).catch(() => {});
|
|
390
|
-
}
|
|
391
|
-
/**
|
|
392
|
-
* 写:load → 变更 → 校验(结构错误不落盘)→ 原子保存(保持原风味)。
|
|
393
|
-
* fn 可通过 mark() 登记受影响的节点 id,写成功后统一通知 daemon。
|
|
394
|
-
*/
|
|
395
|
-
async function write(path, fn) {
|
|
396
|
-
const loaded = loadDoc(path);
|
|
397
|
-
const touched = [];
|
|
398
|
-
const mark = (ids) => {
|
|
399
|
-
touched.push(...ids);
|
|
400
|
-
};
|
|
401
|
-
const result = fn(loaded.doc, mark);
|
|
402
|
-
const v = validateDoc(loaded.doc);
|
|
403
|
-
if (!v.ok) throw new Error(`变更后结构校验失败,未落盘:\n${v.errors.map((e) => ` ${e.path}: ${e.message}`).join("\n")}`);
|
|
404
|
-
saveDoc(path, loaded.doc, {
|
|
405
|
-
expectedMtimeMs: loaded.mtimeMs,
|
|
406
|
-
flavor: loaded.flavor
|
|
407
|
-
});
|
|
408
|
-
notifyChanges(path, touched);
|
|
409
|
-
return {
|
|
410
|
-
ok: true,
|
|
411
|
-
result,
|
|
412
|
-
warnings: v.warnings
|
|
413
|
-
};
|
|
414
|
-
}
|
|
415
|
-
function countByType(children) {
|
|
416
|
-
const counts = {};
|
|
417
|
-
const walk = (nodes) => {
|
|
418
|
-
for (const n of nodes) {
|
|
419
|
-
if (!n || typeof n !== "object") continue;
|
|
420
|
-
const rec = n;
|
|
421
|
-
const t = typeof rec["type"] === "string" ? rec["type"] : "?";
|
|
422
|
-
counts[t] = (counts[t] ?? 0) + 1;
|
|
423
|
-
const kids = rec["children"];
|
|
424
|
-
if (Array.isArray(kids)) walk(kids);
|
|
425
|
-
}
|
|
426
|
-
};
|
|
427
|
-
walk(children);
|
|
428
|
-
return counts;
|
|
429
|
-
}
|
|
430
|
-
/**
|
|
431
|
-
* 新建文档。path 不带扩展名时补 `.ptr`:新建一律默认标准格式,
|
|
432
|
-
* `.pen` 只有在调用方显式写扩展名时才产出(兼容旧工具链)。
|
|
433
|
-
*/
|
|
434
|
-
function newDoc(args) {
|
|
435
|
-
const raw = reqStr(args, "path");
|
|
436
|
-
const path = /\.(pen|ptr)$/i.test(raw) ? raw : `${raw}.ptr`;
|
|
437
|
-
const flavor = path.toLowerCase().endsWith(".pen") ? "pen" : "ptr";
|
|
438
|
-
if (existsSync(path) && optBool(args, "overwrite") !== true) throw new Error(`文件已存在,未覆盖:${path}(确需覆盖传 overwrite=true)`);
|
|
439
|
-
const width = optNum(args, "width");
|
|
440
|
-
const height = optNum(args, "height");
|
|
441
|
-
if (width !== void 0 && width <= 0) throw new Error(`width 必须大于 0,收到:${width}`);
|
|
442
|
-
if (height !== void 0 && height <= 0) throw new Error(`height 必须大于 0,收到:${height}`);
|
|
443
|
-
const layoutArg = optStr(args, "layout");
|
|
444
|
-
if (layoutArg !== void 0 && ![
|
|
445
|
-
"vertical",
|
|
446
|
-
"horizontal",
|
|
447
|
-
"none"
|
|
448
|
-
].includes(layoutArg)) throw new Error(`layout 仅支持 vertical / horizontal / none,收到:${layoutArg}`);
|
|
449
|
-
const { doc, boardId } = createDoc({
|
|
450
|
-
name: optStr(args, "name"),
|
|
451
|
-
width,
|
|
452
|
-
height,
|
|
453
|
-
fill: optStr(args, "fill"),
|
|
454
|
-
layout: layoutArg === "vertical" || layoutArg === "horizontal" || layoutArg === "none" ? layoutArg : void 0,
|
|
455
|
-
padding: optNum(args, "padding"),
|
|
456
|
-
gap: optNum(args, "gap")
|
|
457
|
-
});
|
|
458
|
-
const check = validateDoc(doc);
|
|
459
|
-
if (!check.ok) throw new Error(`新建文档结构校验失败,未落盘:\n${check.errors.map((e) => ` ${e.path}: ${e.message}`).join("\n")}`);
|
|
460
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
461
|
-
saveDoc(path, doc, {
|
|
462
|
-
flavor,
|
|
463
|
-
createOnly: optBool(args, "overwrite") !== true
|
|
464
|
-
});
|
|
465
|
-
const board = doc.children[0];
|
|
466
|
-
return {
|
|
467
|
-
ok: true,
|
|
468
|
-
path,
|
|
469
|
-
flavor,
|
|
470
|
-
version: doc.version,
|
|
471
|
-
boardId,
|
|
472
|
-
canvas: {
|
|
473
|
-
width: Number(board?.["width"] ?? 0),
|
|
474
|
-
height: Number(board?.["height"] ?? 0)
|
|
475
|
-
},
|
|
476
|
-
warnings: check.warnings
|
|
477
|
-
};
|
|
478
|
-
}
|
|
479
|
-
const TOOLS = {
|
|
480
|
-
paintress_new: {
|
|
481
|
-
description: "新建文档(.ptr 是 paintress 的标准格式;.pen 仅作兼容)。创建一个带空 board 的最小可用文档,可直接 render/screenshot 再逐层 insert;已存在同名文件时报错,除非 overwrite=true。path 不带扩展名时自动补 .ptr",
|
|
482
|
-
inputSchema: {
|
|
483
|
-
path: z.string().describe("新文档路径;不带扩展名时自动补 .ptr"),
|
|
484
|
-
name: z.string().optional().describe("board 名称(默认 Board)"),
|
|
485
|
-
width: z.number().positive().optional().describe("board 宽(默认 900)"),
|
|
486
|
-
height: z.number().positive().optional().describe("board 高(默认 600)"),
|
|
487
|
-
fill: z.string().optional().describe("board 背景色(默认 #FFFFFF)"),
|
|
488
|
-
layout: z.enum([
|
|
489
|
-
"vertical",
|
|
490
|
-
"horizontal",
|
|
491
|
-
"none"
|
|
492
|
-
]).optional().describe("子节点布局(默认 vertical)"),
|
|
493
|
-
padding: z.number().optional().describe("board 内边距(默认 0)"),
|
|
494
|
-
gap: z.number().optional().describe("主轴向间距(默认 0)"),
|
|
495
|
-
overwrite: z.boolean().optional().describe("已存在时覆盖(默认 false)")
|
|
496
|
-
},
|
|
497
|
-
run: (a) => newDoc(a)
|
|
498
|
-
},
|
|
499
|
-
paintress_doc_info: {
|
|
500
|
-
description: "文档摘要:版本、格式风味、节点统计、变量表、顶层节点",
|
|
501
|
-
inputSchema: { path: z.string().describe("设计稿路径") },
|
|
502
|
-
run: (a) => read(reqStr(a, "path"), (ctx) => ({
|
|
503
|
-
flavor: ctx.flavor,
|
|
504
|
-
version: ctx.doc.version,
|
|
505
|
-
fileToken: ctx.doc.fileToken,
|
|
506
|
-
variables: ctx.doc.variables ? Object.keys(ctx.doc.variables) : [],
|
|
507
|
-
topLevel: ctx.doc.children.map((c) => ({
|
|
508
|
-
id: c["id"],
|
|
509
|
-
name: c["name"],
|
|
510
|
-
type: c["type"]
|
|
511
|
-
})),
|
|
512
|
-
nodeCount: Object.values(countByType(ctx.doc.children)).reduce((s, n) => s + n, 0),
|
|
513
|
-
byType: countByType(ctx.doc.children),
|
|
514
|
-
warnings: ctx.validation.warnings
|
|
515
|
-
}))
|
|
516
|
-
},
|
|
517
|
-
paintress_query: {
|
|
518
|
-
description: "条件查询节点:type / name(精确或正则)/ id / reusable",
|
|
519
|
-
inputSchema: {
|
|
520
|
-
path: z.string(),
|
|
521
|
-
type: z.string().optional(),
|
|
522
|
-
name: z.string().optional(),
|
|
523
|
-
nameRegex: z.boolean().optional().describe("name 按正则解释"),
|
|
524
|
-
id: z.string().optional(),
|
|
525
|
-
reusable: z.boolean().optional()
|
|
526
|
-
},
|
|
527
|
-
run: (a) => read(reqStr(a, "path"), (ctx) => query(ctx.doc, {
|
|
528
|
-
type: optStr(a, "type"),
|
|
529
|
-
name: optStr(a, "name"),
|
|
530
|
-
nameRegex: optBool(a, "nameRegex"),
|
|
531
|
-
id: optStr(a, "id"),
|
|
532
|
-
reusable: optBool(a, "reusable")
|
|
533
|
-
}))
|
|
534
|
-
},
|
|
535
|
-
paintress_get: {
|
|
536
|
-
description: "取单个节点子树;depth 控制深度(0=仅本节点,children 省略为 …)",
|
|
537
|
-
inputSchema: {
|
|
538
|
-
path: z.string(),
|
|
539
|
-
id: z.string(),
|
|
540
|
-
depth: z.number().int().min(0).optional()
|
|
541
|
-
},
|
|
542
|
-
run: (a) => read(reqStr(a, "path"), (ctx) => {
|
|
543
|
-
const res = get(ctx.doc, reqStr(a, "id"), optNum(a, "depth") ?? Infinity);
|
|
544
|
-
if (!res) throw new Error(`节点不存在:${reqStr(a, "id")}`);
|
|
545
|
-
return res;
|
|
546
|
-
})
|
|
547
|
-
},
|
|
548
|
-
paintress_update: {
|
|
549
|
-
description: "浅合并更新节点属性(id 与 type 不可改)",
|
|
550
|
-
inputSchema: {
|
|
551
|
-
path: z.string(),
|
|
552
|
-
id: z.string(),
|
|
553
|
-
patch: z.record(z.unknown())
|
|
554
|
-
},
|
|
555
|
-
run: (a) => write(reqStr(a, "path"), (doc, mark) => {
|
|
556
|
-
const id = reqStr(a, "id");
|
|
557
|
-
const res = update(doc, id, objArg(a, "patch"));
|
|
558
|
-
mark([id]);
|
|
559
|
-
return res;
|
|
560
|
-
})
|
|
561
|
-
},
|
|
562
|
-
paintress_insert: {
|
|
563
|
-
description: "插入子节点(缺 id 自动生成;返回新 id)",
|
|
564
|
-
inputSchema: {
|
|
565
|
-
path: z.string(),
|
|
566
|
-
parent: z.string().describe("父节点 id,或 'root'"),
|
|
567
|
-
node: z.record(z.unknown()),
|
|
568
|
-
index: z.number().int().min(0).optional()
|
|
569
|
-
},
|
|
570
|
-
run: (a) => write(reqStr(a, "path"), (doc, mark) => {
|
|
571
|
-
const newId = insert(doc, reqStr(a, "parent"), objArg(a, "node"), optNum(a, "index"));
|
|
572
|
-
mark([newId]);
|
|
573
|
-
return newId;
|
|
574
|
-
})
|
|
575
|
-
},
|
|
576
|
-
paintress_delete: {
|
|
577
|
-
description: "删除节点(含子树)",
|
|
578
|
-
inputSchema: {
|
|
579
|
-
path: z.string(),
|
|
580
|
-
id: z.string()
|
|
581
|
-
},
|
|
582
|
-
run: (a) => write(reqStr(a, "path"), (doc, mark) => {
|
|
583
|
-
const id = reqStr(a, "id");
|
|
584
|
-
const node = remove(doc, id);
|
|
585
|
-
mark([id]);
|
|
586
|
-
return { deleted: {
|
|
587
|
-
id: node["id"],
|
|
588
|
-
name: node["name"],
|
|
589
|
-
type: node["type"]
|
|
590
|
-
} };
|
|
591
|
-
})
|
|
592
|
-
},
|
|
593
|
-
paintress_move: {
|
|
594
|
-
description: "移动/重排节点到新父(防环校验)",
|
|
595
|
-
inputSchema: {
|
|
596
|
-
path: z.string(),
|
|
597
|
-
id: z.string(),
|
|
598
|
-
parent: z.string(),
|
|
599
|
-
index: z.number().int().min(0).optional()
|
|
600
|
-
},
|
|
601
|
-
run: (a) => write(reqStr(a, "path"), (doc, mark) => {
|
|
602
|
-
const id = reqStr(a, "id");
|
|
603
|
-
move(doc, id, reqStr(a, "parent"), optNum(a, "index"));
|
|
604
|
-
mark([id]);
|
|
605
|
-
return {
|
|
606
|
-
moved: id,
|
|
607
|
-
to: reqStr(a, "parent")
|
|
608
|
-
};
|
|
609
|
-
})
|
|
610
|
-
},
|
|
611
|
-
paintress_batch: {
|
|
612
|
-
description: "批量原子操作:update/insert/delete/move 数组,任一步失败整体作废",
|
|
613
|
-
inputSchema: {
|
|
614
|
-
path: z.string(),
|
|
615
|
-
ops: z.array(z.record(z.unknown()))
|
|
616
|
-
},
|
|
617
|
-
run: (a) => write(reqStr(a, "path"), (doc, mark) => {
|
|
618
|
-
const raw = a["ops"];
|
|
619
|
-
if (!Array.isArray(raw)) throw new Error("ops 必须是数组");
|
|
620
|
-
const ops = raw.map((op, idx) => {
|
|
621
|
-
const rec = op;
|
|
622
|
-
const kind = rec["op"];
|
|
623
|
-
if (kind === "update") return {
|
|
624
|
-
op: "update",
|
|
625
|
-
id: String(rec["id"]),
|
|
626
|
-
patch: rec["patch"] ?? {}
|
|
627
|
-
};
|
|
628
|
-
if (kind === "insert") return {
|
|
629
|
-
op: "insert",
|
|
630
|
-
parent: String(rec["parent"]),
|
|
631
|
-
node: rec["node"],
|
|
632
|
-
index: typeof rec["index"] === "number" ? rec["index"] : void 0
|
|
633
|
-
};
|
|
634
|
-
if (kind === "delete") return {
|
|
635
|
-
op: "delete",
|
|
636
|
-
id: String(rec["id"])
|
|
637
|
-
};
|
|
638
|
-
if (kind === "move") return {
|
|
639
|
-
op: "move",
|
|
640
|
-
id: String(rec["id"]),
|
|
641
|
-
parent: String(rec["parent"]),
|
|
642
|
-
index: typeof rec["index"] === "number" ? rec["index"] : void 0
|
|
643
|
-
};
|
|
644
|
-
throw new Error(`ops[${idx}] 的 op 必须是 update|insert|delete|move`);
|
|
645
|
-
});
|
|
646
|
-
const before = new Set(collectIds(doc.children));
|
|
647
|
-
doc.children = batch(doc, ops).children;
|
|
648
|
-
const insertedIds = collectIds(doc.children).filter((id) => !before.has(id));
|
|
649
|
-
mark([...ops.flatMap((op) => op.op === "insert" ? [] : [op.id]), ...insertedIds]);
|
|
650
|
-
return {
|
|
651
|
-
applied: ops.length,
|
|
652
|
-
inserted: insertedIds.length
|
|
653
|
-
};
|
|
654
|
-
})
|
|
655
|
-
},
|
|
656
|
-
paintress_validate: {
|
|
657
|
-
description: "校验文档:结构错误 + 语义警告(重复 id、悬空 ref、未知类型、未定义变量)",
|
|
658
|
-
inputSchema: { path: z.string() },
|
|
659
|
-
run: (a) => read(reqStr(a, "path"), (ctx) => ctx.validation)
|
|
660
|
-
},
|
|
661
|
-
paintress_render: {
|
|
662
|
-
description: "渲染设计稿为 HTML 文件(每节点带 data-node-id);返回输出路径与画布尺寸",
|
|
663
|
-
inputSchema: {
|
|
664
|
-
path: z.string(),
|
|
665
|
-
out: z.string().describe("输出 .html 路径")
|
|
666
|
-
},
|
|
667
|
-
run: (a) => {
|
|
668
|
-
const out = reqStr(a, "out");
|
|
669
|
-
const loaded = loadDoc(reqStr(a, "path"));
|
|
670
|
-
const r = renderDocument(loaded.doc);
|
|
671
|
-
writeFileSync(out, r.html, "utf8");
|
|
672
|
-
return {
|
|
673
|
-
ok: true,
|
|
674
|
-
out,
|
|
675
|
-
canvas: r.canvas,
|
|
676
|
-
warnings: r.warnings
|
|
677
|
-
};
|
|
678
|
-
}
|
|
679
|
-
},
|
|
680
|
-
paintress_screenshot: {
|
|
681
|
-
description: "渲染并截图为 PNG(默认 2x);需要本机 Chromium/Chrome 或 PTR_BROWSER 指向的可执行文件。指定 board 时只截该画板(顶层 frame,即“一页”),而不是整张画布",
|
|
682
|
-
inputSchema: {
|
|
683
|
-
path: z.string(),
|
|
684
|
-
out: z.string().describe("输出 .png 路径"),
|
|
685
|
-
scale: z.number().int().min(1).max(4).optional().describe("像素倍率,默认 2"),
|
|
686
|
-
board: z.string().optional().describe("只截某个画板:id 或 name;模糊命中多个时会报错并列出候选")
|
|
687
|
-
},
|
|
688
|
-
run: async (a) => {
|
|
689
|
-
if (!findBrowser()) throw new Error("未找到可用浏览器:安装 Chromium/Chrome,或设置 PTR_BROWSER");
|
|
690
|
-
const board = typeof a["board"] === "string" ? a["board"] : "";
|
|
691
|
-
const res = await screenshotPen(reqStr(a, "path"), reqStr(a, "out"), {
|
|
692
|
-
...typeof a["scale"] === "number" ? { scale: a["scale"] } : {},
|
|
693
|
-
...board !== "" ? { board } : {}
|
|
694
|
-
});
|
|
695
|
-
return {
|
|
696
|
-
ok: true,
|
|
697
|
-
out: res.out,
|
|
698
|
-
canvas: res.canvas,
|
|
699
|
-
...res.board !== void 0 ? { board: res.board } : {},
|
|
700
|
-
browser: res.browser,
|
|
701
|
-
warnings: res.warnings
|
|
702
|
-
};
|
|
703
|
-
}
|
|
704
|
-
},
|
|
705
|
-
paintress_marks: {
|
|
706
|
-
description: "读取/清空当前会话的节点标记:用户在浏览器里点击的节点会分配短号 #1 #2…,返回短号 → 节点 id 映射,便于对话中指代(如“#1 改成蓝色”)",
|
|
707
|
-
inputSchema: {
|
|
708
|
-
project: z.string().optional().describe("项目名;缺省返回全部项目的标记"),
|
|
709
|
-
action: z.enum(["list", "clear"]).optional().describe("默认 list")
|
|
710
|
-
},
|
|
711
|
-
run: async (a) => {
|
|
712
|
-
if (!isDaemonRunning()) throw new Error("paintress web 未运行,无法读取标记;先运行 paintress web --daemon");
|
|
713
|
-
const project = optStr(a, "project") ?? "";
|
|
714
|
-
if ((optStr(a, "action") ?? "list") === "clear") return await sendRequest({
|
|
715
|
-
kind: "clear-marks",
|
|
716
|
-
project
|
|
717
|
-
});
|
|
718
|
-
return await sendRequest({
|
|
719
|
-
kind: "marks",
|
|
720
|
-
project
|
|
721
|
-
});
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
};
|
|
725
|
-
// src/web/spawn.ts
|
|
726
|
-
const START_TIMEOUT_MS = 6e3;
|
|
727
|
-
async function ensureDaemon(opts = {}) {
|
|
728
|
-
if (isDaemonRunning()) {
|
|
729
|
-
const res = await sendRequest({ kind: "ping" });
|
|
730
|
-
if (res.ok && "pong" in res) return {
|
|
731
|
-
started: false,
|
|
732
|
-
pid: res.pid
|
|
733
|
-
};
|
|
734
|
-
}
|
|
735
|
-
const entry = process.argv[1];
|
|
736
|
-
if (!entry) throw new Error("无法确定 CLI 入口路径,不能后台启动 daemon");
|
|
737
|
-
const args = [entry, "web"];
|
|
738
|
-
if (opts.port !== void 0) args.push("--port", String(opts.port));
|
|
739
|
-
if (opts.idleTimeoutMs !== void 0) args.push("--idle", String(opts.idleTimeoutMs));
|
|
740
|
-
spawn(process.execPath, args, {
|
|
741
|
-
detached: true,
|
|
742
|
-
stdio: "ignore"
|
|
743
|
-
}).unref();
|
|
744
|
-
const deadline = Date.now() + START_TIMEOUT_MS;
|
|
745
|
-
while (Date.now() < deadline) {
|
|
746
|
-
if (isDaemonRunning()) {
|
|
747
|
-
const res = await sendRequest({ kind: "ping" });
|
|
748
|
-
if (res.ok && "pong" in res) return {
|
|
749
|
-
started: true,
|
|
750
|
-
pid: res.pid
|
|
751
|
-
};
|
|
752
|
-
}
|
|
753
|
-
await new Promise((r) => setTimeout(r, 100));
|
|
754
|
-
}
|
|
755
|
-
throw new Error(`后台启动 daemon 超时(${START_TIMEOUT_MS}ms)`);
|
|
756
|
-
}
|
|
757
|
-
// src/web/autoregister.ts
|
|
758
|
-
async function autoRegister(cwd = process.cwd()) {
|
|
759
|
-
if (process.env["PAINTRESS_NO_AUTO_WEB"] === "1") return null;
|
|
760
|
-
try {
|
|
761
|
-
const { started } = await ensureDaemon({ idleTimeoutMs: 36e5 });
|
|
762
|
-
const project = deriveProjectName(cwd);
|
|
763
|
-
const watched = await sendRequest({
|
|
764
|
-
kind: "watch",
|
|
765
|
-
project,
|
|
766
|
-
dir: cwd,
|
|
767
|
-
owner: "agent"
|
|
768
|
-
}, 8e3);
|
|
769
|
-
if (!watched.ok) throw new Error(watched.error);
|
|
770
|
-
return {
|
|
771
|
-
handle: await attachPersistent(project),
|
|
772
|
-
project,
|
|
773
|
-
startedDaemon: started
|
|
774
|
-
};
|
|
775
|
-
} catch (cause) {
|
|
776
|
-
process.stderr.write(`paintress: web 自动注册失败(${cause instanceof Error ? cause.message : String(cause)}),MCP 工具不受影响\n`);
|
|
777
|
-
return null;
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
// src/mcp/server.ts
|
|
781
|
-
const SERVER_NAME = "paintress";
|
|
782
|
-
const SERVER_VERSION = VERSION;
|
|
783
|
-
/** 构建 MCP server:把共享工具注册到 stdio 协议上 */
|
|
784
|
-
function createMcpServer() {
|
|
785
|
-
const server = new McpServer({
|
|
786
|
-
name: SERVER_NAME,
|
|
787
|
-
version: SERVER_VERSION
|
|
788
|
-
});
|
|
789
|
-
for (const [name, def] of Object.entries(TOOLS)) server.registerTool(name, {
|
|
790
|
-
description: def.description,
|
|
791
|
-
inputSchema: def.inputSchema
|
|
792
|
-
}, async (args) => {
|
|
793
|
-
try {
|
|
794
|
-
const payload = await def.run(args);
|
|
795
|
-
return { content: [{
|
|
796
|
-
type: "text",
|
|
797
|
-
text: JSON.stringify(payload, null, 2)
|
|
798
|
-
}] };
|
|
799
|
-
} catch (cause) {
|
|
800
|
-
return {
|
|
801
|
-
content: [{
|
|
802
|
-
type: "text",
|
|
803
|
-
text: cause instanceof Error ? cause.message : String(cause)
|
|
804
|
-
}],
|
|
805
|
-
isError: true
|
|
806
|
-
};
|
|
807
|
-
}
|
|
808
|
-
});
|
|
809
|
-
return server;
|
|
810
|
-
}
|
|
811
|
-
/**
|
|
812
|
-
* 父进程看门狗:stdio MCP server 必须随宿主死亡而退出,否则变成孤儿进程
|
|
813
|
-
* (MCP 生态的已知问题)。两道保险:ppid 变化 + stdin EOF。
|
|
814
|
-
*/
|
|
815
|
-
function installParentWatchdog() {
|
|
816
|
-
const parentPid = process.ppid;
|
|
817
|
-
setInterval(() => {
|
|
818
|
-
if (process.ppid !== parentPid) exitClean(0);
|
|
819
|
-
try {
|
|
820
|
-
process.kill(parentPid, 0);
|
|
821
|
-
} catch {
|
|
822
|
-
exitClean(0);
|
|
823
|
-
}
|
|
824
|
-
}, 5e3).unref();
|
|
825
|
-
process.stdin.on("end", () => exitClean(0));
|
|
826
|
-
process.stdin.on("close", () => exitClean(0));
|
|
827
|
-
}
|
|
828
|
-
/**
|
|
829
|
-
* attach 连接引用:必须持有,否则 GC 关闭后 daemon 会误判 agent 已退出。
|
|
830
|
-
* 退出时显式关闭——比等 OS 回收更快让 daemon 看到 we're gone。
|
|
831
|
-
*/
|
|
832
|
-
let registrar = null;
|
|
833
|
-
/** 干净退出:断开 attach 连接后退出 */
|
|
834
|
-
function exitClean(code = 0) {
|
|
835
|
-
try {
|
|
836
|
-
registrar?.handle.close();
|
|
837
|
-
} catch {}
|
|
838
|
-
process.exit(code);
|
|
839
|
-
}
|
|
840
|
-
/** 自动注册自身(best-effort,失败不阻断 MCP) */
|
|
841
|
-
async function registerSelf() {
|
|
842
|
-
const res = await autoRegister();
|
|
843
|
-
registrar = res;
|
|
844
|
-
if (res) process.stderr.write(`paintress: 已注册项目 "${res.project}",浏览器打开 http://localhost:3316/${encodeURIComponent(res.project)}\n`);
|
|
845
|
-
}
|
|
846
|
-
/** CLI `paintress mcp` 入口:stdio transport + 自动注册 */
|
|
847
|
-
async function runStdioServer() {
|
|
848
|
-
const server = createMcpServer();
|
|
849
|
-
const transport = new StdioServerTransport();
|
|
850
|
-
await server.connect(transport);
|
|
851
|
-
installParentWatchdog();
|
|
852
|
-
registerSelf();
|
|
853
|
-
}
|
|
854
|
-
export { isDaemonRunning as a, pidPath as c, sanitizeProjectName as d, socketPath as f, attachPersistent as i, resolveProjectDir as l, encodeLine as m, runStdioServer as n, sendRequest as o, LineDecoder as p, TOOLS as r, lockPath as s, createMcpServer as t, safeJoin as u };
|