@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.
Files changed (140) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -0
  3. package/bin/pi-web-ui.mjs +1818 -0
  4. package/deploy/com.xingshuyin.pi-web-ui.plist +48 -0
  5. package/deploy/nginx-subpath.conf +88 -0
  6. package/deploy/pi-web-ui-task.xml +71 -0
  7. package/deploy/pi-web-ui.service +31 -0
  8. package/dist/server/agent-service.js +2975 -0
  9. package/dist/server/attachments.js +672 -0
  10. package/dist/server/bg-servers.js +150 -0
  11. package/dist/server/client-state.js +234 -0
  12. package/dist/server/control-socket.js +177 -0
  13. package/dist/server/ensure-bash.js +80 -0
  14. package/dist/server/files-service.js +709 -0
  15. package/dist/server/goal-service.js +869 -0
  16. package/dist/server/index.js +879 -0
  17. package/dist/server/mcp-bridge.js +268 -0
  18. package/dist/server/model-admin.js +821 -0
  19. package/dist/server/patch-node-pty.js +80 -0
  20. package/dist/server/plugin-facilities.js +299 -0
  21. package/dist/server/plugin-updater.js +226 -0
  22. package/dist/server/plugins.js +893 -0
  23. package/dist/server/process-utils.js +112 -0
  24. package/dist/server/protocol-version.js +11 -0
  25. package/dist/server/protocol.js +6 -0
  26. package/dist/server/scm.js +298 -0
  27. package/dist/server/serialize.js +172 -0
  28. package/dist/server/settings-service.js +395 -0
  29. package/dist/server/slash-commands.js +263 -0
  30. package/dist/server/terminals.js +1372 -0
  31. package/dist/server/text-sniff.js +268 -0
  32. package/dist/server/uploads.js +107 -0
  33. package/dist/server/vision-bridge.js +131 -0
  34. package/dist/server/webui-context.js +222 -0
  35. package/extensions/webui.ts +192 -0
  36. package/package.json +99 -0
  37. package/web/dist/assets/TerminalPanel-6GBZ9nXN.css +32 -0
  38. package/web/dist/assets/TerminalPanel-D4AktsEn.js +2 -0
  39. package/web/dist/assets/abnfDiagram-VCTEODGH-Ct5StJwt.js +1 -0
  40. package/web/dist/assets/arc-pkuxEN8A.js +1 -0
  41. package/web/dist/assets/architectureDiagram-5GKGNRK7-C6P6yn4y.js +36 -0
  42. package/web/dist/assets/blockDiagram-I7D4REHJ-BmgfH1jt.js +129 -0
  43. package/web/dist/assets/c4Diagram-7LVT6UL2-CgmwTFsK.js +38 -0
  44. package/web/dist/assets/channel-0Y__6sq2.js +1 -0
  45. package/web/dist/assets/chunk-2Q5K7J3B-DX_NqAiH.js +1 -0
  46. package/web/dist/assets/chunk-5VM5RSS4-gLeuQuYv.js +15 -0
  47. package/web/dist/assets/chunk-F27PBJKO-D4WX4mhI.js +1 -0
  48. package/web/dist/assets/chunk-IMKFNOWR-DBpDxGQD.js +231 -0
  49. package/web/dist/assets/chunk-JWPE2WC7-DIQ8iZN5.js +1 -0
  50. package/web/dist/assets/chunk-POPQ4Y6H-F_YTNoGz.js +1 -0
  51. package/web/dist/assets/chunk-SVP7TREG-CmDIvX-r.js +88 -0
  52. package/web/dist/assets/chunk-TICWLB2K-BQUzHQyS.js +206 -0
  53. package/web/dist/assets/chunk-XXDRQBXY-DqR9iffH.js +1 -0
  54. package/web/dist/assets/classDiagram-ZZMXUADV-BEfGIpzu.js +1 -0
  55. package/web/dist/assets/classDiagram-v2-VYDZK3BY-BEfGIpzu.js +1 -0
  56. package/web/dist/assets/cose-bilkent-JH36ORCC-Dl67W4Dx.js +1 -0
  57. package/web/dist/assets/cynefin-OW5HDTMX-fWdqaCpX.js +178 -0
  58. package/web/dist/assets/cynefinDiagram-5FMLGOSQ-CSUAXbaP.js +62 -0
  59. package/web/dist/assets/cytoscape.esm-Bch-eiPH.js +331 -0
  60. package/web/dist/assets/dagre-GXQ25YYZ-CqUVwu1X.js +4 -0
  61. package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  62. package/web/dist/assets/diagram-S7CK7UJ4-bRce-WYV.js +30 -0
  63. package/web/dist/assets/diagram-UQ7AKVKN-CiqPtPBE.js +41 -0
  64. package/web/dist/assets/diagram-VSXAHHWV-CbEA05LX.js +3 -0
  65. package/web/dist/assets/diagram-VX7I27RA-CRcY9MD-.js +24 -0
  66. package/web/dist/assets/diagram-Z3DM3KII-DmTwvZdS.js +24 -0
  67. package/web/dist/assets/ebnfDiagram-PWID7BFC-DbwmiGVj.js +1 -0
  68. package/web/dist/assets/erDiagram-RLTQ6QDP-Cxiaf87y.js +99 -0
  69. package/web/dist/assets/flowDiagram-HODETNUW-BQiecP1I.js +168 -0
  70. package/web/dist/assets/ganttDiagram-EL5Y4UJY-CSr8RRNw.js +292 -0
  71. package/web/dist/assets/gitGraphDiagram-WWUBYQGX-C4Tdis3g.js +106 -0
  72. package/web/dist/assets/index-CniHcFxT.js +22 -0
  73. package/web/dist/assets/index-DYz2iP_B.css +10 -0
  74. package/web/dist/assets/infoDiagram-27XIBGKW-ij_5A0pY.js +2 -0
  75. package/web/dist/assets/init-Gi6I4Gst.js +1 -0
  76. package/web/dist/assets/ishikawaDiagram-5VMMS53U-C8-tm2LV.js +70 -0
  77. package/web/dist/assets/journeyDiagram-3NMN7TZE-ZJedkNel.js +139 -0
  78. package/web/dist/assets/kanban-definition-UXKFOSKX-BEuoM53n.js +89 -0
  79. package/web/dist/assets/katex-C5jXJg4s.js +257 -0
  80. package/web/dist/assets/layout-zq_9DigZ.js +1 -0
  81. package/web/dist/assets/linear-D-QXJ5Wc.js +1 -0
  82. package/web/dist/assets/markdown-CUPbftfV.js +51 -0
  83. package/web/dist/assets/mermaid.core-CCiG-bOm.js +312 -0
  84. package/web/dist/assets/mindmap-definition-YA3MSWOX-VaAisUIe.js +96 -0
  85. package/web/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  86. package/web/dist/assets/pegDiagram-XKGWAZYB-D5vmRtkn.js +1 -0
  87. package/web/dist/assets/pieDiagram-E7YTZNPT-loD6V8fn.js +39 -0
  88. package/web/dist/assets/quadrantDiagram-AXDQQJYC-B7E4mH1-.js +7 -0
  89. package/web/dist/assets/railroadDiagram-O6MQD6OU-DTALHvDa.js +1 -0
  90. package/web/dist/assets/react-ZkKJMDQQ.js +24 -0
  91. package/web/dist/assets/requirementDiagram-BXWQKSXE-ZjJKuEcR.js +84 -0
  92. package/web/dist/assets/sankeyDiagram-P5KCCOFB-DufTudDP.js +40 -0
  93. package/web/dist/assets/sequenceDiagram-WJ2MYXX4-B-Zk4alI.js +162 -0
  94. package/web/dist/assets/sizeCapture-INFHLROL-B1pjlxXN.js +1 -0
  95. package/web/dist/assets/stateDiagram-D77RDMKH-BeWg6PIR.js +1 -0
  96. package/web/dist/assets/stateDiagram-v2-MP3YSRHH-DagWhIFJ.js +1 -0
  97. package/web/dist/assets/swimlanes-42K2YHIH-4J4jmMmO.js +1 -0
  98. package/web/dist/assets/swimlanesDiagram-VR7AAH4N-Dsi9x540.js +8 -0
  99. package/web/dist/assets/timeline-definition-24CTP7MA-ByqLfxqe.js +120 -0
  100. package/web/dist/assets/vennDiagram-4TSXK5OY-B_EqVW4R.js +34 -0
  101. package/web/dist/assets/wardleyDiagram-VM6X3IG4-Bu7sA6F8.js +78 -0
  102. package/web/dist/assets/xterm-D1D2FVe3.js +38 -0
  103. package/web/dist/assets/xychartDiagram-S5SC5T6Z-w1KPVGKk.js +7 -0
  104. package/web/dist/favicon.svg +8 -0
  105. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  106. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  107. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  108. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
  109. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
  110. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
  111. package/web/dist/fonts/inter/inter-latin-400-normal.woff2 +0 -0
  112. package/web/dist/fonts/inter/inter-latin-500-normal.woff2 +0 -0
  113. package/web/dist/fonts/inter/inter-latin-600-normal.woff2 +0 -0
  114. package/web/dist/fonts/inter/inter-latin-700-normal.woff2 +0 -0
  115. package/web/dist/fonts/inter/inter-latin-800-normal.woff2 +0 -0
  116. package/web/dist/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
  117. package/web/dist/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
  118. package/web/dist/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
  119. package/web/dist/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
  120. package/web/dist/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
  121. package/web/dist/icon.ico +0 -0
  122. package/web/dist/index.html +17 -0
  123. package/web/public/favicon.svg +8 -0
  124. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  125. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  126. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  127. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
  128. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
  129. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
  130. package/web/public/fonts/inter/inter-latin-400-normal.woff2 +0 -0
  131. package/web/public/fonts/inter/inter-latin-500-normal.woff2 +0 -0
  132. package/web/public/fonts/inter/inter-latin-600-normal.woff2 +0 -0
  133. package/web/public/fonts/inter/inter-latin-700-normal.woff2 +0 -0
  134. package/web/public/fonts/inter/inter-latin-800-normal.woff2 +0 -0
  135. package/web/public/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
  136. package/web/public/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
  137. package/web/public/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
  138. package/web/public/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
  139. package/web/public/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
  140. package/web/public/icon.ico +0 -0
