paintress 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -5
- package/README.zh-CN.md +4 -3
- package/dist/cli.js +110 -30
- package/dist/index.js +1 -1
- package/dist/mcp/server.js +1 -1
- package/dist/{render-CzpO1cOe.js → render-CCxYNL6A.js} +259 -93
- package/dist/{server-vPylVJZf.js → server-ssrAxbTR.js} +158 -36
- package/package.json +1 -1
- package/skills/paintress/SKILL.md +4 -2
- package/ui/dist/assets/index-BnDj5nL8.js +8 -0
- package/ui/dist/assets/index-CP8lZ8lp.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-Dyrrkh92.css +0 -1
- package/ui/dist/assets/index-kPX0SDa0.js +0 -8
package/README.md
CHANGED
|
@@ -73,16 +73,18 @@ paintress mcp-config <client> print the config snippet
|
|
|
73
73
|
```bash
|
|
74
74
|
paintress web --daemon --idle 3600000
|
|
75
75
|
paintress watch demo ./demo
|
|
76
|
-
# open http://localhost:3316
|
|
76
|
+
# open http://localhost:3316/demo?file=hello.ptr
|
|
77
77
|
```
|
|
78
78
|
|
|
79
79
|
Canvas navigation: wheel zooms at the pointer, space / middle button / empty area drags to pan, `Ctrl/Cmd+0` returns to 100%, `Shift+1` fits the window. Files with more than one board get a board strip. Edits made through the CLI or MCP are highlighted in the viewer, and clicks assign short ids that the agent can read back with `paintress_marks`.
|
|
80
80
|
|
|
81
|
-
Project rows have an **×** on hover that unbinds the project (design files are untouched; an agent re-registers on its next start), and **Stop server** in the sidebar footer shuts the viewer process down — both ask for confirmation first.
|
|
81
|
+
Projects are named after their directory path (`c-thun/raybend`), so the URL reads like the path: `http://localhost:3316/c-thun/raybend?file=design/editor.pen`. Project rows have an **×** on hover that unbinds the project (design files are untouched; an agent re-registers on its next start), and **Stop server** in the sidebar footer shuts the viewer process down — both ask for confirmation first.
|
|
82
|
+
|
|
83
|
+
The viewer follows the agent instead of asking you to manage it. When an agent starts the viewer it opens the browser on its own project; projects appear and leave with a short animation as agents come and go; when the last project is gone a 10-second countdown closes the window and the process. A browser that nobody is looking at no longer keeps the process alive.
|
|
82
84
|
|
|
83
85
|
When npm has a newer release than the running one, the version badge in the header grows a second line in rust red; clicking it opens the upgrade prompt (one copyable sentence) and the GitHub link. The check is client-optional on the daemon side: if npm is unreachable, the badge simply stays as it is.
|
|
84
86
|
|
|
85
|
-
The toolbar's right side has two viewer-only controls. **Canvas background** switches Light / Gray / Dark for the design surface without touching the rest of the UI
|
|
87
|
+
The toolbar's right side has two viewer-only controls. **Canvas background** switches Light / Gray / Dark for the design surface without touching the rest of the UI. **Language** switches the interface between 中文 and EN; on first load it follows the browser, and anything that is not Chinese gets English. Both are per-browser and remembered locally. Registering a project, or dropping a new `.ptr` / `.pen` into a watched directory, updates every open viewer without a reload.
|
|
86
88
|
|
|
87
89
|
## MCP clients
|
|
88
90
|
|
|
@@ -116,18 +118,19 @@ paintress mcp-config cursor # print the exact snippet instead of editing files
|
|
|
116
118
|
|
|
117
119
|
## Release (maintainers)
|
|
118
120
|
|
|
119
|
-
Publishing is tag-driven: a `v*` tag makes `.github/workflows/publish.yml` build,
|
|
121
|
+
Publishing is tag-driven: a `v*` tag makes `.github/workflows/publish.yml` first check that **ci passed for the same commit**, then build, verify the package contents and run `npm publish --provenance` through OIDC trusted publishing. Tests run once, in `ci.yml`; publishing does not re-run them. Do not publish from a laptop.
|
|
120
122
|
|
|
121
123
|
```bash
|
|
122
124
|
pnpm release patch # 0.3.1 → 0.3.2
|
|
123
125
|
pnpm release minor # 0.3.1 → 0.4.0
|
|
124
126
|
pnpm release major # 0.3.1 → 1.0.0
|
|
125
127
|
pnpm release patch --dry-run # print the plan only
|
|
128
|
+
pnpm release --retag # after a failed release: move the unpublished tag and re-publish
|
|
126
129
|
```
|
|
127
130
|
|
|
128
131
|
The script refuses to run on a dirty worktree or a non-default branch, verifies the tag is free, runs typecheck (server + ui), the test suite, the build and the package-contents check, then commits `chore(release): vX.Y.Z`, tags it, pushes branch + tag, and finally waits until the version is actually on npm (a green run that published nothing is reported as a failure, not a success).
|
|
129
132
|
|
|
130
|
-
`--prepare-only` stops after the local commit and tag; `--no-wait` returns right after the push; `--local` publishes from this machine as an emergency path and skips the CI checks.
|
|
133
|
+
`--prepare-only` stops after the local commit and tag; `--no-wait` returns right after the push; `--local` publishes from this machine as an emergency path and skips the CI checks. `--retag` is the recovery path after a failed release: once it confirms the version is not on npm yet, it moves the existing tag to the current commit and re-publishes under the same version number; an already published version is refused.
|
|
131
134
|
|
|
132
135
|
## License
|
|
133
136
|
|
package/README.zh-CN.md
CHANGED
|
@@ -73,7 +73,7 @@ paintress mcp-config <client> 打印单个客户端的
|
|
|
73
73
|
```bash
|
|
74
74
|
paintress web --daemon --idle 3600000
|
|
75
75
|
paintress watch demo ./demo
|
|
76
|
-
# 打开 http://localhost:3316
|
|
76
|
+
# 打开 http://localhost:3316/demo?file=hello.ptr
|
|
77
77
|
```
|
|
78
78
|
|
|
79
79
|
画布操作:滚轮以指针为锚点缩放,空格 / 中键 / 空白处拖拽平移,`Ctrl/Cmd+0` 回到 100%,`Shift+1` 适应窗口。多画板文件会显示画板条。通过 CLI 或 MCP 做的修改会在查看器里高亮,点击会让节点获得短号,agent 用 `paintress_marks` 读回。
|
|
@@ -112,18 +112,19 @@ paintress mcp-config cursor # 只打印片段,不改文件
|
|
|
112
112
|
|
|
113
113
|
## 发版(维护者)
|
|
114
114
|
|
|
115
|
-
发布由 tag 驱动:`v*` tag 会让 `.github/workflows/publish.yml`
|
|
115
|
+
发布由 tag 驱动:`v*` tag 会让 `.github/workflows/publish.yml` 先校验**同一提交的 ci 已通过**,再构建、自检包内容并通过 OIDC trusted publishing 执行 `npm publish --provenance`。测试只在 `ci.yml` 跑一次,发布不重跑。不要在笔记本上直接发布。
|
|
116
116
|
|
|
117
117
|
```bash
|
|
118
118
|
pnpm release patch # 0.3.1 → 0.3.2
|
|
119
119
|
pnpm release minor # 0.3.1 → 0.4.0
|
|
120
120
|
pnpm release major # 0.3.1 → 1.0.0
|
|
121
121
|
pnpm release patch --dry-run # 只打印计划
|
|
122
|
+
pnpm release --retag # 发版失败后:把未发布的 tag 移到当前提交重发
|
|
122
123
|
```
|
|
123
124
|
|
|
124
125
|
脚本会拒绝在脏工作区或非默认分支上发版,确认 tag 未被占用,跑类型检查(服务端 + ui)、测试、构建与包内容自检,然后提交 `chore(release): vX.Y.Z`、打 tag、推分支与 tag,最后等到版本真的出现在 npm 上才算完(CI 绿了但什么都没发出去,会如实报成失败)。
|
|
125
126
|
|
|
126
|
-
`--prepare-only` 停在本地提交与 tag;`--no-wait` 推完就返回;`--local` 本机直接发布,是应急路径,会跳过 CI
|
|
127
|
+
`--prepare-only` 停在本地提交与 tag;`--no-wait` 推完就返回;`--local` 本机直接发布,是应急路径,会跳过 CI 的检查。`--retag` 是发版失败后的恢复路径:确认该版本还没上 npm 后,把已有 tag 移到当前提交重发(版本号不变);已发布的版本会被拒绝。
|
|
127
128
|
|
|
128
129
|
## 许可
|
|
129
130
|
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { a as
|
|
3
|
-
import { T as loadDoc, qt as VERSION, t as renderDocument } from "./render-
|
|
2
|
+
import { a as attachPersistent, c as lockPath, d as safeJoin, f as sanitizeProjectName, h as encodeLine, i as TOOLS, l as pidPath, m as LineDecoder, n as runStdioServer, o as isDaemonRunning, p as socketPath, s as sendRequest, u as resolveProjectDir } from "./server-ssrAxbTR.js";
|
|
3
|
+
import { T as loadDoc, qt as VERSION, t as renderDocument } from "./render-CCxYNL6A.js";
|
|
4
4
|
import "./index.js";
|
|
5
5
|
import { chmodSync, closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { homedir } from "node:os";
|
|
@@ -35,6 +35,7 @@ var Registry = class {
|
|
|
35
35
|
}
|
|
36
36
|
const entry = {
|
|
37
37
|
name,
|
|
38
|
+
label: name,
|
|
38
39
|
root,
|
|
39
40
|
rootReal,
|
|
40
41
|
depth: wanted,
|
|
@@ -100,6 +101,7 @@ var Registry = class {
|
|
|
100
101
|
info(entry) {
|
|
101
102
|
return {
|
|
102
103
|
name: entry.name,
|
|
104
|
+
label: entry.label,
|
|
103
105
|
root: entry.root,
|
|
104
106
|
depth: entry.depth,
|
|
105
107
|
fileCount: this.listDesigns(entry).length,
|
|
@@ -427,6 +429,23 @@ function findUiDist() {
|
|
|
427
429
|
for (const c of candidates) if (existsSync(join(c, "index.html"))) return c;
|
|
428
430
|
return null;
|
|
429
431
|
}
|
|
432
|
+
/**
|
|
433
|
+
* 从 URL 路径段里按最长前缀解析项目。
|
|
434
|
+
* 项目 key 现在是目录层级本身(`c-thun/raybend`),所以 `/c-thun/raybend`、
|
|
435
|
+
* `/render/c-thun/raybend/design/editor.pen` 都能在不需要 %2F 转义的情况下命中。
|
|
436
|
+
* 单段名(`paintress watch myname dir`)同样适用。
|
|
437
|
+
*/
|
|
438
|
+
function resolveProjectPrefix(registry, segments, maxDepth = 3) {
|
|
439
|
+
for (let n = Math.min(maxDepth, segments.length); n >= 1; n--) {
|
|
440
|
+
const key = segments.slice(0, n).map((s) => decodeURIComponent(s)).join("/");
|
|
441
|
+
const entry = registry.get(key);
|
|
442
|
+
if (entry) return {
|
|
443
|
+
entry,
|
|
444
|
+
rest: segments.slice(n)
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
430
449
|
function isAlive(pid) {
|
|
431
450
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
432
451
|
try {
|
|
@@ -512,9 +531,16 @@ async function startDaemon(opts = {}) {
|
|
|
512
531
|
const versions = new VersionChecker();
|
|
513
532
|
const startedAt = Date.now();
|
|
514
533
|
const idleTimeoutMs = opts.idleTimeoutMs ?? 6e4;
|
|
515
|
-
const
|
|
534
|
+
const emptyGraceMs = opts.emptyGraceMs ?? Math.min(idleTimeoutMs, 3e3);
|
|
535
|
+
const emptyBrowserMs = opts.emptyBrowserMs ?? 3e4;
|
|
536
|
+
const manualEmptyMs = opts.manualEmptyMs ?? 6e5;
|
|
537
|
+
/** 本次启动有没有过项目:决定「空仓 + 浏览器开着」是等接入还是收工 */
|
|
538
|
+
let everHadProjects = false;
|
|
539
|
+
const idleCheckMs = opts.idleCheckMs ?? 2e3;
|
|
516
540
|
let idleSince = null;
|
|
517
541
|
let closing = false;
|
|
542
|
+
/** 最近一次 HTTP 活动:用来判定「浏览器还在不在」而不单靠 SSE 关闭事件 */
|
|
543
|
+
let lastHttpAt = Date.now();
|
|
518
544
|
/**
|
|
519
545
|
* 注册表订阅者(浏览器开着的查看器窗口)。
|
|
520
546
|
* 项目列表变化时推 `projects` 事件,避免用户按 F5 才看到新项目。
|
|
@@ -522,11 +548,20 @@ async function startDaemon(opts = {}) {
|
|
|
522
548
|
const registryClients = /* @__PURE__ */ new Set();
|
|
523
549
|
/** 控制面长连接:shutdown 时必须主动结束,否则 server.close() 会一直等 agent attach */
|
|
524
550
|
const controlClients = /* @__PURE__ */ new Set();
|
|
551
|
+
/** agent attach 总数 / 浏览器连接总数(注册表 SSE + 设计稿 SSE) */
|
|
552
|
+
const agentCount = () => registry.all().reduce((n, p) => n + p.agents.size, 0);
|
|
553
|
+
const browserCount = () => registryClients.size + registry.all().reduce((n, p) => n + p.browsers.size, 0);
|
|
525
554
|
/** 将被关闭的长连接数:agent attach + 设计稿 SSE + 项目列表 SSE */
|
|
526
555
|
const connectionCount = () => registryClients.size + registry.all().reduce((n, p) => n + p.agents.size + p.browsers.size, 0);
|
|
556
|
+
/**
|
|
557
|
+
* 推注册表快照。事件带全量项目信息:前端不用再拉一次接口,项目增删能同一帧完成,
|
|
558
|
+
* 动画也因此不会晚一步。列表很小(每个项目只扫一次 shallow 目录),频繁推不心疼。
|
|
559
|
+
*/
|
|
527
560
|
const notifyRegistry = () => {
|
|
528
561
|
const payload = JSON.stringify({
|
|
529
|
-
projects: registry.all().
|
|
562
|
+
projects: registry.all().map((p) => registry.info(p)),
|
|
563
|
+
agents: agentCount(),
|
|
564
|
+
browsers: browserCount(),
|
|
530
565
|
at: Date.now()
|
|
531
566
|
});
|
|
532
567
|
for (const res of registryClients) try {
|
|
@@ -555,7 +590,7 @@ async function startDaemon(opts = {}) {
|
|
|
555
590
|
* - 浏览器 SSE 开着时永不退场
|
|
556
591
|
* - 人手动 watch 的项目(owner=manual)不参与自动退场
|
|
557
592
|
*/
|
|
558
|
-
const autoDetachMs = opts.autoDetachMs ??
|
|
593
|
+
const autoDetachMs = opts.autoDetachMs ?? 5e3;
|
|
559
594
|
const autoDetachTimers = /* @__PURE__ */ new Map();
|
|
560
595
|
function cancelAutoDetach(name) {
|
|
561
596
|
const timer = autoDetachTimers.get(name);
|
|
@@ -641,6 +676,7 @@ async function startDaemon(opts = {}) {
|
|
|
641
676
|
});
|
|
642
677
|
break;
|
|
643
678
|
case "watch": {
|
|
679
|
+
everHadProjects = true;
|
|
644
680
|
const entry = registry.watch(req.project, req.dir, req.depth, req.owner === "agent" ? { owner: "agent" } : {});
|
|
645
681
|
cancelAutoDetach(entry.name);
|
|
646
682
|
watchers.watchDesigns(entry.rootReal, entry.depth, scheduleRegistryNotify);
|
|
@@ -676,6 +712,7 @@ async function startDaemon(opts = {}) {
|
|
|
676
712
|
attached: true,
|
|
677
713
|
project: req.project
|
|
678
714
|
});
|
|
715
|
+
notifyRegistry();
|
|
679
716
|
break;
|
|
680
717
|
}
|
|
681
718
|
case "detach":
|
|
@@ -747,6 +784,7 @@ async function startDaemon(opts = {}) {
|
|
|
747
784
|
const sockServer = createServer(handleClient);
|
|
748
785
|
const uiDist = findUiDist();
|
|
749
786
|
const httpServer = createServer$1(async (req, res) => {
|
|
787
|
+
lastHttpAt = Date.now();
|
|
750
788
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
751
789
|
const segments = url.pathname.split("/").filter((s) => s !== "");
|
|
752
790
|
const send = (code, body, type = "text/html; charset=utf-8") => {
|
|
@@ -793,7 +831,7 @@ async function startDaemon(opts = {}) {
|
|
|
793
831
|
});
|
|
794
832
|
return;
|
|
795
833
|
}
|
|
796
|
-
if (segments[1] === "projects" &&
|
|
834
|
+
if (segments[1] === "projects" && req.method === "DELETE") {
|
|
797
835
|
if (!isTrustedWrite(req)) {
|
|
798
836
|
sendJson(403, {
|
|
799
837
|
ok: false,
|
|
@@ -980,14 +1018,14 @@ async function startDaemon(opts = {}) {
|
|
|
980
1018
|
return;
|
|
981
1019
|
}
|
|
982
1020
|
if (segments[0] === "render" && segments.length >= 3) {
|
|
983
|
-
const
|
|
984
|
-
|
|
985
|
-
if (!entry) {
|
|
1021
|
+
const hit = resolveProjectPrefix(registry, segments.slice(1));
|
|
1022
|
+
if (hit === null || hit.rest.length === 0) {
|
|
986
1023
|
send(404, "project not found", "text/plain; charset=utf-8");
|
|
987
1024
|
return;
|
|
988
1025
|
}
|
|
1026
|
+
const { entry } = hit;
|
|
989
1027
|
entry.lastAccess = Date.now();
|
|
990
|
-
const relPath =
|
|
1028
|
+
const relPath = hit.rest.map(decodeURIComponent).join("/");
|
|
991
1029
|
const abs = safeJoin(entry.rootReal, relPath);
|
|
992
1030
|
if (!abs) {
|
|
993
1031
|
send(404, "file not found", "text/plain; charset=utf-8");
|
|
@@ -996,7 +1034,7 @@ async function startDaemon(opts = {}) {
|
|
|
996
1034
|
const loaded = loadDoc(abs);
|
|
997
1035
|
const rendered = renderDocument(loaded.doc, { title: relPath });
|
|
998
1036
|
const dir = relPath.includes("/") ? relPath.slice(0, relPath.lastIndexOf("/") + 1) : "";
|
|
999
|
-
const baseHref = `/files/${
|
|
1037
|
+
const baseHref = `/files/${entry.name.split("/").map(encodeURIComponent).join("/")}/${dir.split("/").filter((s) => s !== "").map(encodeURIComponent).join("/")}${dir ? "/" : ""}`;
|
|
1000
1038
|
let html = rendered.html.replace("<head>", `<head>\n<base href="${baseHref}">`);
|
|
1001
1039
|
if (url.searchParams.get("embed") !== "1") {
|
|
1002
1040
|
const liveScript = `<script>\n(function(){\n var es = new EventSource('/events?project=${encodeURIComponent(entry.name)}&path=${encodeURIComponent(relPath)}');\n es.addEventListener('change', function(){ location.reload(); });\n})();\n<\/script>`;
|
|
@@ -1006,13 +1044,13 @@ async function startDaemon(opts = {}) {
|
|
|
1006
1044
|
return;
|
|
1007
1045
|
}
|
|
1008
1046
|
if (segments[0] === "files" && segments.length >= 3) {
|
|
1009
|
-
const
|
|
1010
|
-
|
|
1011
|
-
if (!entry) {
|
|
1047
|
+
const hit = resolveProjectPrefix(registry, segments.slice(1));
|
|
1048
|
+
if (hit === null) {
|
|
1012
1049
|
send(404, "project not found", "text/plain; charset=utf-8");
|
|
1013
1050
|
return;
|
|
1014
1051
|
}
|
|
1015
|
-
const
|
|
1052
|
+
const { entry } = hit;
|
|
1053
|
+
const relPath = hit.rest.map(decodeURIComponent).join("/");
|
|
1016
1054
|
const abs = safeJoin(entry.rootReal, relPath);
|
|
1017
1055
|
if (!abs) {
|
|
1018
1056
|
send(404, "file not found", "text/plain; charset=utf-8");
|
|
@@ -1053,21 +1091,21 @@ async function startDaemon(opts = {}) {
|
|
|
1053
1091
|
if (segments.length === 0) {
|
|
1054
1092
|
const items = registry.all().map((p) => {
|
|
1055
1093
|
const info = registry.info(p);
|
|
1056
|
-
return `<div class="card"><h2 style="margin:0 0 4px"><a href="
|
|
1094
|
+
return `<div class="card"><h2 style="margin:0 0 4px"><a href="${`/${p.name.split("/").map(encodeURIComponent).join("/")}`}">${esc(p.label)}</a></h2><div class="meta">${esc(info.root)} · ${info.fileCount} 个设计稿 · agent ${info.agentConns} · 浏览器 ${info.browserConns}</div></div>`;
|
|
1057
1095
|
}).join("");
|
|
1058
1096
|
send(200, layout("项目", `<h1>paintress</h1><div class="meta">pid ${process.pid} · 端口 ${opts.port ?? DEFAULT_PORT} · 运行 ${Math.round((Date.now() - startedAt) / 1e3)}s</div><p class="meta">前端未构建(<code>ui/dist</code> 不存在),当前为精简视图。</p>` + (items || "<p class=\"empty\">还没有注册任何项目。用 <code>paintress watch <name> <dir></code> 注册。</p>")));
|
|
1059
1097
|
return;
|
|
1060
1098
|
}
|
|
1061
|
-
const
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
send(404, layout("未找到", `<h1>404</h1><p>未注册的项目:<code>${esc(projectName)}</code></p><p><a href="/">返回</a></p>`));
|
|
1099
|
+
const hit = resolveProjectPrefix(registry, segments);
|
|
1100
|
+
if (hit === null) {
|
|
1101
|
+
send(404, layout("未找到", `<h1>404</h1><p>未注册的项目:<code>${esc(segments.join("/"))}</code></p><p><a href="/">返回</a></p>`));
|
|
1065
1102
|
return;
|
|
1066
1103
|
}
|
|
1104
|
+
const { entry } = hit;
|
|
1067
1105
|
entry.lastAccess = Date.now();
|
|
1068
|
-
if (
|
|
1106
|
+
if (hit.rest.length === 0) {
|
|
1069
1107
|
const files = registry.listDesigns(entry);
|
|
1070
|
-
const list = files.map((f) => `<li><a href="/render/${
|
|
1108
|
+
const list = files.map((f) => `<li><a href="/render/${entry.name.split("/").map(encodeURIComponent).join("/")}/${f.split("/").map(encodeURIComponent).join("/")}">${esc(f)}</a></li>`).join("");
|
|
1071
1109
|
send(200, layout(entry.name, `<h1>${esc(entry.name)}</h1><div class="meta">${esc(entry.root)} · depth ${entry.depth}</div><h2>设计稿(${files.length})</h2>` + (files.length ? `<ul>${list}</ul>` : "<p class=\"empty\">目录下没有 .ptr / .pen 文件</p>") + "<p><a href=\"/\">← 项目列表</a></p>"));
|
|
1072
1110
|
return;
|
|
1073
1111
|
}
|
|
@@ -1096,14 +1134,40 @@ async function startDaemon(opts = {}) {
|
|
|
1096
1134
|
resolve();
|
|
1097
1135
|
});
|
|
1098
1136
|
});
|
|
1137
|
+
/**
|
|
1138
|
+
* 生命周期策略:控制权在 agent 侧,人不需要管进程在不在。
|
|
1139
|
+
*
|
|
1140
|
+
* - 有 agent attach → 永远活着(agent 正在干活)。
|
|
1141
|
+
* - 没有 agent,但浏览器开着:
|
|
1142
|
+
* · 还有项目 → 活着;项目归零 → 给前端 10s 倒计时自己走(服务端 emptyBrowserMs 兜底)。
|
|
1143
|
+
* - 没有 agent 也没有浏览器:
|
|
1144
|
+
* · 还有手动 watch 的项目 → idleTimeoutMs 后关;
|
|
1145
|
+
* · 一个项目都没有 → 立刻(emptyGraceMs)关。
|
|
1146
|
+
*
|
|
1147
|
+
* 修的是旧逻辑的真 bug:旧代码只在「项目全部 idle」时计数,自动拉起的 daemon
|
|
1148
|
+
* 又写死 `--idle 1h`,于是浏览器早就关了、进程还能挂一小时。
|
|
1149
|
+
*/
|
|
1099
1150
|
const idleTimer = setInterval(() => {
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1151
|
+
const now = Date.now();
|
|
1152
|
+
const projects = registry.all().length;
|
|
1153
|
+
const agents = agentCount();
|
|
1154
|
+
const browsing = browserCount() > 0 && now - lastHttpAt < 3e4;
|
|
1155
|
+
let threshold;
|
|
1156
|
+
if (agents > 0) {
|
|
1103
1157
|
idleSince = null;
|
|
1158
|
+
changes.prune();
|
|
1104
1159
|
return;
|
|
1105
1160
|
}
|
|
1106
|
-
if (
|
|
1161
|
+
if (browsing) {
|
|
1162
|
+
if (projects > 0) {
|
|
1163
|
+
idleSince = null;
|
|
1164
|
+
changes.prune();
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
threshold = everHadProjects ? emptyBrowserMs : manualEmptyMs;
|
|
1168
|
+
} else threshold = projects > 0 ? idleTimeoutMs : emptyGraceMs;
|
|
1169
|
+
idleSince = idleSince ?? now;
|
|
1170
|
+
if (now - idleSince >= threshold) shutdown(projects > 0 ? "idle" : "无项目");
|
|
1107
1171
|
changes.prune();
|
|
1108
1172
|
}, idleCheckMs);
|
|
1109
1173
|
idleTimer.unref();
|
|
@@ -1116,6 +1180,10 @@ async function startDaemon(opts = {}) {
|
|
|
1116
1180
|
for (const timer of autoDetachTimers.values()) clearTimeout(timer);
|
|
1117
1181
|
autoDetachTimers.clear();
|
|
1118
1182
|
for (const client of registryClients) try {
|
|
1183
|
+
client.write(`event: shutdown\ndata: ${JSON.stringify({
|
|
1184
|
+
reason,
|
|
1185
|
+
at: Date.now()
|
|
1186
|
+
})}\n\n`);
|
|
1119
1187
|
client.end();
|
|
1120
1188
|
} catch {}
|
|
1121
1189
|
registryClients.clear();
|
|
@@ -1423,6 +1491,7 @@ function readConfig(path) {
|
|
|
1423
1491
|
return existsSync(path) ? readFileSync(path, "utf8") : null;
|
|
1424
1492
|
}
|
|
1425
1493
|
// src/cli.ts
|
|
1494
|
+
const NO_OPEN_FLAG = "no-open";
|
|
1426
1495
|
const FLAG_KEYS = /* @__PURE__ */ new Set([
|
|
1427
1496
|
"type",
|
|
1428
1497
|
"name",
|
|
@@ -1444,14 +1513,16 @@ const FLAG_KEYS = /* @__PURE__ */ new Set([
|
|
|
1444
1513
|
"padding",
|
|
1445
1514
|
"gap",
|
|
1446
1515
|
"force",
|
|
1447
|
-
"board"
|
|
1516
|
+
"board",
|
|
1517
|
+
NO_OPEN_FLAG
|
|
1448
1518
|
]);
|
|
1449
1519
|
const BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
|
|
1450
1520
|
"regex",
|
|
1451
1521
|
"reusable",
|
|
1452
1522
|
"daemon",
|
|
1453
1523
|
"dry-run",
|
|
1454
|
-
"force"
|
|
1524
|
+
"force",
|
|
1525
|
+
NO_OPEN_FLAG
|
|
1455
1526
|
]);
|
|
1456
1527
|
/** 纯函数:argv → 命令 + 位置参数 + 标志(可单测) */
|
|
1457
1528
|
function parseArgv(argv) {
|
|
@@ -1556,10 +1627,11 @@ async function main(argv) {
|
|
|
1556
1627
|
if (cmd === "watch") {
|
|
1557
1628
|
if (!file || !a1) throw new Error("用法:paintress watch <name> <dir> [--depth 1..6]");
|
|
1558
1629
|
const depth = flagNum(flags, "depth");
|
|
1630
|
+
const dir = resolve(a1);
|
|
1559
1631
|
console.log(JSON.stringify(await sendRequest({
|
|
1560
1632
|
kind: "watch",
|
|
1561
1633
|
project: file,
|
|
1562
|
-
dir
|
|
1634
|
+
dir,
|
|
1563
1635
|
...depth !== void 0 ? { depth } : {}
|
|
1564
1636
|
}, 8e3), null, 2));
|
|
1565
1637
|
return 0;
|
|
@@ -1674,11 +1746,13 @@ async function main(argv) {
|
|
|
1674
1746
|
if (cmd === "web") {
|
|
1675
1747
|
const port = flagNum(flags, "port");
|
|
1676
1748
|
const idle = flagNum(flags, "idle");
|
|
1749
|
+
const noOpen = flags[NO_OPEN_FLAG] === true || process.env["PAINTRESS_NO_OPEN"] === "1";
|
|
1677
1750
|
if (flags["daemon"] === true) {
|
|
1678
1751
|
const { spawn } = await import("node:child_process");
|
|
1679
1752
|
const args = [process.argv[1] ?? "", "web"];
|
|
1680
1753
|
if (port !== void 0) args.push("--port", String(port));
|
|
1681
1754
|
if (idle !== void 0) args.push("--idle", String(idle));
|
|
1755
|
+
if (noOpen) args.push("--no-open");
|
|
1682
1756
|
const child = spawn(process.execPath, args, {
|
|
1683
1757
|
detached: true,
|
|
1684
1758
|
stdio: "ignore"
|
|
@@ -1702,14 +1776,20 @@ async function main(argv) {
|
|
|
1702
1776
|
const handle = await startDaemon({
|
|
1703
1777
|
...port !== void 0 ? { port } : {},
|
|
1704
1778
|
...idle !== void 0 ? { idleTimeoutMs: idle } : {},
|
|
1779
|
+
emptyGraceMs: 6e4,
|
|
1705
1780
|
updateCheck: true
|
|
1706
1781
|
});
|
|
1782
|
+
const http = `http://localhost:${handle.port}`;
|
|
1707
1783
|
console.log(JSON.stringify({
|
|
1708
1784
|
ok: true,
|
|
1709
1785
|
pid: handle.pid,
|
|
1710
1786
|
port: handle.port,
|
|
1711
|
-
http
|
|
1787
|
+
http
|
|
1712
1788
|
}, null, 2));
|
|
1789
|
+
if (!noOpen) {
|
|
1790
|
+
const { openBrowser } = await import("./server-ssrAxbTR.js").then((n) => n.r);
|
|
1791
|
+
openBrowser(`${http}/`);
|
|
1792
|
+
}
|
|
1713
1793
|
await new Promise((resolve) => {
|
|
1714
1794
|
process.once("SIGINT", () => void handle.close().then(() => resolve()));
|
|
1715
1795
|
process.once("SIGTERM", () => void handle.close().then(() => resolve()));
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { It as Affine, it as BackgroundBlurEffect, bt as BlendMode, at as BlurEffect, Lt as BoolOrVar, z as BrowserSchema, Rt as Color, zt as ColorOrVar, Bt as CornerRadius, xt as CrossAxis, j as DocumentObjectSchema, M as DocumentSchema, ot as EffectEntry, st as Effects, B as EllipseSchema, w as FileChangedError, ct as FillEntry, St as FillRule, lt as Fills, V as FrameSchema, ut as GradientFill, Ct as GradientType, dt as GraphicsBlock, H as GroupSchema, U as IconFontSchema, wt as IconLibrary, W as IconSchema, ft as ImageFill, Tt as ImageFit, G as KNOWN_NODE_TYPES, K as LayoutBlock, Et as LayoutDir, Dt as MainAxis, pt as MeshGradientFill, q as NodeBase, J as NodeSchema, Y as NoteSchema, Vt as NumOrVar, N as PEN_DOC_VERSION, P as PTR_SPEC_VERSION, Ht as Padding, X as PathSchema, Z as PolygonSchema, Ot as PositionMode, F as PtrDocumentSchema, Q as RectangleSchema, $ as RefSchema, et as ScriptSchema, mt as ShaderEffect, ht as ShaderFill, gt as ShadowEffect, kt as ShadowKind, Ut as Sizing, _t as SolidFill, Wt as StringOrVar, At as StrokeAlign, vt as StrokeBlock, jt as StrokeCap, Mt as StrokeJoin, yt as StrokeWidth, Nt as TextAlign, Pt as TextAlignVertical, Ft as TextGrowth, tt as TextSchema, nt as UnknownNodeSchema, Gt as VARIABLE_NAME, I as VariableDef, Kt as VariableRef, L as XPaintress, R as X_PAINTRESS_KEY, g as batch, n as boardBoxes, _ as buildIndex, k as collectIds, c as commonToCss, r as computeCanvas, l as cornerRadiusToCss, u as effectsToCss, i as escapeAttr, a as escapeHtml, m as expandRefs, d as fillsToCss, A as genId, v as get, s as getLucideIcon, y as insert, T as loadDoc, h as makeVarResolver, b as move, x as query, S as remove, t as renderDocument, o as renderTree, E as saveDoc, rt as schemaForType, D as serializeDoc, f as strokeToCss, p as textToCss, C as update, O as validateDoc } from "./render-
|
|
1
|
+
export { It as Affine, it as BackgroundBlurEffect, bt as BlendMode, at as BlurEffect, Lt as BoolOrVar, z as BrowserSchema, Rt as Color, zt as ColorOrVar, Bt as CornerRadius, xt as CrossAxis, j as DocumentObjectSchema, M as DocumentSchema, ot as EffectEntry, st as Effects, B as EllipseSchema, w as FileChangedError, ct as FillEntry, St as FillRule, lt as Fills, V as FrameSchema, ut as GradientFill, Ct as GradientType, dt as GraphicsBlock, H as GroupSchema, U as IconFontSchema, wt as IconLibrary, W as IconSchema, ft as ImageFill, Tt as ImageFit, G as KNOWN_NODE_TYPES, K as LayoutBlock, Et as LayoutDir, Dt as MainAxis, pt as MeshGradientFill, q as NodeBase, J as NodeSchema, Y as NoteSchema, Vt as NumOrVar, N as PEN_DOC_VERSION, P as PTR_SPEC_VERSION, Ht as Padding, X as PathSchema, Z as PolygonSchema, Ot as PositionMode, F as PtrDocumentSchema, Q as RectangleSchema, $ as RefSchema, et as ScriptSchema, mt as ShaderEffect, ht as ShaderFill, gt as ShadowEffect, kt as ShadowKind, Ut as Sizing, _t as SolidFill, Wt as StringOrVar, At as StrokeAlign, vt as StrokeBlock, jt as StrokeCap, Mt as StrokeJoin, yt as StrokeWidth, Nt as TextAlign, Pt as TextAlignVertical, Ft as TextGrowth, tt as TextSchema, nt as UnknownNodeSchema, Gt as VARIABLE_NAME, I as VariableDef, Kt as VariableRef, L as XPaintress, R as X_PAINTRESS_KEY, g as batch, n as boardBoxes, _ as buildIndex, k as collectIds, c as commonToCss, r as computeCanvas, l as cornerRadiusToCss, u as effectsToCss, i as escapeAttr, a as escapeHtml, m as expandRefs, d as fillsToCss, A as genId, v as get, s as getLucideIcon, y as insert, T as loadDoc, h as makeVarResolver, b as move, x as query, S as remove, t as renderDocument, o as renderTree, E as saveDoc, rt as schemaForType, D as serializeDoc, f as strokeToCss, p as textToCss, C as update, O as validateDoc } from "./render-CCxYNL6A.js";
|
package/dist/mcp/server.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { t as createMcpServer, n as runStdioServer } from "../server-
|
|
1
|
+
export { t as createMcpServer, n as runStdioServer } from "../server-ssrAxbTR.js";
|