@youweichen/pi-web-ui 0.45.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/LICENSE +21 -0
- package/README.md +45 -0
- package/bin/pi-web-ui.mjs +1818 -0
- package/deploy/com.xingshuyin.pi-web-ui.plist +48 -0
- package/deploy/nginx-subpath.conf +88 -0
- package/deploy/pi-web-ui-task.xml +71 -0
- package/deploy/pi-web-ui.service +31 -0
- package/dist/server/agent-service.js +2975 -0
- package/dist/server/attachments.js +672 -0
- package/dist/server/bg-servers.js +150 -0
- package/dist/server/client-state.js +234 -0
- package/dist/server/control-socket.js +177 -0
- package/dist/server/ensure-bash.js +80 -0
- package/dist/server/files-service.js +709 -0
- package/dist/server/goal-service.js +869 -0
- package/dist/server/index.js +879 -0
- package/dist/server/mcp-bridge.js +268 -0
- package/dist/server/model-admin.js +821 -0
- package/dist/server/patch-node-pty.js +80 -0
- package/dist/server/plugin-facilities.js +299 -0
- package/dist/server/plugin-updater.js +226 -0
- package/dist/server/plugins.js +893 -0
- package/dist/server/process-utils.js +112 -0
- package/dist/server/protocol-version.js +11 -0
- package/dist/server/protocol.js +6 -0
- package/dist/server/scm.js +298 -0
- package/dist/server/serialize.js +172 -0
- package/dist/server/settings-service.js +395 -0
- package/dist/server/slash-commands.js +263 -0
- package/dist/server/terminals.js +1372 -0
- package/dist/server/text-sniff.js +268 -0
- package/dist/server/uploads.js +107 -0
- package/dist/server/vision-bridge.js +131 -0
- package/dist/server/webui-context.js +222 -0
- package/extensions/webui.ts +192 -0
- package/package.json +99 -0
- package/web/dist/assets/TerminalPanel-6GBZ9nXN.css +32 -0
- package/web/dist/assets/TerminalPanel-D4AktsEn.js +2 -0
- package/web/dist/assets/abnfDiagram-VCTEODGH-Ct5StJwt.js +1 -0
- package/web/dist/assets/arc-pkuxEN8A.js +1 -0
- package/web/dist/assets/architectureDiagram-5GKGNRK7-C6P6yn4y.js +36 -0
- package/web/dist/assets/blockDiagram-I7D4REHJ-BmgfH1jt.js +129 -0
- package/web/dist/assets/c4Diagram-7LVT6UL2-CgmwTFsK.js +38 -0
- package/web/dist/assets/channel-0Y__6sq2.js +1 -0
- package/web/dist/assets/chunk-2Q5K7J3B-DX_NqAiH.js +1 -0
- package/web/dist/assets/chunk-5VM5RSS4-gLeuQuYv.js +15 -0
- package/web/dist/assets/chunk-F27PBJKO-D4WX4mhI.js +1 -0
- package/web/dist/assets/chunk-IMKFNOWR-DBpDxGQD.js +231 -0
- package/web/dist/assets/chunk-JWPE2WC7-DIQ8iZN5.js +1 -0
- package/web/dist/assets/chunk-POPQ4Y6H-F_YTNoGz.js +1 -0
- package/web/dist/assets/chunk-SVP7TREG-CmDIvX-r.js +88 -0
- package/web/dist/assets/chunk-TICWLB2K-BQUzHQyS.js +206 -0
- package/web/dist/assets/chunk-XXDRQBXY-DqR9iffH.js +1 -0
- package/web/dist/assets/classDiagram-ZZMXUADV-BEfGIpzu.js +1 -0
- package/web/dist/assets/classDiagram-v2-VYDZK3BY-BEfGIpzu.js +1 -0
- package/web/dist/assets/cose-bilkent-JH36ORCC-Dl67W4Dx.js +1 -0
- package/web/dist/assets/cynefin-OW5HDTMX-fWdqaCpX.js +178 -0
- package/web/dist/assets/cynefinDiagram-5FMLGOSQ-CSUAXbaP.js +62 -0
- package/web/dist/assets/cytoscape.esm-Bch-eiPH.js +331 -0
- package/web/dist/assets/dagre-GXQ25YYZ-CqUVwu1X.js +4 -0
- package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/web/dist/assets/diagram-S7CK7UJ4-bRce-WYV.js +30 -0
- package/web/dist/assets/diagram-UQ7AKVKN-CiqPtPBE.js +41 -0
- package/web/dist/assets/diagram-VSXAHHWV-CbEA05LX.js +3 -0
- package/web/dist/assets/diagram-VX7I27RA-CRcY9MD-.js +24 -0
- package/web/dist/assets/diagram-Z3DM3KII-DmTwvZdS.js +24 -0
- package/web/dist/assets/ebnfDiagram-PWID7BFC-DbwmiGVj.js +1 -0
- package/web/dist/assets/erDiagram-RLTQ6QDP-Cxiaf87y.js +99 -0
- package/web/dist/assets/flowDiagram-HODETNUW-BQiecP1I.js +168 -0
- package/web/dist/assets/ganttDiagram-EL5Y4UJY-CSr8RRNw.js +292 -0
- package/web/dist/assets/gitGraphDiagram-WWUBYQGX-C4Tdis3g.js +106 -0
- package/web/dist/assets/index-CniHcFxT.js +22 -0
- package/web/dist/assets/index-DYz2iP_B.css +10 -0
- package/web/dist/assets/infoDiagram-27XIBGKW-ij_5A0pY.js +2 -0
- package/web/dist/assets/init-Gi6I4Gst.js +1 -0
- package/web/dist/assets/ishikawaDiagram-5VMMS53U-C8-tm2LV.js +70 -0
- package/web/dist/assets/journeyDiagram-3NMN7TZE-ZJedkNel.js +139 -0
- package/web/dist/assets/kanban-definition-UXKFOSKX-BEuoM53n.js +89 -0
- package/web/dist/assets/katex-C5jXJg4s.js +257 -0
- package/web/dist/assets/layout-zq_9DigZ.js +1 -0
- package/web/dist/assets/linear-D-QXJ5Wc.js +1 -0
- package/web/dist/assets/markdown-CUPbftfV.js +51 -0
- package/web/dist/assets/mermaid.core-CCiG-bOm.js +312 -0
- package/web/dist/assets/mindmap-definition-YA3MSWOX-VaAisUIe.js +96 -0
- package/web/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/web/dist/assets/pegDiagram-XKGWAZYB-D5vmRtkn.js +1 -0
- package/web/dist/assets/pieDiagram-E7YTZNPT-loD6V8fn.js +39 -0
- package/web/dist/assets/quadrantDiagram-AXDQQJYC-B7E4mH1-.js +7 -0
- package/web/dist/assets/railroadDiagram-O6MQD6OU-DTALHvDa.js +1 -0
- package/web/dist/assets/react-ZkKJMDQQ.js +24 -0
- package/web/dist/assets/requirementDiagram-BXWQKSXE-ZjJKuEcR.js +84 -0
- package/web/dist/assets/sankeyDiagram-P5KCCOFB-DufTudDP.js +40 -0
- package/web/dist/assets/sequenceDiagram-WJ2MYXX4-B-Zk4alI.js +162 -0
- package/web/dist/assets/sizeCapture-INFHLROL-B1pjlxXN.js +1 -0
- package/web/dist/assets/stateDiagram-D77RDMKH-BeWg6PIR.js +1 -0
- package/web/dist/assets/stateDiagram-v2-MP3YSRHH-DagWhIFJ.js +1 -0
- package/web/dist/assets/swimlanes-42K2YHIH-4J4jmMmO.js +1 -0
- package/web/dist/assets/swimlanesDiagram-VR7AAH4N-Dsi9x540.js +8 -0
- package/web/dist/assets/timeline-definition-24CTP7MA-ByqLfxqe.js +120 -0
- package/web/dist/assets/vennDiagram-4TSXK5OY-B_EqVW4R.js +34 -0
- package/web/dist/assets/wardleyDiagram-VM6X3IG4-Bu7sA6F8.js +78 -0
- package/web/dist/assets/xterm-D1D2FVe3.js +38 -0
- package/web/dist/assets/xychartDiagram-S5SC5T6Z-w1KPVGKk.js +7 -0
- package/web/dist/favicon.svg +8 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-400-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-500-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-600-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-700-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-800-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
- package/web/dist/icon.ico +0 -0
- package/web/dist/index.html +17 -0
- package/web/public/favicon.svg +8 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-400-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-500-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-600-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-700-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-800-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
- package/web/public/icon.ico +0 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { killPidTree, lookupProcessName, lookupProcessCommandLine, snapshotListeningPorts, } from "./process-utils.js";
|
|
2
|
+
const BG_REFRESH_INTERVAL_MS = 30_000;
|
|
3
|
+
/** bash 结束后等这么久再拍「后」快照——给后台服务绑定端口的时间。 */
|
|
4
|
+
const BG_BIND_WAIT_MS = 1500;
|
|
5
|
+
export class BgServerTracker {
|
|
6
|
+
opts;
|
|
7
|
+
servers = new Map();
|
|
8
|
+
/** bash 工具开始执行前拍的监听端口快照(tool_execution_start 时设置)。 */
|
|
9
|
+
listenBefore = null;
|
|
10
|
+
refreshTimer = null;
|
|
11
|
+
constructor(opts) {
|
|
12
|
+
this.opts = opts;
|
|
13
|
+
}
|
|
14
|
+
/** 启动周期性存活检查(死项静默剔除)。 */
|
|
15
|
+
start() {
|
|
16
|
+
this.refreshTimer = setInterval(() => void this.refresh(), BG_REFRESH_INTERVAL_MS);
|
|
17
|
+
this.refreshTimer.unref?.();
|
|
18
|
+
}
|
|
19
|
+
stop() {
|
|
20
|
+
if (this.refreshTimer) {
|
|
21
|
+
clearInterval(this.refreshTimer);
|
|
22
|
+
this.refreshTimer = null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** tool_execution_start(bash):先记下「前」快照。 */
|
|
26
|
+
snapshotBefore() {
|
|
27
|
+
void snapshotListeningPorts().then((m) => {
|
|
28
|
+
this.listenBefore = m;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/** After a bash tool run, wait briefly for background servers to bind,
|
|
32
|
+
* then diff the listening-port snapshot against the pre-run one and
|
|
33
|
+
* remember anything new — those are servers the agent left running. */
|
|
34
|
+
async trackAfterBash() {
|
|
35
|
+
const before = this.listenBefore;
|
|
36
|
+
this.listenBefore = null;
|
|
37
|
+
if (!before)
|
|
38
|
+
return;
|
|
39
|
+
await new Promise((r) => setTimeout(r, BG_BIND_WAIT_MS));
|
|
40
|
+
const after = await snapshotListeningPorts();
|
|
41
|
+
let added = false;
|
|
42
|
+
for (const [port, pid] of after) {
|
|
43
|
+
if (!before.has(port) && !this.servers.has(port)) {
|
|
44
|
+
this.servers.set(port, { pid, since: Date.now() });
|
|
45
|
+
added = true;
|
|
46
|
+
// Best-effort process name + full command line so the panel shows
|
|
47
|
+
// something readable (name) AND what is actually running (command).
|
|
48
|
+
void lookupProcessName(pid).then((name) => {
|
|
49
|
+
const cur = this.servers.get(port);
|
|
50
|
+
if (cur && cur.pid === pid && name) {
|
|
51
|
+
cur.name = name;
|
|
52
|
+
this.push();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
void lookupProcessCommandLine(pid).then((command) => {
|
|
56
|
+
const cur = this.servers.get(port);
|
|
57
|
+
if (cur && cur.pid === pid && command) {
|
|
58
|
+
cur.command = command;
|
|
59
|
+
this.push();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
this.opts.emit({
|
|
63
|
+
type: "notice",
|
|
64
|
+
level: "info",
|
|
65
|
+
text: `检测到 AI 启动的后台服务:端口 ${port}(pid ${pid})——可在顶栏「后台任务」里单独停止或全部关闭`,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (added)
|
|
70
|
+
this.push();
|
|
71
|
+
}
|
|
72
|
+
/** The current background-server list, oldest first. 合并插件任务。 */
|
|
73
|
+
list() {
|
|
74
|
+
const out = [...this.servers.entries()]
|
|
75
|
+
.map(([port, v]) => ({
|
|
76
|
+
port,
|
|
77
|
+
pid: v.pid,
|
|
78
|
+
since: v.since,
|
|
79
|
+
...(v.name ? { name: v.name } : {}),
|
|
80
|
+
...(v.command ? { command: v.command } : {}),
|
|
81
|
+
}))
|
|
82
|
+
.sort((a, b) => a.since - b.since);
|
|
83
|
+
for (const t of this.opts.pluginTasks?.() ?? [])
|
|
84
|
+
out.push(t);
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
/** Push the current background-task list to every connected socket. */
|
|
88
|
+
push() {
|
|
89
|
+
this.opts.emit({ type: "bg_servers", servers: this.list() });
|
|
90
|
+
}
|
|
91
|
+
/** Re-snapshot listening ports and drop tracked entries that are no longer
|
|
92
|
+
* listening — the process exited on its own, so it must leave the panel.
|
|
93
|
+
* Port AND pid must both match: a port reused by an unrelated process is
|
|
94
|
+
* not our server anymore. Silent (the list just updates). */
|
|
95
|
+
async refresh() {
|
|
96
|
+
if (this.opts.isDisposed() || this.servers.size === 0)
|
|
97
|
+
return;
|
|
98
|
+
const now = await snapshotListeningPorts();
|
|
99
|
+
let changed = false;
|
|
100
|
+
for (const [port, v] of [...this.servers]) {
|
|
101
|
+
if (now.get(port) !== v.pid) {
|
|
102
|
+
this.servers.delete(port);
|
|
103
|
+
changed = true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (changed)
|
|
107
|
+
this.push();
|
|
108
|
+
}
|
|
109
|
+
/** Re-push the current list on request (panel opened); prunes dead entries first. */
|
|
110
|
+
async listAndPush() {
|
|
111
|
+
await this.refresh();
|
|
112
|
+
this.push();
|
|
113
|
+
}
|
|
114
|
+
/** Kill ONE background server (by port); returns whether anything was killed. */
|
|
115
|
+
async killOne(port) {
|
|
116
|
+
const entry = this.servers.get(port);
|
|
117
|
+
if (!entry) {
|
|
118
|
+
this.opts.emit({
|
|
119
|
+
type: "notice",
|
|
120
|
+
level: "info",
|
|
121
|
+
text: `端口 ${port} 不在后台任务列表中`,
|
|
122
|
+
});
|
|
123
|
+
this.opts.flushSnapshot();
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
killPidTree(entry.pid);
|
|
127
|
+
this.servers.delete(port);
|
|
128
|
+
this.push();
|
|
129
|
+
this.opts.emit({
|
|
130
|
+
type: "notice",
|
|
131
|
+
level: "info",
|
|
132
|
+
text: `已停止后台任务:端口 ${port}(pid ${entry.pid})`,
|
|
133
|
+
});
|
|
134
|
+
this.opts.flushSnapshot();
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
/** Kill every background server the agent started; returns the freed ports. */
|
|
138
|
+
async killAll() {
|
|
139
|
+
if (this.servers.size === 0)
|
|
140
|
+
return [];
|
|
141
|
+
const killed = [];
|
|
142
|
+
for (const [port, { pid }] of [...this.servers]) {
|
|
143
|
+
killPidTree(pid);
|
|
144
|
+
killed.push(String(port));
|
|
145
|
+
}
|
|
146
|
+
this.servers.clear();
|
|
147
|
+
this.push();
|
|
148
|
+
return killed;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* client-state — 每浏览器客户端的持久化 UI 状态(<dataDir>/client-state.json):
|
|
3
|
+
* 最近项目/工作目录、目标审查偏好、设置面板状态(提示词模式 + 技能/插件开关 +
|
|
4
|
+
* 视觉桥偏好)、命名预设。文件 I/O 一律 best-effort:持久化故障绝不能
|
|
5
|
+
* 弄崩 server 或阻塞会话。
|
|
6
|
+
*
|
|
7
|
+
* 从 agent-service.ts 抽出,行为保持不变。
|
|
8
|
+
*/
|
|
9
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { dirname } from "node:path";
|
|
11
|
+
/** Stable identity of an extension for the enable/disable toggle: the npm
|
|
12
|
+
* spec for packages (survives version bumps), the resolved entry path
|
|
13
|
+
* otherwise. */
|
|
14
|
+
export function extensionKey(e) {
|
|
15
|
+
const src = e.sourceInfo;
|
|
16
|
+
if (src?.origin === "package" && src.source)
|
|
17
|
+
return src.source;
|
|
18
|
+
return src?.path ?? e.path;
|
|
19
|
+
}
|
|
20
|
+
/** All identities an extension may be disabled by. The SDK applies
|
|
21
|
+
* `sourceInfo` only AFTER extensionsOverride runs (resource-loader reload():
|
|
22
|
+
* override first, applyExtensionSourceInfo second), so inside the override a
|
|
23
|
+
* package extension still has no sourceInfo and extensionKey() falls back to
|
|
24
|
+
* the raw entry path — which never matches the "npm:<pkg>" id the settings
|
|
25
|
+
* panel stores. Derive the package name from the entry path
|
|
26
|
+
* (.../node_modules/<pkg>/... or .../node_modules/@scope/<pkg>/...) so both
|
|
27
|
+
* sides agree. */
|
|
28
|
+
export function extensionKeyCandidates(e) {
|
|
29
|
+
const keys = new Set([extensionKey(e)]);
|
|
30
|
+
const norm = e.path.replace(/\\/g, "/");
|
|
31
|
+
const marker = "/node_modules/";
|
|
32
|
+
const idx = norm.lastIndexOf(marker);
|
|
33
|
+
if (idx !== -1) {
|
|
34
|
+
const segs = norm.slice(idx + marker.length).split("/");
|
|
35
|
+
// Scoped package @scope/name spans two segments.
|
|
36
|
+
const name = segs[0]?.startsWith("@") && segs[1] ? `${segs[0]}/${segs[1]}` : segs[0];
|
|
37
|
+
if (name)
|
|
38
|
+
keys.add(`npm:${name}`);
|
|
39
|
+
}
|
|
40
|
+
return [...keys];
|
|
41
|
+
}
|
|
42
|
+
/** Whether an extension is covered by the disabled list (any identity match). */
|
|
43
|
+
export function isExtensionDisabled(e, disabled) {
|
|
44
|
+
if (disabled.length === 0)
|
|
45
|
+
return false;
|
|
46
|
+
const keys = extensionKeyCandidates(e);
|
|
47
|
+
return disabled.some((d) => keys.includes(d));
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Persists which workspace each browser client last used + which workspaces it
|
|
51
|
+
* has opened, so a server restart / page reload restores the same project and
|
|
52
|
+
* the UI can offer a one-click recent-project list. File I/O is best-effort:
|
|
53
|
+
* persistence problems must never crash the server or block a session.
|
|
54
|
+
*/
|
|
55
|
+
export class ClientStateStore {
|
|
56
|
+
filePath;
|
|
57
|
+
cache = null;
|
|
58
|
+
constructor(filePath) {
|
|
59
|
+
this.filePath = filePath;
|
|
60
|
+
}
|
|
61
|
+
load() {
|
|
62
|
+
if (this.cache)
|
|
63
|
+
return this.cache;
|
|
64
|
+
try {
|
|
65
|
+
const parsed = JSON.parse(readFileSync(this.filePath, "utf8"));
|
|
66
|
+
this.cache = parsed && typeof parsed === "object" ? parsed : {};
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
this.cache = {};
|
|
70
|
+
}
|
|
71
|
+
return this.cache;
|
|
72
|
+
}
|
|
73
|
+
save() {
|
|
74
|
+
try {
|
|
75
|
+
mkdirSync(dirname(this.filePath), { recursive: true });
|
|
76
|
+
// Atomic write (tmp + rename): a crash mid-write must never leave a
|
|
77
|
+
// half-written JSON — that would wipe ALL persisted state (recent
|
|
78
|
+
// projects / presets / settings / goal prefs) on next load.
|
|
79
|
+
const tmp = `${this.filePath}.${process.pid}.tmp`;
|
|
80
|
+
writeFileSync(tmp, JSON.stringify(this.cache, null, 2) + "\n");
|
|
81
|
+
renameSync(tmp, this.filePath);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// best effort
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
get(clientId) {
|
|
88
|
+
return this.load()[clientId] ?? { projects: [] };
|
|
89
|
+
}
|
|
90
|
+
/** Remember which workspace a client last used; bumps its project entry. */
|
|
91
|
+
remember(clientId, cwd) {
|
|
92
|
+
const all = this.load();
|
|
93
|
+
const state = (all[clientId] ??= { projects: [] });
|
|
94
|
+
state.lastCwd = cwd;
|
|
95
|
+
const now = Date.now();
|
|
96
|
+
state.projects = [
|
|
97
|
+
{ path: cwd, lastUsed: now },
|
|
98
|
+
...state.projects.filter((p) => p.path !== cwd),
|
|
99
|
+
].slice(0, 30);
|
|
100
|
+
// Opening the workspace again clears its removal tombstone.
|
|
101
|
+
if (state.removedProjects?.length) {
|
|
102
|
+
state.removedProjects = state.removedProjects.filter((p) => p !== cwd);
|
|
103
|
+
}
|
|
104
|
+
this.save();
|
|
105
|
+
}
|
|
106
|
+
/** Drop one workspace from the recent-project list (user-requested removal).
|
|
107
|
+
* Records a tombstone too: pushProjects() re-discovers cwds from session
|
|
108
|
+
* files on every listing, so without it the entry would instantly reappear. */
|
|
109
|
+
removeProject(clientId, cwd) {
|
|
110
|
+
const all = this.load();
|
|
111
|
+
const state = (all[clientId] ??= { projects: [] });
|
|
112
|
+
state.projects = state.projects.filter((p) => p.path !== cwd);
|
|
113
|
+
if (state.lastCwd === cwd)
|
|
114
|
+
delete state.lastCwd;
|
|
115
|
+
const removed = new Set(state.removedProjects ?? []);
|
|
116
|
+
removed.add(cwd);
|
|
117
|
+
state.removedProjects = [...removed];
|
|
118
|
+
this.save();
|
|
119
|
+
}
|
|
120
|
+
/** Tombstoned projects (explicitly removed by the user) for filtering the
|
|
121
|
+
* merged recent-project list. */
|
|
122
|
+
getRemovedProjects(clientId) {
|
|
123
|
+
return this.load()[clientId]?.removedProjects ?? [];
|
|
124
|
+
}
|
|
125
|
+
/** Last-used goal/review prefs for a client, or undefined if never set. */
|
|
126
|
+
getGoalPrefs(clientId) {
|
|
127
|
+
const s = this.load()[clientId];
|
|
128
|
+
if (!s?.goalPrefs)
|
|
129
|
+
return undefined;
|
|
130
|
+
return {
|
|
131
|
+
reviewModel: s.goalPrefs.reviewModel ?? null,
|
|
132
|
+
maxRounds: s.goalPrefs.maxRounds ?? 0,
|
|
133
|
+
locked: s.goalPrefs.locked ?? true,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/** Persist the client's goal/review preferences (model choice, rounds, lock). */
|
|
137
|
+
saveGoalPrefs(clientId, prefs) {
|
|
138
|
+
const all = this.load();
|
|
139
|
+
const state = (all[clientId] ??= { projects: [] });
|
|
140
|
+
state.goalPrefs = {
|
|
141
|
+
reviewModel: prefs?.reviewModel ?? null,
|
|
142
|
+
maxRounds: prefs?.maxRounds ?? 0,
|
|
143
|
+
locked: prefs?.locked ?? true,
|
|
144
|
+
};
|
|
145
|
+
this.save();
|
|
146
|
+
}
|
|
147
|
+
/** Remember conversations that were still streaming at shutdown (best-
|
|
148
|
+
* effort; called during the graceful-shutdown path). */
|
|
149
|
+
saveInterrupted(clientId, list) {
|
|
150
|
+
if (list.length === 0)
|
|
151
|
+
return;
|
|
152
|
+
const all = this.load();
|
|
153
|
+
const state = (all[clientId] ??= { projects: [] });
|
|
154
|
+
state.interrupted = list.slice(0, 8);
|
|
155
|
+
this.save();
|
|
156
|
+
}
|
|
157
|
+
/** Consume the interrupted-conversation record (returns and clears it) —
|
|
158
|
+
* called once on the client's first attach after a restart. */
|
|
159
|
+
takeInterrupted(clientId) {
|
|
160
|
+
const all = this.load();
|
|
161
|
+
const state = all[clientId];
|
|
162
|
+
const list = state?.interrupted;
|
|
163
|
+
if (list?.length && state) {
|
|
164
|
+
delete state.interrupted;
|
|
165
|
+
this.save();
|
|
166
|
+
}
|
|
167
|
+
return list;
|
|
168
|
+
}
|
|
169
|
+
/** Last-used settings-panel state for a client, or defaults. */
|
|
170
|
+
getSettings(clientId) {
|
|
171
|
+
const s = this.load()[clientId];
|
|
172
|
+
return {
|
|
173
|
+
promptMode: s?.settings?.promptMode === "replace" ? "replace" : "append",
|
|
174
|
+
customSystemPrompt: s?.settings?.customSystemPrompt ?? "",
|
|
175
|
+
disabledSkills: s?.settings?.disabledSkills ?? [],
|
|
176
|
+
disabledExtensions: s?.settings?.disabledExtensions ?? [],
|
|
177
|
+
terminalToolsEnabled: s?.settings?.terminalToolsEnabled ?? true,
|
|
178
|
+
terminalBash: s?.settings?.terminalBash ?? false,
|
|
179
|
+
terminalBashIdleMs: s?.settings?.terminalBashIdleMs ?? 15_000,
|
|
180
|
+
thinkingWrap: s?.settings?.thinkingWrap ?? false,
|
|
181
|
+
toolsWrap: s?.settings?.toolsWrap ?? true,
|
|
182
|
+
visionBridgeEnabled: s?.settings?.visionBridgeEnabled ?? true,
|
|
183
|
+
visionBridgeModel: s?.settings?.visionBridgeModel ?? null,
|
|
184
|
+
visionBridgePromptMode: s?.settings?.visionBridgePromptMode === "replace" ? "replace" : "append",
|
|
185
|
+
visionBridgePrompt: s?.settings?.visionBridgePrompt ?? "",
|
|
186
|
+
reviewPrompt: s?.settings?.reviewPrompt ?? "",
|
|
187
|
+
reviewDisabledSkills: s?.settings?.reviewDisabledSkills ?? [],
|
|
188
|
+
disabledPlugins: s?.settings?.disabledPlugins ?? [],
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/** Persist the client's settings-panel state (partial merge). */
|
|
192
|
+
saveSettings(clientId, settings) {
|
|
193
|
+
const all = this.load();
|
|
194
|
+
const state = (all[clientId] ??= { projects: [] });
|
|
195
|
+
const cur = state.settings ?? {};
|
|
196
|
+
state.settings = {
|
|
197
|
+
promptMode: settings.promptMode ?? cur.promptMode ?? "append",
|
|
198
|
+
customSystemPrompt: settings.customSystemPrompt ?? cur.customSystemPrompt ?? "",
|
|
199
|
+
disabledSkills: settings.disabledSkills ?? cur.disabledSkills ?? [],
|
|
200
|
+
disabledExtensions: settings.disabledExtensions ?? cur.disabledExtensions ?? [],
|
|
201
|
+
terminalToolsEnabled: settings.terminalToolsEnabled ?? cur.terminalToolsEnabled ?? true,
|
|
202
|
+
terminalBash: settings.terminalBash ?? cur.terminalBash ?? false,
|
|
203
|
+
terminalBashIdleMs: settings.terminalBashIdleMs ?? cur.terminalBashIdleMs ?? 15_000,
|
|
204
|
+
thinkingWrap: settings.thinkingWrap ?? cur.thinkingWrap ?? false,
|
|
205
|
+
toolsWrap: settings.toolsWrap ?? cur.toolsWrap ?? true,
|
|
206
|
+
visionBridgeEnabled: settings.visionBridgeEnabled ?? cur.visionBridgeEnabled ?? true,
|
|
207
|
+
visionBridgeModel: settings.visionBridgeModel ?? cur.visionBridgeModel ?? null,
|
|
208
|
+
visionBridgePromptMode: settings.visionBridgePromptMode ??
|
|
209
|
+
cur.visionBridgePromptMode ??
|
|
210
|
+
"append",
|
|
211
|
+
visionBridgePrompt: settings.visionBridgePrompt ?? cur.visionBridgePrompt ?? "",
|
|
212
|
+
reviewPrompt: settings.reviewPrompt ?? cur.reviewPrompt ?? "",
|
|
213
|
+
reviewDisabledSkills: settings.reviewDisabledSkills ?? cur.reviewDisabledSkills ?? [],
|
|
214
|
+
disabledPlugins: settings.disabledPlugins ?? cur.disabledPlugins ?? [],
|
|
215
|
+
};
|
|
216
|
+
this.save();
|
|
217
|
+
}
|
|
218
|
+
/** Named settings presets for a client (empty if never saved). */
|
|
219
|
+
getPresets(clientId) {
|
|
220
|
+
return (this.load()[clientId]?.presets ?? []).map((p) => ({
|
|
221
|
+
...p,
|
|
222
|
+
// Older client-state files predate review settings.
|
|
223
|
+
reviewPrompt: p.reviewPrompt ?? "",
|
|
224
|
+
reviewDisabledSkills: p.reviewDisabledSkills ?? [],
|
|
225
|
+
}));
|
|
226
|
+
}
|
|
227
|
+
/** Persist the client's named settings presets. */
|
|
228
|
+
savePresets(clientId, presets) {
|
|
229
|
+
const all = this.load();
|
|
230
|
+
const state = (all[clientId] ??= { projects: [] });
|
|
231
|
+
state.presets = presets;
|
|
232
|
+
this.save();
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local control socket for the pi-web-ui server.
|
|
3
|
+
*
|
|
4
|
+
* Lets the CLI (and humans) query status and quiesce/unquiesce the server
|
|
5
|
+
* WITHOUT opening a network port or exposing an unauthenticated HTTP
|
|
6
|
+
* endpoint. Only the local OS user can reach it:
|
|
7
|
+
* - POSIX: a mode-0600 Unix domain socket at <dataDir>/pi-web-ui.sock
|
|
8
|
+
* - Windows: a named pipe \\.\pipe\pi-web-ui-<port>
|
|
9
|
+
*
|
|
10
|
+
* Protocol: one JSON object per line.
|
|
11
|
+
* → {"cmd":"status"} ← {"ok":true, ...serviceStatus}
|
|
12
|
+
* → {"cmd":"quiesce"} ← {"ok":true}
|
|
13
|
+
* → {"cmd":"unquiesce"} ← {"ok":true}
|
|
14
|
+
* → anything else ← {"ok":false,"error":"..."}
|
|
15
|
+
*
|
|
16
|
+
* Idle connections are closed after a short timeout so a stuck CLI never
|
|
17
|
+
* holds the socket.
|
|
18
|
+
*/
|
|
19
|
+
import { createServer, createConnection } from "node:net";
|
|
20
|
+
import { chmodSync, existsSync, rmSync } from "node:fs";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
/** How long a control connection may sit idle before the server closes it. */
|
|
23
|
+
const CONTROL_IDLE_TIMEOUT_MS = 5_000;
|
|
24
|
+
/** How long the CLI waits for a reply before giving up. */
|
|
25
|
+
const CONTROL_CLIENT_TIMEOUT_MS = 3_000;
|
|
26
|
+
/** Socket path (POSIX) or pipe name (Windows). */
|
|
27
|
+
export function controlPath(dataDir, port) {
|
|
28
|
+
return process.platform === "win32"
|
|
29
|
+
? `\\\\.\\pipe\\pi-web-ui-${port}`
|
|
30
|
+
: join(dataDir, "pi-web-ui.sock");
|
|
31
|
+
}
|
|
32
|
+
/** Start the control socket; returns a stop function. */
|
|
33
|
+
export function startControlServer(opts) {
|
|
34
|
+
const { service, dataDir, port } = opts;
|
|
35
|
+
const path = controlPath(dataDir, port);
|
|
36
|
+
let server;
|
|
37
|
+
let stop = false;
|
|
38
|
+
if (process.platform === "win32") {
|
|
39
|
+
server = createServer(handleConnection);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
// Remove a stale socket left by a previous crash (only if it's ours —
|
|
43
|
+
// an existing socket file that refuses connections is stale).
|
|
44
|
+
if (existsSync(path)) {
|
|
45
|
+
try {
|
|
46
|
+
rmSync(path);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
/* best-effort */
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
server = createServer(handleConnection);
|
|
53
|
+
}
|
|
54
|
+
// A second instance on the same data dir / port would fail to bind — don't
|
|
55
|
+
// crash the server over it, just log and run without a control socket.
|
|
56
|
+
server.on("error", (err) => {
|
|
57
|
+
if (err.code === "EADDRINUSE") {
|
|
58
|
+
console.warn(`[control] socket ${path} already in use — control socket disabled`);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
console.warn(`[control] socket error: ${err.message}`);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
function handleConnection(sock) {
|
|
65
|
+
let buf = "";
|
|
66
|
+
const timer = setTimeout(() => {
|
|
67
|
+
sock.destroy();
|
|
68
|
+
}, CONTROL_IDLE_TIMEOUT_MS);
|
|
69
|
+
sock.on("data", (chunk) => {
|
|
70
|
+
buf += chunk.toString("utf8");
|
|
71
|
+
let nl;
|
|
72
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
73
|
+
const line = buf.slice(0, nl).trim();
|
|
74
|
+
buf = buf.slice(nl + 1);
|
|
75
|
+
if (!line)
|
|
76
|
+
continue;
|
|
77
|
+
timer.refresh();
|
|
78
|
+
let req;
|
|
79
|
+
try {
|
|
80
|
+
req = JSON.parse(line);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
sock.write(JSON.stringify({ ok: false, error: "bad json" }) + "\n");
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
let resp;
|
|
87
|
+
switch (req.cmd) {
|
|
88
|
+
case "status":
|
|
89
|
+
resp = { ok: true, ...service.serviceStatus() };
|
|
90
|
+
break;
|
|
91
|
+
case "quiesce":
|
|
92
|
+
service.quiesce();
|
|
93
|
+
resp = { ok: true };
|
|
94
|
+
break;
|
|
95
|
+
case "unquiesce":
|
|
96
|
+
service.unquiesce();
|
|
97
|
+
resp = { ok: true };
|
|
98
|
+
break;
|
|
99
|
+
default:
|
|
100
|
+
resp = { ok: false, error: `unknown cmd: ${String(req.cmd)}` };
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
sock.write(JSON.stringify(resp) + "\n");
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
sock.on("error", () => {
|
|
107
|
+
/* client vanished */
|
|
108
|
+
});
|
|
109
|
+
sock.on("close", () => clearTimeout(timer));
|
|
110
|
+
}
|
|
111
|
+
if (process.platform === "win32") {
|
|
112
|
+
// net.Server on a named pipe: listen on the pipe name directly.
|
|
113
|
+
server.listen(path, () => {
|
|
114
|
+
console.log(` control : ${path}`);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
server.listen(path, () => {
|
|
119
|
+
try {
|
|
120
|
+
chmodSync(path, 0o600);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
/* best-effort */
|
|
124
|
+
}
|
|
125
|
+
console.log(` control : ${path}`);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return () => {
|
|
129
|
+
stop = true;
|
|
130
|
+
server.close();
|
|
131
|
+
try {
|
|
132
|
+
if (process.platform !== "win32" && existsSync(path))
|
|
133
|
+
rmSync(path);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
/* best-effort */
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* CLI-side client: send one command and return the parsed reply (or null if
|
|
142
|
+
* the server is unreachable / timed out).
|
|
143
|
+
*/
|
|
144
|
+
export function sendControlCommand(dataDir, port, cmd) {
|
|
145
|
+
const path = controlPath(dataDir, port);
|
|
146
|
+
return new Promise((resolve) => {
|
|
147
|
+
const sock = createConnection(path);
|
|
148
|
+
let done = false;
|
|
149
|
+
const finish = (v) => {
|
|
150
|
+
if (done)
|
|
151
|
+
return;
|
|
152
|
+
done = true;
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
sock.destroy();
|
|
155
|
+
resolve(v);
|
|
156
|
+
};
|
|
157
|
+
const timer = setTimeout(() => finish(null), CONTROL_CLIENT_TIMEOUT_MS);
|
|
158
|
+
let buf = "";
|
|
159
|
+
sock.on("connect", () => {
|
|
160
|
+
sock.write(JSON.stringify({ cmd }) + "\n");
|
|
161
|
+
});
|
|
162
|
+
sock.on("data", (chunk) => {
|
|
163
|
+
buf += chunk.toString("utf8");
|
|
164
|
+
const nl = buf.indexOf("\n");
|
|
165
|
+
if (nl >= 0) {
|
|
166
|
+
try {
|
|
167
|
+
finish(JSON.parse(buf.slice(0, nl)));
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
finish(null);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
sock.on("error", () => finish(null));
|
|
175
|
+
sock.on("close", () => finish(null));
|
|
176
|
+
});
|
|
177
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight bash fallback for Windows.
|
|
3
|
+
*
|
|
4
|
+
* When neither Git Bash nor a bash on PATH exists, download busybox-w32
|
|
5
|
+
* (single self-contained ~1.5MB exe, no installer) into <home>/.pi-web/bin/
|
|
6
|
+
* and expose it as bash.exe — busybox dispatches on argv[0], so `bash.exe`
|
|
7
|
+
* runs its bash (ash) applet. The terminal panel (terminals.ts) and the SDK
|
|
8
|
+
* bash tool (via PATH) then both resolve to it, so the agent never silently
|
|
9
|
+
* falls back to cmd/PowerShell syntax on a bare Windows box.
|
|
10
|
+
*
|
|
11
|
+
* Download is fire-and-forget at server start and never throws: on failure
|
|
12
|
+
* the terminal simply falls back to $COMSPEC (cmd.exe) as before.
|
|
13
|
+
*/
|
|
14
|
+
import { copyFileSync, existsSync, mkdirSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
/** Official busybox-w32 64-bit Unicode build (Win10 1903+ / Win11). */
|
|
18
|
+
const BUSYBOX_URL = "https://frippery.org/files/busybox/busybox64u.exe";
|
|
19
|
+
/** busybox.exe is ~660KB; anything far smaller is an error page, not a binary. */
|
|
20
|
+
const MIN_SIZE = 500_000;
|
|
21
|
+
/** Download cap — a stalled connection must not block startup forever. */
|
|
22
|
+
const DOWNLOAD_TIMEOUT_MS = 60_000;
|
|
23
|
+
/** Directory holding the busybox fallback (shared with terminals.ts / PATH). */
|
|
24
|
+
export function windowsBashDir() {
|
|
25
|
+
return join(homedir(), ".pi-web", "bin");
|
|
26
|
+
}
|
|
27
|
+
/** bash.exe (busybox bash applet) used by the terminal and the SDK bash tool. */
|
|
28
|
+
export function windowsBashPath() {
|
|
29
|
+
return join(windowsBashDir(), "bash.exe");
|
|
30
|
+
}
|
|
31
|
+
/** True when a standard Git Bash install exists (SDK's preferred shell). */
|
|
32
|
+
export function hasGitBash() {
|
|
33
|
+
const pf = process.env.ProgramFiles;
|
|
34
|
+
const pf86 = process.env["ProgramFiles(x86)"];
|
|
35
|
+
for (const cand of [
|
|
36
|
+
pf ? join(pf, "Git", "bin", "bash.exe") : "",
|
|
37
|
+
pf86 ? join(pf86, "Git", "bin", "bash.exe") : "",
|
|
38
|
+
]) {
|
|
39
|
+
if (cand && existsSync(cand))
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Ensure a bash.exe exists on Windows. No-op when Git Bash is already
|
|
46
|
+
* installed (it is strictly preferred) or the fallback is already present.
|
|
47
|
+
* Never throws — failures degrade silently to the previous behaviour.
|
|
48
|
+
*
|
|
49
|
+
* @returns the bash.exe path when ready, null otherwise.
|
|
50
|
+
*/
|
|
51
|
+
export async function ensureWindowsBash() {
|
|
52
|
+
if (process.platform !== "win32")
|
|
53
|
+
return null;
|
|
54
|
+
const target = windowsBashPath();
|
|
55
|
+
if (existsSync(target))
|
|
56
|
+
return target;
|
|
57
|
+
if (hasGitBash())
|
|
58
|
+
return null; // Git Bash preferred — nothing to install.
|
|
59
|
+
const dir = windowsBashDir();
|
|
60
|
+
mkdirSync(dir, { recursive: true });
|
|
61
|
+
const tmp = join(dir, `busybox-${process.pid}.tmp`);
|
|
62
|
+
try {
|
|
63
|
+
const res = await fetch(BUSYBOX_URL, {
|
|
64
|
+
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
|
65
|
+
});
|
|
66
|
+
if (!res.ok)
|
|
67
|
+
return null;
|
|
68
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
69
|
+
if (buf.length < MIN_SIZE)
|
|
70
|
+
return null; // error/HTML page, not the exe.
|
|
71
|
+
writeFileSync(tmp, buf);
|
|
72
|
+
renameSync(tmp, join(dir, "busybox.exe"));
|
|
73
|
+
copyFileSync(join(dir, "busybox.exe"), target);
|
|
74
|
+
return target;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
rmSync(tmp, { force: true });
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|