@@ -0,0 +1,709 @@
1
+ /**
2
+ * Files service — 从 agent-service.ts 抽出(文件树列目录 / 预览读写 / 路径补全 /
3
+ * SCM 只读查询 / 目录与 git-dir watcher)。
4
+ *
5
+ * 全部为无状态 fs 操作 + 两个自持的 watcher(当前列出目录、git dir),
6
+ * 经 FilesHost 回调与 ClientSession 解耦。
7
+ */
8
+ import { statSync, writeFileSync, watch } from "node:fs";
9
+ import { resolve, relative, sep } from "node:path";
10
+ import { previewKind, looksLikeText, decodeText, hexDump, countLines, } from "./text-sniff.js";
11
+ import { gitDirOf, isNotRepoError, scmStatus, scmHistory, scmFileDiff, scmCommitDetail, } from "./scm.js";
12
+ export const IS_WIN32 = process.platform === "win32";
13
+ /** 预览只读文件前 512KB。 */
14
+ export const MAX_PREVIEW_BYTES = 512 * 1024;
15
+ // mac/linux: hide build & dependency noise (original behavior).
16
+ const IGNORED_ENTRIES = new Set([
17
+ "node_modules",
18
+ ".git",
19
+ ".svn",
20
+ ".hg",
21
+ "dist",
22
+ ".next",
23
+ ".nuxt",
24
+ ".cache",
25
+ ".venv",
26
+ "venv",
27
+ "__pycache__",
28
+ "coverage",
29
+ ".pi-web",
30
+ ".DS_Store",
31
+ "Thumbs.db",
32
+ ]);
33
+ // Windows: the file tree is the primary way to navigate a project, so only
34
+ // hide what would flood or destabilize the panel (dependency trees, VCS
35
+ // internals, session data) plus pure junk. Build output (dist/.next/…) and
36
+ // local env dirs (venv/__pycache__/…) stay visible — "所有文件可查看".
37
+ const IGNORED_ENTRIES_WIN = new Set([
38
+ "node_modules",
39
+ ".git",
40
+ ".pi-web",
41
+ ".DS_Store",
42
+ "Thumbs.db",
43
+ "desktop.ini",
44
+ ]);
45
+ /** The ignore set for the current platform — keeps win/posix lists separate. */
46
+ function ignoredEntries() {
47
+ return IS_WIN32 ? IGNORED_ENTRIES_WIN : IGNORED_ENTRIES;
48
+ }
49
+ /**
50
+ * Resolve a workspace-relative path against a root, refusing traversal
51
+ * (".." escapes). Returns { abs, rel } — rel is normalized and slash-
52
+ * separated — or null when the path leaves the workspace.
53
+ */
54
+ export function workspacePath(root, raw) {
55
+ const abs = resolve(root, raw);
56
+ const rawRel = relative(root, abs);
57
+ if (rawRel.startsWith("..") || rawRel.includes(`${sep}..`))
58
+ return null;
59
+ // Normalize to forward slashes: the wire protocol and the frontend always
60
+ // use "/", but relative() returns "\\" on Windows.
61
+ return { abs, rel: rawRel.split(sep).join("/") };
62
+ }
63
+ /**
64
+ * Read a directory for the file panel. The two platforms intentionally use
65
+ * different strategies — do NOT unify them:
66
+ *
67
+ * darwin/linux (posix): original behavior — hide build/dependency noise,
68
+ * small cap, hard error notice when the directory itself is unreadable.
69
+ *
70
+ * win32: stability and completeness first, preview second. ACL-protected
71
+ * system dirs (C:\$Recycle.Bin, Program Files internals, OneDrive placeholders)
72
+ * throw EPERM/EACCES on open — that must not kill the panel, so it degrades
73
+ * to an empty listing plus a warning. Directory symlinks/junctions are
74
+ * followed so mklink /D folders stay navigable; broken links still show as
75
+ * files instead of vanishing. The cap is 4x posix and truncation is reported
76
+ * via `truncated` instead of happening silently.
77
+ */
78
+ async function readDirForUI(abs, rel) {
79
+ const { join } = await import("node:path");
80
+ const fs = await import("node:fs/promises");
81
+ const ignored = ignoredEntries();
82
+ const MAX = IS_WIN32 ? 2000 : 500;
83
+ let dirents;
84
+ try {
85
+ dirents = await fs.readdir(abs, { withFileTypes: true });
86
+ }
87
+ catch (err) {
88
+ if (!IS_WIN32)
89
+ throw err;
90
+ // Windows ACL-protected/system dirs throw EPERM/EACCES on open —
91
+ // degrade to an empty listing; listFiles turns this into a warning.
92
+ return { entries: [], truncated: false, error: err.message };
93
+ }
94
+ const out = [];
95
+ for (const d of dirents) {
96
+ if (ignored.has(d.name))
97
+ continue;
98
+ let type;
99
+ if (IS_WIN32 && d.isSymbolicLink()) {
100
+ // mklink /D symlinks and junctions are reparse points — libuv
101
+ // classifies them as links, so isDirectory() is false. Follow the
102
+ // target so folder links stay navigable; broken links still show.
103
+ try {
104
+ const st = await fs.stat(join(abs, d.name));
105
+ type = st.isDirectory() ? "dir" : "file";
106
+ }
107
+ catch {
108
+ type = "file";
109
+ }
110
+ }
111
+ else {
112
+ type = d.isDirectory() ? "dir" : "file";
113
+ }
114
+ const entry = {
115
+ name: d.name,
116
+ path: rel === "" ? d.name : `${rel}/${d.name}`,
117
+ type,
118
+ };
119
+ if (type === "file")
120
+ entry.kind = previewKind(d.name);
121
+ out.push(entry);
122
+ }
123
+ out.sort((a, b) => a.type === b.type
124
+ ? a.name.localeCompare(b.name)
125
+ : a.type === "dir"
126
+ ? -1
127
+ : 1);
128
+ const truncated = out.length > MAX;
129
+ if (truncated)
130
+ out.length = MAX;
131
+ return { entries: out, truncated };
132
+ }
133
+ export class FilesService {
134
+ host;
135
+ // ---- 当前列出目录的 watcher ----
136
+ fsWatcher = null;
137
+ watchPath = null;
138
+ watchTimer = null;
139
+ /** Recursive-watch state (win32/darwin): one native recursive watcher on the
140
+ * workspace root covers the WHOLE tree; watchPath just tracks which listed
141
+ * directory file_changed should refresh. */
142
+ recursiveWatcher = false;
143
+ watchRoot = null;
144
+ /** 已对哪个工作区根提示过「实时监听不可用,已回落轮询」——只提示一次。 */
145
+ degradedNoticedFor = null;
146
+ // ---- git dir watcher ----
147
+ gitWatcher = null;
148
+ gitWatchCwd = null;
149
+ gitDirtyTimer = null;
150
+ constructor(host) {
151
+ this.host = host;
152
+ }
153
+ async listFiles(relPath) {
154
+ const { resolve, sep, relative } = await import("node:path");
155
+ const root = resolve(this.host.getCwd());
156
+ const target = relPath ? resolve(root, relPath) : root;
157
+ const rawRel = relative(root, target);
158
+ if (rawRel.startsWith("..") || rawRel.includes(`${sep}..`)) {
159
+ this.host.emit({
160
+ type: "notice",
161
+ level: "warning",
162
+ text: `路径超出工作区:${relPath ?? ""}`,
163
+ });
164
+ return;
165
+ }
166
+ // Normalize to forward slashes: the wire protocol and the frontend
167
+ // always use "/", but relative() returns "\\" on Windows.
168
+ const rel = rawRel.split(sep).join("/");
169
+ const { entries, truncated, error } = await readDirForUI(target, rel);
170
+ // Watch the listed directory (only after a successful read — a missing
171
+ // dir throws above and must not create a watcher on a phantom path).
172
+ this.watchDir(target, rel);
173
+ if (error) {
174
+ // Windows-only: unreadable system dirs degrade to an empty list
175
+ // with a warning instead of a hard error — the panel stays usable.
176
+ this.host.emit({
177
+ type: "notice",
178
+ level: "warning",
179
+ text: `目录不可读:${error}`,
180
+ });
181
+ }
182
+ this.host.emit({
183
+ type: "files",
184
+ path: rel === "" ? "" : rel,
185
+ parent: rel === ""
186
+ ? null
187
+ : rel.includes("/")
188
+ ? rel.slice(0, rel.lastIndexOf("/"))
189
+ : "",
190
+ entries,
191
+ truncated,
192
+ });
193
+ }
194
+ /**
195
+ * Global search: recursive filename match across the active workspace.
196
+ * Best-effort bounded walk — ignored dirs (node_modules/.git/…) are
197
+ * skipped, unreadable dirs silently passed, and hard caps on results /
198
+ * visited entries / elapsed time keep big repos responsive. Always answers
199
+ * with a search_files_result echoing reqId so the client's request never
200
+ * stalls (ok:false on unexpected failure).
201
+ */
202
+ async searchFiles(query, reqId) {
203
+ const { join } = await import("node:path");
204
+ const fsp = await import("node:fs/promises");
205
+ const q = query.trim().toLowerCase();
206
+ if (!q) {
207
+ this.host.emit({ type: "search_files_result", reqId, ok: true, results: [] });
208
+ return;
209
+ }
210
+ const root = resolve(this.host.getActiveCwd());
211
+ const ignored = ignoredEntries();
212
+ const MAX_RESULTS = 50;
213
+ const MAX_VISITED = 20000;
214
+ const MAX_MS = 4000;
215
+ const start = Date.now();
216
+ const results = [];
217
+ let visited = 0;
218
+ let truncated = false;
219
+ const budgetLeft = () => results.length < MAX_RESULTS &&
220
+ visited < MAX_VISITED &&
221
+ Date.now() - start < MAX_MS;
222
+ // Breadth-first-ish iterative stack; depth cap is a symlink-cycle guard.
223
+ const walk = async (abs, rel, depth) => {
224
+ if (!budgetLeft() || depth > 24) {
225
+ truncated = true;
226
+ return;
227
+ }
228
+ let dirents;
229
+ try {
230
+ dirents = await fsp.readdir(abs, { withFileTypes: true });
231
+ }
232
+ catch {
233
+ return; // unreadable dir (ACL/permissions) — skip silently
234
+ }
235
+ for (const d of dirents) {
236
+ visited++;
237
+ if (!budgetLeft()) {
238
+ truncated = true;
239
+ break;
240
+ }
241
+ if (ignored.has(d.name))
242
+ continue;
243
+ const childRel = rel ? `${rel}/${d.name}` : d.name;
244
+ if (d.name.toLowerCase().includes(q)) {
245
+ results.push({
246
+ path: childRel,
247
+ name: d.name,
248
+ type: d.isDirectory() ? "dir" : "file",
249
+ });
250
+ }
251
+ if (d.isDirectory()) {
252
+ await walk(join(abs, d.name), childRel, depth + 1);
253
+ }
254
+ }
255
+ };
256
+ try {
257
+ await walk(root, "", 0);
258
+ this.host.emit({
259
+ type: "search_files_result",
260
+ reqId,
261
+ ok: true,
262
+ results,
263
+ ...(truncated ? { truncated: true } : {}),
264
+ });
265
+ }
266
+ catch {
267
+ this.host.emit({ type: "search_files_result", reqId, ok: false, results: [] });
268
+ }
269
+ }
270
+ /**
271
+ * Source-control panel: read-only git queries via server-side execFile
272
+ * (no shell, no prompts). Always responds with an scm_data message echoing
273
+ * reqId so the client's request matching never stalls. Also (re)arms the
274
+ * git-dir watcher so external repo changes push scm_changed.
275
+ */
276
+ async scmQuery(kind, reqId, arg) {
277
+ const cwd = this.host.getActiveCwd();
278
+ if (kind === "status")
279
+ this.watchGitDir(cwd);
280
+ try {
281
+ if (kind === "status") {
282
+ const data = await scmStatus(cwd);
283
+ this.host.emit({ type: "scm_data", reqId, kind, ok: true, ...data });
284
+ return;
285
+ }
286
+ if (kind === "history") {
287
+ const history = await scmHistory(cwd);
288
+ this.host.emit({ type: "scm_data", reqId, kind, ok: true, history });
289
+ return;
290
+ }
291
+ if (kind === "filediff" && arg?.path) {
292
+ // Path stays inside the workspace (defense in depth — paths come
293
+ // from our own listing, and execFile passes args verbatim anyway).
294
+ const { resolve, relative } = await import("node:path");
295
+ const rel = relative(resolve(cwd), resolve(cwd, arg.path));
296
+ if (rel.startsWith("..") || rel === "")
297
+ throw new Error("路径超出工作区");
298
+ const { staged, worktree } = await scmFileDiff(cwd, arg.path);
299
+ this.host.emit({
300
+ type: "scm_data", reqId, kind, ok: true,
301
+ stagedText: staged, worktreeText: worktree,
302
+ });
303
+ return;
304
+ }
305
+ if (kind === "commit" && arg?.hash && /^[0-9a-f]{7,40}$/i.test(arg.hash)) {
306
+ const text = await scmCommitDetail(cwd, arg.hash);
307
+ this.host.emit({ type: "scm_data", reqId, kind, ok: true, text });
308
+ return;
309
+ }
310
+ throw new Error("无效的 scm 查询参数");
311
+ }
312
+ catch (err) {
313
+ if (isNotRepoError(err)) {
314
+ // Not a repo — a valid empty answer so the panel shows its hint.
315
+ this.host.emit({
316
+ type: "scm_data", reqId, kind, ok: true, notRepo: true,
317
+ branch: "", detached: false, upstream: null,
318
+ ahead: 0, behind: 0, upstreamGone: false,
319
+ files: [], branches: [], stats: {}, history: [],
320
+ });
321
+ this.unwatchGit();
322
+ return;
323
+ }
324
+ this.host.emit({
325
+ type: "scm_data",
326
+ reqId,
327
+ kind,
328
+ ok: false,
329
+ error: err instanceof Error ? err.message : String(err),
330
+ });
331
+ }
332
+ }
333
+ /**
334
+ * Watch the active repo's git dir (HEAD / index / packed-refs live at the
335
+ * top level, which covers commit / stage / checkout). Re-targets when the
336
+ * queried workspace changes. Uses `git rev-parse --absolute-git-dir` so
337
+ * worktrees and submodules resolve to the real dir.
338
+ */
339
+ async watchGitDir(cwd) {
340
+ if (this.gitWatchCwd === cwd && this.gitWatcher)
341
+ return;
342
+ this.unwatchGit();
343
+ this.gitWatchCwd = cwd;
344
+ try {
345
+ const gitDir = await gitDirOf(cwd);
346
+ if (!gitDir)
347
+ return;
348
+ this.gitWatcher = watch(gitDir, { persistent: false }, () => {
349
+ if (this.host.isDisposed() || this.gitDirtyTimer)
350
+ return;
351
+ // Debounce: one checkout/commit fires several fs events.
352
+ this.gitDirtyTimer = setTimeout(() => {
353
+ this.gitDirtyTimer = null;
354
+ this.host.emit({ type: "scm_changed" });
355
+ }, 600);
356
+ });
357
+ this.gitWatcher.on("error", () => {
358
+ // Unsupported filesystem — silently fall back to manual refresh.
359
+ this.unwatchGit();
360
+ });
361
+ }
362
+ catch {
363
+ // no .git here (or git missing) — watcher stays off; queries still work
364
+ this.unwatchGit();
365
+ }
366
+ }
367
+ unwatchGit() {
368
+ if (this.gitWatcher) {
369
+ try {
370
+ this.gitWatcher.close();
371
+ }
372
+ catch {
373
+ // already gone
374
+ }
375
+ }
376
+ this.gitWatcher = null;
377
+ this.gitWatchCwd = null;
378
+ if (this.gitDirtyTimer) {
379
+ clearTimeout(this.gitDirtyTimer);
380
+ this.gitDirtyTimer = null;
381
+ }
382
+ }
383
+ watchDir(absPath, rel) {
384
+ if (this.host.isDisposed())
385
+ return;
386
+ // ---- Recursive mode (native on win32 / darwin): watch the workspace
387
+ // root once, so deep changes in NOT-listed subdirectories still refresh
388
+ // the panel (the old per-directory watch only saw the current level).
389
+ if (process.platform === "win32" || process.platform === "darwin") {
390
+ const root = resolve(this.host.getCwd());
391
+ if (this.recursiveWatcher) {
392
+ if (this.watchRoot === root) {
393
+ // Same workspace — only retarget the refresh path.
394
+ this.watchPath = rel;
395
+ return;
396
+ }
397
+ this.unwatchDir(); // cwd switched to another project
398
+ }
399
+ try {
400
+ const w = watch(root, { persistent: false, recursive: true }, (_event, filename) => {
401
+ // Skip high-churn subtrees (npm install storms); .git has its
402
+ // own watcher for the SCM panel. filename may be null on some
403
+ // platforms — let those through (debounce absorbs bursts).
404
+ if (filename) {
405
+ const f = String(filename).split("\\").join("/");
406
+ // Single-segment names (e.g. the dir itself) have no "/" —
407
+ // slice(0, -1) would corrupt them, so special-case that.
408
+ const slash = f.indexOf("/");
409
+ const top = slash === -1 ? f : f.slice(0, slash);
410
+ if (top === "node_modules" || top === ".git")
411
+ return;
412
+ }
413
+ // Burst events are debounced into a single refresh.
414
+ if (this.watchTimer)
415
+ return;
416
+ this.watchTimer = setTimeout(() => {
417
+ this.watchTimer = null;
418
+ this.host.emit({
419
+ type: "file_changed",
420
+ path: this.watchPath ?? "",
421
+ });
422
+ }, 400);
423
+ });
424
+ w.on("error", () => {
425
+ // Directory deleted / unsupported — fall back to poll semantics.
426
+ this.noticeDegraded(root);
427
+ this.unwatchDir();
428
+ });
429
+ this.fsWatcher = w;
430
+ this.watchRoot = root;
431
+ this.recursiveWatcher = true;
432
+ this.watchPath = rel;
433
+ return;
434
+ }
435
+ catch {
436
+ // recursive unsupported here — fall through to per-directory watch.
437
+ this.fsWatcher = null;
438
+ this.recursiveWatcher = false;
439
+ this.watchRoot = null;
440
+ this.noticeDegraded(root);
441
+ }
442
+ }
443
+ // ---- Fallback: single non-recursive watch on the LISTED directory.
444
+ if (!this.recursiveWatcher && this.watchPath === rel && this.fsWatcher)
445
+ return;
446
+ this.unwatchDir();
447
+ this.watchPath = rel;
448
+ try {
449
+ // persistent: false — the watcher must not keep the process alive.
450
+ this.fsWatcher = watch(absPath, { persistent: false }, () => {
451
+ // Burst events (npm install, git ops, editor save→rename) are
452
+ // debounced into a single refresh.
453
+ if (this.watchTimer)
454
+ return;
455
+ this.watchTimer = setTimeout(() => {
456
+ this.watchTimer = null;
457
+ this.host.emit({ type: "file_changed", path: this.watchPath ?? "" });
458
+ }, 400);
459
+ });
460
+ this.fsWatcher.on("error", () => {
461
+ // Directory deleted / unsupported fs — stop watching; the poll (or
462
+ // the next navigation) restores things.
463
+ this.unwatchDir();
464
+ });
465
+ }
466
+ catch {
467
+ // fs.watch unsupported (some network mounts, containers) — poll covers it.
468
+ this.fsWatcher = null;
469
+ this.watchPath = null;
470
+ this.noticeDegraded(absPath);
471
+ }
472
+ }
473
+ /** 实时监听不可用(网络盘/WSL/受限目录):一次性告知用户已回落 10s 轮询,
474
+ * 免得疑惑「面板为什么不实时」。每个工作区根只提示一次。 */
475
+ noticeDegraded(root) {
476
+ if (this.degradedNoticedFor === root)
477
+ return;
478
+ this.degradedNoticedFor = root;
479
+ this.host.emit({
480
+ type: "notice",
481
+ level: "info",
482
+ text: "此目录不支持实时文件监听(网络盘/受限目录),文件面板已改为每 10 秒自动刷新。",
483
+ });
484
+ }
485
+ unwatchDir() {
486
+ if (this.watchTimer) {
487
+ clearTimeout(this.watchTimer);
488
+ this.watchTimer = null;
489
+ }
490
+ if (this.fsWatcher) {
491
+ try {
492
+ this.fsWatcher.close();
493
+ }
494
+ catch {
495
+ // already closed
496
+ }
497
+ this.fsWatcher = null;
498
+ }
499
+ this.recursiveWatcher = false;
500
+ this.watchRoot = null;
501
+ this.watchPath = null;
502
+ }
503
+ /** Read a workspace file for the preview panel (size-capped, binary-safe). */
504
+ async readFile(relPath) {
505
+ try {
506
+ const fs = await import("node:fs/promises");
507
+ const root = this.host.getCwd();
508
+ const wp = workspacePath(resolve(root), relPath);
509
+ if (!wp) {
510
+ this.host.emit({
511
+ type: "notice",
512
+ level: "warning",
513
+ text: `路径超出工作区:${relPath}`,
514
+ });
515
+ return;
516
+ }
517
+ const { abs, rel } = wp;
518
+ const stat = await fs.stat(abs);
519
+ if (!stat.isFile()) {
520
+ this.host.emit({
521
+ type: "notice",
522
+ level: "warning",
523
+ text: `不是文件:${relPath}`,
524
+ });
525
+ return;
526
+ }
527
+ const name = relPath.split(/[\\/]/).pop() ?? relPath;
528
+ const kind = previewKind(name);
529
+ // Media previews stream over the /api/file HTTP endpoint, so only
530
+ // metadata is sent here — the raw bytes never touch the socket.
531
+ if (kind === "image" || kind === "video") {
532
+ this.host.emit({
533
+ type: "file_content",
534
+ path: rel,
535
+ name,
536
+ text: "",
537
+ truncated: false,
538
+ binary: true,
539
+ kind,
540
+ lines: 0,
541
+ size: stat.size,
542
+ });
543
+ return;
544
+ }
545
+ // Everything else: read a capped prefix and sniff the content.
546
+ // Anything that looks like text previews as text regardless of its
547
+ // extension (jsonl, .log.1, weird suffixes, …); binary content gets
548
+ // a hex dump of the first few KB instead of being refused.
549
+ const handle = await fs.open(abs, "r");
550
+ try {
551
+ const buf = Buffer.alloc(Math.min(stat.size, MAX_PREVIEW_BYTES));
552
+ const { bytesRead } = await handle.read(buf, 0, buf.length, 0);
553
+ const data = buf.subarray(0, bytesRead);
554
+ if (looksLikeText(data)) {
555
+ this.host.emit({
556
+ type: "file_content",
557
+ path: rel,
558
+ name,
559
+ text: decodeText(data),
560
+ truncated: bytesRead < stat.size,
561
+ binary: false,
562
+ kind: "text",
563
+ lines: countLines(data),
564
+ size: stat.size,
565
+ });
566
+ }
567
+ else {
568
+ this.host.emit({
569
+ type: "file_content",
570
+ path: rel,
571
+ name,
572
+ text: hexDump(data),
573
+ truncated: bytesRead < stat.size,
574
+ binary: true,
575
+ kind: kind === "text" ? "text" : "none",
576
+ lines: 0,
577
+ size: stat.size,
578
+ });
579
+ }
580
+ }
581
+ finally {
582
+ await handle.close();
583
+ }
584
+ }
585
+ catch (err) {
586
+ this.host.emit({
587
+ type: "notice",
588
+ level: "error",
589
+ text: `读取文件失败:${err.message}`,
590
+ });
591
+ }
592
+ }
593
+ /** Save text from the file preview panel within the active workspace. */
594
+ async writeFile(relPath, text) {
595
+ try {
596
+ const root = this.host.getCwd();
597
+ const wp = workspacePath(resolve(root), relPath);
598
+ if (!wp) {
599
+ this.host.emit({
600
+ type: "notice",
601
+ level: "warning",
602
+ text: `路径超出工作区:${relPath}`,
603
+ });
604
+ return;
605
+ }
606
+ if (Buffer.byteLength(text, "utf8") > 2 * 1024 * 1024) {
607
+ this.host.emit({
608
+ type: "notice",
609
+ level: "warning",
610
+ text: "文件内容过大,无法保存(上限 2MB)",
611
+ });
612
+ return;
613
+ }
614
+ const stat = statSync(wp.abs);
615
+ if (!stat.isFile()) {
616
+ this.host.emit({
617
+ type: "notice",
618
+ level: "warning",
619
+ text: `不是文件:${relPath}`,
620
+ });
621
+ return;
622
+ }
623
+ writeFileSync(wp.abs, text, "utf8");
624
+ this.host.emit({
625
+ type: "notice",
626
+ level: "info",
627
+ text: `已保存:${wp.rel}`,
628
+ });
629
+ // Re-read through the same path as the preview request so the client
630
+ // gets the canonical content, line count and file size after saving.
631
+ await this.readFile(wp.rel);
632
+ }
633
+ catch (err) {
634
+ this.host.emit({
635
+ type: "notice",
636
+ level: "error",
637
+ text: `保存文件失败:${err.message}`,
638
+ });
639
+ }
640
+ }
641
+ /**
642
+ * Path completion for the cwd input: expand ~/relative paths, list the parent
643
+ * directory, and return prefix matches (dirs first, capped).
644
+ */
645
+ async completePath(input) {
646
+ const empty = () => this.host.emit({ type: "path_completions", completions: [] });
647
+ try {
648
+ const fs = await import("node:fs/promises");
649
+ const { resolve, sep, isAbsolute } = await import("node:path");
650
+ const { homedir } = await import("node:os");
651
+ const home = homedir();
652
+ const cwd = this.host.getCwd();
653
+ // Expand ~ and relative inputs to an absolute path. Windows users type
654
+ // backslashes (P:\agent) and ~\ — handle both separator styles.
655
+ let expanded = input.trim();
656
+ if (expanded === "") {
657
+ empty();
658
+ return;
659
+ }
660
+ if (expanded === "~" || expanded === "~\\") {
661
+ expanded = home;
662
+ }
663
+ else if (expanded.startsWith("~/") || expanded.startsWith("~\\")) {
664
+ expanded = home + sep + expanded.slice(2);
665
+ }
666
+ else if (!isAbsolute(expanded)) {
667
+ expanded = resolve(cwd, expanded);
668
+ }
669
+ // Split into parent dir + prefix on the LAST separator of either style
670
+ // (Windows accepts both / and \, so P:\agent/de must work too).
671
+ const lastSlash = Math.max(expanded.lastIndexOf("/"), expanded.lastIndexOf("\\"));
672
+ const dirPart = lastSlash >= 0 ? expanded.slice(0, lastSlash + 1) : "";
673
+ const prefix = lastSlash >= 0 ? expanded.slice(lastSlash + 1) : expanded;
674
+ const dirents = await fs
675
+ .readdir(dirPart, { withFileTypes: true })
676
+ .catch(() => null);
677
+ if (!dirents) {
678
+ empty();
679
+ return;
680
+ }
681
+ const { join } = await import("node:path");
682
+ const completions = dirents
683
+ .filter((d) => d.name.startsWith(prefix) && !ignoredEntries().has(d.name))
684
+ .map((d) => ({
685
+ name: d.name,
686
+ // Windows users type backslashes — normalize the completion to the
687
+ // wire format ("/") so the picked path round-trips cleanly.
688
+ path: IS_WIN32
689
+ ? join(dirPart, d.name).split(sep).join("/")
690
+ : dirPart + d.name,
691
+ type: (d.isDirectory() ? "dir" : "file"),
692
+ }))
693
+ .sort((a, b) => {
694
+ const aHidden = a.name.startsWith(".");
695
+ const bHidden = b.name.startsWith(".");
696
+ if (aHidden !== bHidden)
697
+ return aHidden ? 1 : -1;
698
+ if (a.type !== b.type)
699
+ return a.type === "dir" ? -1 : 1;
700
+ return a.name.localeCompare(b.name);
701
+ })
702
+ .slice(0, 30);
703
+ this.host.emit({ type: "path_completions", completions });
704
+ }
705
+ catch {
706
+ empty();
707
+ }
708
+ }
709
+ }