@youweichen/pi-harness 1.0.0 → 1.2.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 (99) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +14 -0
  3. package/dist/server/agent-service.js +87 -7
  4. package/dist/server/control-socket.js +1 -0
  5. package/dist/server/document-conversion/extension.js +164 -0
  6. package/dist/server/document-conversion/python/bridge.py +424 -0
  7. package/dist/server/document-conversion/python/fixtures.py +201 -0
  8. package/dist/server/document-conversion/python/native_code.py +149 -0
  9. package/dist/server/document-conversion/runtime.js +321 -0
  10. package/dist/server/document-conversion/service.js +340 -0
  11. package/dist/server/document-conversion/settings.js +63 -0
  12. package/dist/server/extensions-worker.js +3 -2
  13. package/dist/server/files-service.js +8 -16
  14. package/dist/server/index.js +9 -3
  15. package/dist/server/native-tools.js +4 -0
  16. package/dist/server/node-agent.js +262 -0
  17. package/dist/server/node-workbench.js +62 -1
  18. package/dist/server/okf/extension.js +118 -0
  19. package/dist/server/okf/markdown-sources.js +58 -0
  20. package/dist/server/okf/service.js +713 -0
  21. package/dist/server/okf/storage.js +165 -0
  22. package/dist/server/okf/types.js +1 -0
  23. package/dist/server/plan/extension.js +4 -4
  24. package/dist/server/plan/state.js +11 -1
  25. package/dist/server/slash-commands.js +6 -0
  26. package/dist/server/wiki-links.js +24 -0
  27. package/dist/server/wiki-routes.js +8 -1
  28. package/dist/server/wiki-service.js +50 -5
  29. package/package.json +2 -2
  30. package/web/dist/assets/NodeWorkbench-DM6YTS5e.js +3 -0
  31. package/web/dist/assets/{TerminalPanel-CWh6vw_N.js → TerminalPanel-aFJylyxg.js} +1 -1
  32. package/web/dist/assets/{abnfDiagram-VCTEODGH-D5Ti0Kpa.js → abnfDiagram-VCTEODGH-CcM7f7oX.js} +1 -1
  33. package/web/dist/assets/{arc-DKJilLd6.js → arc-Bv7vS5l5.js} +1 -1
  34. package/web/dist/assets/{architectureDiagram-5GKGNRK7-Cf0w38g-.js → architectureDiagram-5GKGNRK7-Cl8QcB3w.js} +1 -1
  35. package/web/dist/assets/{blockDiagram-I7D4REHJ-BOTLWLIL.js → blockDiagram-I7D4REHJ-DarS2-4G.js} +1 -1
  36. package/web/dist/assets/{c4Diagram-7LVT6UL2-CLCCoJs3.js → c4Diagram-7LVT6UL2-BY0_ttBt.js} +1 -1
  37. package/web/dist/assets/channel-DJGMgVTh.js +1 -0
  38. package/web/dist/assets/{chunk-2Q5K7J3B-BHjga2Ge.js → chunk-2Q5K7J3B-Cz-zS7_X.js} +1 -1
  39. package/web/dist/assets/{chunk-5VM5RSS4-CQJ5VfDZ.js → chunk-5VM5RSS4-B7AyqkmI.js} +1 -1
  40. package/web/dist/assets/{chunk-F27PBJKO-C67qoPJH.js → chunk-F27PBJKO-B7hVduzL.js} +1 -1
  41. package/web/dist/assets/{chunk-IMKFNOWR-CgXXGwI8.js → chunk-IMKFNOWR-DNGJqYn5.js} +1 -1
  42. package/web/dist/assets/{chunk-JWPE2WC7-D0RPyByP.js → chunk-JWPE2WC7-B5iJgXvl.js} +1 -1
  43. package/web/dist/assets/{chunk-POPQ4Y6H-BeYC9s9x.js → chunk-POPQ4Y6H-BvWCkriR.js} +1 -1
  44. package/web/dist/assets/{chunk-SVP7TREG-eQPl4UBQ.js → chunk-SVP7TREG-BcdlBuOQ.js} +1 -1
  45. package/web/dist/assets/{chunk-TICWLB2K-DT73arPU.js → chunk-TICWLB2K-B1igqjQd.js} +1 -1
  46. package/web/dist/assets/{chunk-XXDRQBXY-B4FwQcP-.js → chunk-XXDRQBXY-DKGDquaw.js} +1 -1
  47. package/web/dist/assets/classDiagram-ZZMXUADV-D9nxgF0M.js +1 -0
  48. package/web/dist/assets/classDiagram-v2-VYDZK3BY-D9nxgF0M.js +1 -0
  49. package/web/dist/assets/{cose-bilkent-JH36ORCC-Bnn5nlQN.js → cose-bilkent-JH36ORCC-BU_9nu88.js} +1 -1
  50. package/web/dist/assets/{cynefin-OW5HDTMX-CStWX9kE.js → cynefin-OW5HDTMX-DY0BDLjc.js} +1 -1
  51. package/web/dist/assets/{cynefinDiagram-5FMLGOSQ-BMVHC03f.js → cynefinDiagram-5FMLGOSQ-B-zM25Fk.js} +1 -1
  52. package/web/dist/assets/{dagre-GXQ25YYZ-ClSaO4ml.js → dagre-GXQ25YYZ-BxooDyBc.js} +1 -1
  53. package/web/dist/assets/{diagram-S7CK7UJ4-C43LL08C.js → diagram-S7CK7UJ4-CCO-8de9.js} +1 -1
  54. package/web/dist/assets/{diagram-UQ7AKVKN-BPVn_ySW.js → diagram-UQ7AKVKN-CmGsH525.js} +1 -1
  55. package/web/dist/assets/{diagram-VSXAHHWV-C34FFhRi.js → diagram-VSXAHHWV-CSQJYo3m.js} +1 -1
  56. package/web/dist/assets/{diagram-VX7I27RA-BRk4bncf.js → diagram-VX7I27RA-CwuJsF66.js} +1 -1
  57. package/web/dist/assets/{diagram-Z3DM3KII-O5hYPLEd.js → diagram-Z3DM3KII-BF13T_lj.js} +1 -1
  58. package/web/dist/assets/{ebnfDiagram-PWID7BFC-DVGEBm99.js → ebnfDiagram-PWID7BFC-ZtoDLro6.js} +1 -1
  59. package/web/dist/assets/{erDiagram-RLTQ6QDP-M-BLpTEV.js → erDiagram-RLTQ6QDP-BBo-qmNH.js} +1 -1
  60. package/web/dist/assets/{flowDiagram-HODETNUW-BwdLopEx.js → flowDiagram-HODETNUW-DAq9qdP8.js} +1 -1
  61. package/web/dist/assets/{ganttDiagram-EL5Y4UJY-DYxx2ppA.js → ganttDiagram-EL5Y4UJY-C53eRlMu.js} +1 -1
  62. package/web/dist/assets/{gitGraphDiagram-WWUBYQGX-DUU0wMYB.js → gitGraphDiagram-WWUBYQGX-BviBMJxj.js} +1 -1
  63. package/web/dist/assets/index-Bab44wX8.js +276 -0
  64. package/web/dist/assets/index-C3vDFSEV.css +10 -0
  65. package/web/dist/assets/{infoDiagram-27XIBGKW-CFaW5C0C.js → infoDiagram-27XIBGKW-DI95SO3j.js} +1 -1
  66. package/web/dist/assets/{ishikawaDiagram-5VMMS53U--ENP1MTB.js → ishikawaDiagram-5VMMS53U-Cl8IDXwf.js} +1 -1
  67. package/web/dist/assets/{journeyDiagram-3NMN7TZE-DgnAzSTj.js → journeyDiagram-3NMN7TZE-Co6aJeTZ.js} +1 -1
  68. package/web/dist/assets/{kanban-definition-UXKFOSKX-_STZN1vU.js → kanban-definition-UXKFOSKX-CknQQpwW.js} +1 -1
  69. package/web/dist/assets/{layout-CkUClkXt.js → layout-CFtUmtTW.js} +1 -1
  70. package/web/dist/assets/{linear-sYHiUOxa.js → linear-mRlGUAKy.js} +1 -1
  71. package/web/dist/assets/{markdown-vNTCZJ6d.js → markdown-BhJ_LInD.js} +1 -1
  72. package/web/dist/assets/{mermaid.core-B8fKGWsc.js → mermaid.core-CvQEO3ue.js} +5 -5
  73. package/web/dist/assets/{mindmap-definition-YA3MSWOX-DP0BYOEx.js → mindmap-definition-YA3MSWOX-DpqhsxUl.js} +1 -1
  74. package/web/dist/assets/{pegDiagram-XKGWAZYB-DkWTmZre.js → pegDiagram-XKGWAZYB-C9eawB5u.js} +1 -1
  75. package/web/dist/assets/{pieDiagram-E7YTZNPT-DfZgMV4u.js → pieDiagram-E7YTZNPT-8vaqXVD6.js} +1 -1
  76. package/web/dist/assets/{quadrantDiagram-AXDQQJYC-Djl4TT7q.js → quadrantDiagram-AXDQQJYC-Diwr8ynm.js} +1 -1
  77. package/web/dist/assets/{railroadDiagram-O6MQD6OU-DbKF6zXn.js → railroadDiagram-O6MQD6OU-krW50Cgi.js} +1 -1
  78. package/web/dist/assets/{react-BNj3n7jX.js → react-BddUxV-9.js} +1 -1
  79. package/web/dist/assets/{requirementDiagram-BXWQKSXE-idola5qb.js → requirementDiagram-BXWQKSXE-BPLI6eMG.js} +1 -1
  80. package/web/dist/assets/{sankeyDiagram-P5KCCOFB-KA0hQOle.js → sankeyDiagram-P5KCCOFB-Bix_elmH.js} +1 -1
  81. package/web/dist/assets/{sequenceDiagram-WJ2MYXX4-BXSKvxzP.js → sequenceDiagram-WJ2MYXX4-0m0xaUKi.js} +1 -1
  82. package/web/dist/assets/{sizeCapture-INFHLROL-C_Ewv3S5.js → sizeCapture-INFHLROL-BtzJ_oOR.js} +1 -1
  83. package/web/dist/assets/{stateDiagram-D77RDMKH-DIf5X8oo.js → stateDiagram-D77RDMKH-Clq5bN0v.js} +1 -1
  84. package/web/dist/assets/stateDiagram-v2-MP3YSRHH-Bn1oW4gp.js +1 -0
  85. package/web/dist/assets/{swimlanes-42K2YHIH-Cx_Rc8M2.js → swimlanes-42K2YHIH-11MDGnYA.js} +1 -1
  86. package/web/dist/assets/swimlanesDiagram-VR7AAH4N-BW96Edgo.js +8 -0
  87. package/web/dist/assets/{timeline-definition-24CTP7MA-BRrDhWVz.js → timeline-definition-24CTP7MA-BeQfWP95.js} +1 -1
  88. package/web/dist/assets/{vennDiagram-4TSXK5OY-Ad6G0YwD.js → vennDiagram-4TSXK5OY-BMYkX0KL.js} +1 -1
  89. package/web/dist/assets/{wardleyDiagram-VM6X3IG4-X6-ENpSi.js → wardleyDiagram-VM6X3IG4-BV3fTUkU.js} +1 -1
  90. package/web/dist/assets/{xychartDiagram-S5SC5T6Z-DhegNS6d.js → xychartDiagram-S5SC5T6Z-Ct7ezLnc.js} +1 -1
  91. package/web/dist/index.html +4 -4
  92. package/web/dist/assets/NodeWorkbench-C4U1PldE.js +0 -3
  93. package/web/dist/assets/channel-D8Hd7T4P.js +0 -1
  94. package/web/dist/assets/classDiagram-ZZMXUADV-B_btgYRB.js +0 -1
  95. package/web/dist/assets/classDiagram-v2-VYDZK3BY-B_btgYRB.js +0 -1
  96. package/web/dist/assets/index-CI2KfVSr.css +0 -10
  97. package/web/dist/assets/index-DQcpggFU.js +0 -275
  98. package/web/dist/assets/stateDiagram-v2-MP3YSRHH-DYwe_2mB.js +0 -1
  99. package/web/dist/assets/swimlanesDiagram-VR7AAH4N-52Y8tgRV.js +0 -8
@@ -0,0 +1,340 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { createReadStream } from "node:fs";
3
+ import { readFile, writeFile, stat, mkdir, rm, rename, access, lstat, readdir, cp, copyFile } from "node:fs/promises";
4
+ import { basename, dirname, extname, join, resolve, isAbsolute } from "node:path";
5
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
6
+ import { unified } from "unified";
7
+ import remarkParse from "remark-parse";
8
+ import lockfile from "proper-lockfile";
9
+ import { documentDataDir } from "./settings.js";
10
+ import { decodeText } from "../text-sniff.js";
11
+ import { CONVERSION_PROFILE, ensureRuntimeReady, runConversionWorker, runtimeFingerprint, waitDocumentQueue } from "./runtime.js";
12
+ export const DOCUMENT_EXTENSIONS = new Set([".pdf", ".md", ".markdown", ".txt", ".docx", ".xlsx", ".pptx"]);
13
+ const locks = new Map();
14
+ async function hashFile(path, signal) {
15
+ const hash = createHash("sha256"), stream = createReadStream(path, { signal });
16
+ for await (const chunk of stream)
17
+ hash.update(chunk);
18
+ return hash.digest("hex");
19
+ }
20
+ function digest(value) { return createHash("sha256").update(value).digest("hex"); }
21
+ function markdownImageNodes(text) {
22
+ const tree = unified().use(remarkParse).parse(text);
23
+ const nodes = [], references = new Set();
24
+ const visit = (node) => { nodes.push(node); if (node.type === "imageReference" && node.identifier)
25
+ references.add(node.identifier); node.children?.forEach(visit); };
26
+ visit(tree);
27
+ return nodes.filter(node => node.type === "image" || (node.type === "definition" && node.identifier && references.has(node.identifier)));
28
+ }
29
+ function imageSource(url, source, assets) {
30
+ if (assets !== undefined) {
31
+ if (!Object.hasOwn(assets, url) || !assets[url])
32
+ throw new Error("Image is not present in the archived evidence snapshot");
33
+ return assets[url];
34
+ }
35
+ return resolve(dirname(source), decodeURIComponent(url.split("#")[0]));
36
+ }
37
+ async function markdownDependencyFingerprint(text, source, assets) {
38
+ const dependencies = [];
39
+ for (const node of markdownImageNodes(text)) {
40
+ const url = node.url ?? "";
41
+ if (!url || /^(?:[a-z]+:|#)/i.test(url) || isAbsolute(url))
42
+ continue;
43
+ try {
44
+ const path = imageSource(url, source, assets), info = await stat(path);
45
+ if (!info.isFile() || info.size > 20 * 1024 * 1024)
46
+ throw new Error("Unsupported asset");
47
+ dependencies.push(`${url}:${await hashFile(path)}`);
48
+ }
49
+ catch {
50
+ dependencies.push(`${url}:missing`);
51
+ }
52
+ }
53
+ return digest(JSON.stringify(dependencies));
54
+ }
55
+ async function preserveMarkdownImages(text, source, output, warnings, assets) {
56
+ const replacements = [];
57
+ for (const node of markdownImageNodes(text)) {
58
+ const url = node.url ?? "";
59
+ if (!url || /^(?:https?:|data:|#)/i.test(url))
60
+ continue;
61
+ try {
62
+ if (isAbsolute(url) || /^[a-z]+:/i.test(url))
63
+ throw new Error("absolute image paths are not portable");
64
+ const local = imageSource(url, source, assets);
65
+ if (![".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", ".bmp", ".tiff"].includes(extname(local).toLowerCase()))
66
+ throw new Error("unsupported image format");
67
+ const info = await stat(local);
68
+ if (!info.isFile() || info.size > 20 * 1024 * 1024)
69
+ throw new Error("image is not a regular file below 20 MiB");
70
+ const target = `assets/${(await hashFile(local)).slice(0, 24)}${extname(local).toLowerCase()}`;
71
+ await copyFile(local, join(output, target));
72
+ const start = node.position?.start.offset, end = node.position?.end.offset;
73
+ if (start !== undefined && end !== undefined)
74
+ replacements.push({ start, end, text: node.type === "definition" ? `[${node.identifier}]: ${target}${node.title ? ` ${JSON.stringify(node.title)}` : ""}` : `![${(node.alt ?? "").replace(/]/g, "\\]")}](${target}${node.title ? ` ${JSON.stringify(node.title)}` : ""})` });
75
+ }
76
+ catch (error) {
77
+ warnings.push(`Markdown image was not copied: ${url}: ${error instanceof Error ? error.message : error}`);
78
+ }
79
+ }
80
+ for (const replacement of replacements.sort((a, b) => b.start - a.start))
81
+ text = text.slice(0, replacement.start) + replacement.text + text.slice(replacement.end);
82
+ return text;
83
+ }
84
+ export function textBlocks(text) {
85
+ const lines = text.split("\n"), blocks = [];
86
+ let start = 0, heading, fence;
87
+ const emit = (end) => {
88
+ const value = lines.slice(start, end).join("\n");
89
+ if (value.trim())
90
+ blocks.push({ id: `block-${blocks.length + 1}`, text: value, locator: { lineStart: start + 1, lineEnd: end, ...(heading ? { heading } : {}) } });
91
+ start = end;
92
+ };
93
+ for (let i = 0; i < lines.length; i++) {
94
+ const marker = lines[i].match(/^\s*(`{3,}|~{3,})/);
95
+ if (marker) {
96
+ if (!fence)
97
+ fence = marker[1];
98
+ else if (marker[1][0] === fence[0] && marker[1].length >= fence.length)
99
+ fence = undefined;
100
+ }
101
+ if (!fence && /^#{1,6}\s+/.test(lines[i])) {
102
+ emit(i);
103
+ heading = lines[i].replace(/^#{1,6}\s+/, "");
104
+ }
105
+ if (!fence && !lines[i].trim()) {
106
+ emit(i);
107
+ start = i + 1;
108
+ }
109
+ }
110
+ emit(lines.length);
111
+ return blocks;
112
+ }
113
+ export async function convertDocument(options) {
114
+ const key = resolve(options.outputDir), prior = locks.get(key) ?? Promise.resolve();
115
+ let release;
116
+ const lock = new Promise(done => { release = done; });
117
+ const chain = prior.then(() => lock);
118
+ locks.set(key, chain);
119
+ try {
120
+ await waitDocumentQueue(prior, options.signal);
121
+ options.signal?.throwIfAborted();
122
+ return await convert({ ...options, outputDir: key });
123
+ }
124
+ finally {
125
+ release();
126
+ if (locks.get(key) === chain)
127
+ locks.delete(key);
128
+ }
129
+ }
130
+ async function artifactFiles(root, prefix = "") {
131
+ const paths = [];
132
+ for (const entry of await readdir(join(root, prefix), { withFileTypes: true })) {
133
+ const path = join(prefix, entry.name);
134
+ if (entry.isSymbolicLink())
135
+ throw new Error("Parser artifacts must not contain symlinks");
136
+ if (entry.isDirectory())
137
+ paths.push(...await artifactFiles(root, path));
138
+ else if (entry.isFile() && path !== "manifest.json")
139
+ paths.push(path);
140
+ }
141
+ return paths;
142
+ }
143
+ async function loadResult(root, manifest) {
144
+ const sourceMap = JSON.parse(await readFile(join(root, "source-map.json"), "utf8"));
145
+ return { markdownPath: join(root, "document.md"), structurePath: join(root, "structure.json"), sourceMapPath: join(root, "source-map.json"), assetsDir: join(root, "assets"), sourceHash: manifest.sourceHash, parserVersion: manifest.parserVersion, warnings: manifest.warnings, status: manifest.status, blocks: sourceMap.blocks };
146
+ }
147
+ async function existingManifest(root, signal) {
148
+ try {
149
+ await access(root);
150
+ }
151
+ catch (error) {
152
+ if (error.code === "ENOENT")
153
+ return undefined;
154
+ throw error;
155
+ }
156
+ if ((await lstat(root)).isSymbolicLink())
157
+ throw new Error("Output directory must not be a symlink");
158
+ if ((await readdir(root)).length === 0)
159
+ return undefined;
160
+ let manifest;
161
+ try {
162
+ manifest = JSON.parse(await readFile(join(root, "manifest.json"), "utf8"));
163
+ }
164
+ catch {
165
+ throw new Error("Output directory contains existing files without a conversion manifest; choose another directory");
166
+ }
167
+ if (manifest.schemaVersion !== 1 || !manifest.files || typeof manifest.files !== "object")
168
+ throw new Error("Invalid existing conversion manifest");
169
+ const actual = await artifactFiles(root);
170
+ if (actual.length !== Object.keys(manifest.files).length)
171
+ throw new Error("Conversion output has added or removed files; choose another directory to preserve changes");
172
+ for (const path of actual) {
173
+ if (!manifest.files[path] || await hashFile(join(root, path), signal) !== manifest.files[path])
174
+ throw new Error(`Conversion output was edited: ${path}. Choose another directory to preserve changes.`);
175
+ }
176
+ for (const required of ["document.md", "structure.json", "source-map.json"])
177
+ if (!manifest.files[required])
178
+ throw new Error("Incomplete conversion manifest");
179
+ return manifest;
180
+ }
181
+ async function destinationLock(root, signal) {
182
+ for (;;) {
183
+ signal?.throwIfAborted();
184
+ try {
185
+ return await lockfile.lock(root, { realpath: false, lockfilePath: `${root}.conversion-lock`, stale: 120_000, retries: 0 });
186
+ }
187
+ catch (error) {
188
+ if (error.code !== "ELOCKED")
189
+ throw error;
190
+ await new Promise(done => setTimeout(done, 100));
191
+ }
192
+ }
193
+ }
194
+ async function withArtifactMutationQueues(root, paths, operation) {
195
+ const ordered = [...new Set(paths)].sort();
196
+ const acquire = (index) => index === ordered.length ? operation() : withFileMutationQueue(join(root, ordered[index]), () => acquire(index + 1));
197
+ return acquire(0);
198
+ }
199
+ async function convert(options) {
200
+ const { outputDir, signal, onProgress } = options, inputPath = resolve(options.inputPath), suffix = extname(inputPath).toLowerCase();
201
+ if (!DOCUMENT_EXTENSIONS.has(suffix))
202
+ throw new Error(`Unsupported document format: ${suffix}`);
203
+ if (inputPath === outputDir || inputPath.startsWith(outputDir + "/") || inputPath.startsWith(outputDir + "\\"))
204
+ throw new Error("Output directory must not contain the source document");
205
+ const info = await stat(inputPath);
206
+ if (!info.isFile() || info.size > 100 * 1024 * 1024)
207
+ throw new Error("Document must be a file no larger than 100 MiB");
208
+ const sourceHash = await hashFile(inputPath, signal), plain = [".md", ".markdown", ".txt"].includes(suffix);
209
+ let parserVersion = "text-v1", fingerprint = "text-v1", markdownDependencies;
210
+ if (suffix === ".md" || suffix === ".markdown") {
211
+ if (info.size > 16 * 1024 * 1024)
212
+ throw new Error("Text documents are limited to 16 MiB");
213
+ markdownDependencies = await markdownDependencyFingerprint(decodeText(await readFile(inputPath)), inputPath, options.markdownAssets);
214
+ fingerprint += markdownDependencies;
215
+ }
216
+ if (!plain) {
217
+ const doctor = await ensureRuntimeReady({ signal, onProgress });
218
+ if (!doctor.ready)
219
+ throw new Error(`Local document runtime is not ready. Run /pdf-md setup. ${doctor.missing.join("; ")}`);
220
+ parserVersion = `docling-${doctor.parserVersion}`;
221
+ fingerprint = await runtimeFingerprint();
222
+ }
223
+ const cacheKey = digest(JSON.stringify({ sourceHash, suffix, profile: CONVERSION_PROFILE, fingerprint }));
224
+ await mkdir(dirname(outputDir), { recursive: true });
225
+ const unlock = await destinationLock(outputDir, signal);
226
+ try {
227
+ const existing = await existingManifest(outputDir, signal);
228
+ if (existing?.cacheKey === cacheKey && existing.status === "complete") {
229
+ const priorSource = JSON.parse(await readFile(join(outputDir, "source-map.json"), "utf8")).source;
230
+ if (priorSource === inputPath) {
231
+ onProgress?.("Using unchanged document conversion");
232
+ return await loadResult(outputDir, existing);
233
+ }
234
+ }
235
+ const cacheDir = join(documentDataDir(), "document-cache", cacheKey);
236
+ const staging = join(dirname(outputDir), `.${basename(outputDir)}-${randomUUID()}.partial`), backup = `${staging}.previous`;
237
+ await mkdir(join(staging, "assets"), { recursive: true });
238
+ let installed = false, backedUp = false;
239
+ try {
240
+ let warnings = [], status = "complete";
241
+ let reused;
242
+ try {
243
+ reused = await existingManifest(cacheDir, signal);
244
+ }
245
+ catch {
246
+ signal?.throwIfAborted();
247
+ }
248
+ if (reused?.cacheKey === cacheKey && reused.status === "complete") {
249
+ await cp(cacheDir, staging, { recursive: true });
250
+ warnings = reused.warnings;
251
+ status = reused.status;
252
+ const sourceMap = JSON.parse(await readFile(join(staging, "source-map.json"), "utf8"));
253
+ sourceMap.source = inputPath;
254
+ await writeFile(join(staging, "source-map.json"), JSON.stringify(sourceMap, null, 2));
255
+ const structure = JSON.parse(await readFile(join(staging, "structure.json"), "utf8"));
256
+ if (structure.origin && typeof structure.origin === "object")
257
+ structure.origin.filename = basename(inputPath);
258
+ await writeFile(join(staging, "structure.json"), JSON.stringify(structure, null, 2));
259
+ onProgress?.("Reusing the local document content cache");
260
+ }
261
+ else if (plain) {
262
+ if (info.size > 16 * 1024 * 1024)
263
+ throw new Error("Text documents are limited to 16 MiB");
264
+ const data = await readFile(inputPath);
265
+ try {
266
+ new TextDecoder("utf-8", { fatal: true }).decode(data);
267
+ }
268
+ catch {
269
+ warnings.push("Source is not UTF-8; decoded using the legacy text encoding fallback. Verify non-ASCII text.");
270
+ }
271
+ const text = decodeText(data).replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n"), blocks = textBlocks(text);
272
+ const markdown = suffix === ".txt" ? text : await preserveMarkdownImages(text, inputPath, staging, warnings, options.markdownAssets);
273
+ if (warnings.some(warning => warning.startsWith("Markdown image was not copied")))
274
+ status = "partial";
275
+ await writeFile(join(staging, "document.md"), markdown.endsWith("\n") ? markdown : markdown + "\n");
276
+ await writeFile(join(staging, "structure.json"), JSON.stringify({ schemaVersion: 1, kind: "text", blocks }, null, 2));
277
+ await writeFile(join(staging, "source-map.json"), JSON.stringify({ schemaVersion: 1, source: inputPath, sourceHash, blocks }, null, 2));
278
+ }
279
+ else {
280
+ onProgress?.(`Converting ${basename(inputPath)} locally`);
281
+ const response = await runConversionWorker({ inputPath, outputDir: staging, sourceHash }, { signal, onProgress });
282
+ if (response.status !== "complete" && response.status !== "partial")
283
+ throw new Error("Invalid parser completion status");
284
+ status = response.status;
285
+ warnings = response.warnings ?? [];
286
+ }
287
+ if (await hashFile(inputPath, signal) !== sourceHash)
288
+ throw new Error("Source changed during conversion; retry with the current document");
289
+ if (markdownDependencies !== undefined && await markdownDependencyFingerprint(decodeText(await readFile(inputPath)), inputPath, options.markdownAssets) !== markdownDependencies)
290
+ throw new Error("Markdown images changed during conversion; retry with the current document");
291
+ for (const path of ["document.md", "structure.json", "source-map.json"])
292
+ await access(join(staging, path));
293
+ const files = {};
294
+ for (const path of await artifactFiles(staging))
295
+ files[path] = await hashFile(join(staging, path), signal);
296
+ const manifest = { schemaVersion: 1, cacheKey, sourceHash, parserVersion, status, warnings, files };
297
+ await writeFile(join(staging, "manifest.json"), JSON.stringify(manifest, null, 2));
298
+ signal?.throwIfAborted();
299
+ onProgress?.("Committing verified document artifacts");
300
+ await withArtifactMutationQueues(outputDir, ["manifest.json", ...Object.keys(existing?.files ?? {}), ...Object.keys(files)], async () => {
301
+ // Revalidate after the slow parser run, catching edits made during OCR.
302
+ await existingManifest(outputDir, signal);
303
+ try {
304
+ await access(outputDir);
305
+ await rename(outputDir, backup);
306
+ backedUp = true;
307
+ }
308
+ catch (error) {
309
+ if (error.code !== "ENOENT")
310
+ throw error;
311
+ }
312
+ await rename(staging, outputDir);
313
+ installed = true;
314
+ });
315
+ if (status === "complete") {
316
+ await mkdir(dirname(cacheDir), { recursive: true });
317
+ const temporaryCache = `${cacheDir}.${randomUUID()}.partial`;
318
+ try {
319
+ await cp(outputDir, temporaryCache, { recursive: true });
320
+ await rename(temporaryCache, cacheDir);
321
+ }
322
+ catch { /* Cache races or cache I/O failures never invalidate the output. */ }
323
+ finally {
324
+ await rm(temporaryCache, { recursive: true, force: true });
325
+ }
326
+ }
327
+ return await loadResult(outputDir, manifest);
328
+ }
329
+ finally {
330
+ if (!installed && backedUp)
331
+ await rename(backup, outputDir);
332
+ await rm(staging, { recursive: true, force: true });
333
+ if (installed)
334
+ await rm(backup, { recursive: true, force: true });
335
+ }
336
+ }
337
+ finally {
338
+ await unlock();
339
+ }
340
+ }
@@ -0,0 +1,63 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { isAbsolute, join, resolve } from "node:path";
5
+ export function documentDataDir() {
6
+ return resolve(process.env.PI_WEB_DATA_DIR ?? join(homedir(), ".pi-web"));
7
+ }
8
+ function readSettings() {
9
+ try {
10
+ const value = JSON.parse(readFileSync(join(documentDataDir(), "document-extensions.json"), "utf8"));
11
+ if (!value || typeof value !== "object" || Array.isArray(value))
12
+ throw new Error("Expected an object");
13
+ return value;
14
+ }
15
+ catch (error) {
16
+ if (error.code === "ENOENT")
17
+ return {};
18
+ throw new Error(`Cannot read document-extensions.json: ${error instanceof Error ? error.message : String(error)}`);
19
+ }
20
+ }
21
+ function validate(value) {
22
+ const result = { pdfEnabled: true, okfEnabled: true };
23
+ for (const key of ["pdfEnabled", "okfEnabled"]) {
24
+ if (value[key] !== undefined) {
25
+ if (typeof value[key] !== "boolean")
26
+ throw new Error(`${key} must be a boolean`);
27
+ result[key] = value[key];
28
+ }
29
+ }
30
+ for (const key of ["pythonPath", "runtimePath"]) {
31
+ const path = value[key];
32
+ if (path === undefined || path === "")
33
+ continue;
34
+ if (typeof path !== "string" || !isAbsolute(path) || /[\0\r\n]/.test(path))
35
+ throw new Error(`${key} must be an absolute filesystem path`);
36
+ result[key] = resolve(path);
37
+ }
38
+ return result;
39
+ }
40
+ /** Read on every invocation so already loaded sessions honor a global disable. */
41
+ export function getDocumentSettings() {
42
+ return validate(readSettings());
43
+ }
44
+ export function updateDocumentSettings(patch) {
45
+ const saved = readSettings();
46
+ const next = validate({ ...saved, ...patch });
47
+ const output = { ...saved, ...next };
48
+ for (const key of ["pythonPath", "runtimePath"])
49
+ if (!next[key])
50
+ delete output[key];
51
+ const folder = documentDataDir();
52
+ mkdirSync(folder, { recursive: true });
53
+ const target = join(folder, "document-extensions.json");
54
+ const temporary = `${target}.${randomUUID()}.tmp`;
55
+ try {
56
+ writeFileSync(temporary, JSON.stringify(output, null, 2) + "\n", { mode: 0o600, flag: "wx" });
57
+ renameSync(temporary, target);
58
+ }
59
+ finally {
60
+ rmSync(temporary, { force: true });
61
+ }
62
+ return next;
63
+ }
@@ -1,8 +1,9 @@
1
1
  import { DefaultPackageManager, SettingsManager, ProjectTrustStore } from "@earendil-works/pi-coding-agent";
2
2
  import { writeFileSync, mkdirSync, renameSync } from "node:fs";
3
- import { basename, join, relative } from "node:path";
3
+ import { join, relative } from "node:path";
4
4
  import { createHash } from "node:crypto";
5
5
  import { readJson, textField, resourceTypes, sourceOf, sourceInfo, packageDisabled, settingsVersion } from "./extensions-model.js";
6
+ import { extensionDisplay } from "./extension-display.js";
6
7
  import { gitOutput, latestNpmVersion } from "./component-updates.js";
7
8
  const blankResources = () => ({ extensions: [], skills: [], prompts: [], themes: [] });
8
9
  export async function extensionWork(input, progress = () => { }) {
@@ -55,7 +56,7 @@ export async function extensionWork(input, progress = () => { }) {
55
56
  for (const resource of resources.extensions) {
56
57
  if (resource.metadata.origin !== "top-level" || resource.path.startsWith("builtin:") || resource.metadata.scope === "temporary")
57
58
  continue;
58
- result.push({ id: `file:${resource.metadata.scope}:${resource.path}`, source: resource.path, name: basename(resource.path), scope: resource.metadata.scope, kind: "file", path: resource.path, enabled: resource.enabled, pinned: false, trusted: resource.metadata.scope === "user" || trusted, resources: blankResources() });
59
+ result.push({ id: `file:${resource.metadata.scope}:${resource.path}`, source: resource.path, ...extensionDisplay(resource.path), scope: resource.metadata.scope, kind: "file", path: resource.path, enabled: resource.enabled, pinned: false, trusted: resource.metadata.scope === "user" || trusted, resources: blankResources() });
59
60
  }
60
61
  return { packages: result, version: settingsVersion(cwd, agentDir), trusted };
61
62
  }
@@ -117,7 +117,7 @@ export function isRealPathInWorkspace(root, abs) {
117
117
  * win32: stability and completeness first, preview second. ACL-protected
118
118
  * system dirs (C:\$Recycle.Bin, Program Files internals, OneDrive placeholders)
119
119
  * throw EPERM/EACCES on open — that must not kill the panel, so it degrades
120
- * to an empty listing plus a warning. Directory symlinks/junctions are
120
+ * to an empty listing with a node-local error. Directory symlinks/junctions are
121
121
  * followed so mklink /D folders stay navigable; broken links still show as
122
122
  * files instead of vanishing. The cap is 4x posix and truncation is reported
123
123
  * via `truncated` instead of happening silently.
@@ -132,11 +132,11 @@ async function readDirForUI(abs, rel) {
132
132
  dirents = await fs.readdir(abs, { withFileTypes: true });
133
133
  }
134
134
  catch (err) {
135
- if (!IS_WIN32)
136
- throw err;
137
- // Windows ACL-protected/system dirs throw EPERM/EACCES on open —
138
- // degrade to an empty listing; listFiles turns this into a warning.
139
- return { entries: [], truncated: false, error: err.message };
135
+ const code = err.code;
136
+ return { entries: [], truncated: false, error: {
137
+ code: code === "ENOENT" ? "missing" : code === "ENOTDIR" ? "not_directory" : code === "EACCES" || code === "EPERM" ? "denied" : "unavailable",
138
+ message: err.message,
139
+ } };
140
140
  }
141
141
  const out = [];
142
142
  for (const d of dirents) {
@@ -225,15 +225,6 @@ export class FilesService {
225
225
  // always use "/", but relative() returns "\\" on Windows.
226
226
  const rel = rawRel.split(sep).join("/");
227
227
  const { entries, truncated, error } = await readDirForUI(target, rel);
228
- if (error) {
229
- // Windows-only: unreadable system dirs degrade to an empty list
230
- // with a warning instead of a hard error — the panel stays usable.
231
- this.host.emit({
232
- type: "notice",
233
- level: "warning",
234
- text: `目录不可读:${error}`,
235
- });
236
- }
237
228
  this.host.emit({
238
229
  type: "files",
239
230
  cwd,
@@ -245,6 +236,7 @@ export class FilesService {
245
236
  : "",
246
237
  entries,
247
238
  truncated,
239
+ ...(error ? { error } : {}),
248
240
  });
249
241
  // Watch the listed directory — deferred until AFTER the response is on
250
242
  // the wire. On win32, fs.watch(root, {recursive:true}) registering over
@@ -253,7 +245,7 @@ export class FilesService {
253
245
  // macOS's FSEvents); doing it inline before the emit made every
254
246
  // project switch wait on that registration instead of just seeing the
255
247
  // file list immediately and having the watcher arm a beat later.
256
- if (cwd === this.host.getCwd())
248
+ if (!error && cwd === this.host.getCwd())
257
249
  this.watchDir(target, rel);
258
250
  }
259
251
  /**
@@ -29,6 +29,7 @@ import { homedir } from "node:os";
29
29
  import { fileURLToPath } from "node:url";
30
30
  import { randomUUID } from "node:crypto";
31
31
  import express from "express";
32
+ import { shutdownDocumentRuntime } from "./document-conversion/runtime.js";
32
33
  import compression from "compression";
33
34
  import { WebSocket, WebSocketServer } from "ws";
34
35
  import { VERSION, getAgentDir } from "@earendil-works/pi-coding-agent";
@@ -615,7 +616,7 @@ wss.on("connection", (ws) => {
615
616
  pending.push(msg);
616
617
  return;
617
618
  }
618
- if (cs.switchingWorkspace && msg.type !== "set_cwd" && msg.type !== "get_state") {
619
+ if (cs.switchingWorkspace && msg.type !== "set_cwd" && msg.type !== "get_state" && msg.type !== "node_request") {
619
620
  if (msg.type === "prompt" && msg.requestId)
620
621
  send({ type: "prompt_result", requestId: msg.requestId, ok: false });
621
622
  if (msg.type === "read_file" || msg.type === "write_file")
@@ -628,6 +629,10 @@ wss.on("connection", (ws) => {
628
629
  }
629
630
  switch (msg.type) {
630
631
  case "node_request":
632
+ if (service.quiesceInfo().quiesced && ["agent_start", "agent_prompt", "agent_new", "agent_model"].includes(msg.action)) {
633
+ send({ type: "node_event", event: "failure", requestId: msg.requestId, nodeId: msg.nodeId, conversationId: msg.conversationId, data: { action: msg.action, message: "服务暂停接收新操作。" } });
634
+ break;
635
+ }
631
636
  void nodeWorkbench.handle(clientId, msg);
632
637
  break;
633
638
  case "prompt":
@@ -754,7 +759,7 @@ wss.on("connection", (ws) => {
754
759
  void cs.checkComponentUpdates(msg.requestId);
755
760
  break;
756
761
  case "update_component":
757
- if (service.quiesceInfo().quiesced || service.activeConversations() || service.pendingMessages()) {
762
+ if (service.quiesceInfo().quiesced || service.activeConversations() || nodeWorkbench.activeAgents() || service.pendingMessages()) {
758
763
  send({ type: "component_updates", requestId: msg.requestId, cwd: cs.cwd, phase: "error", items: [], error: "请等待所有任务结束后再更新扩展" });
759
764
  }
760
765
  else {
@@ -1009,7 +1014,7 @@ httpServer.listen(PORT, HOST, () => {
1009
1014
  scheduleUploadCleanup();
1010
1015
  // Local control socket (status / quiesce / unquiesce) — same data dir the
1011
1016
  // CLI uses, so `pi-harness server status|quiesce|unquiesce` just works.
1012
- const stopControl = startControlServer({ service, dataDir: DATA_DIR, port: PORT });
1017
+ const stopControl = startControlServer({ service, dataDir: DATA_DIR, port: PORT, additionalActiveConversations: () => nodeWorkbench.activeAgents() });
1013
1018
  let shuttingDown = false;
1014
1019
  async function shutdown() {
1015
1020
  if (shuttingDown)
@@ -1021,6 +1026,7 @@ async function shutdown() {
1021
1026
  nodeWorkbench.dispose();
1022
1027
  pluginMgr.dispose();
1023
1028
  await service.disposeAll();
1029
+ await shutdownDocumentRuntime();
1024
1030
  wss.close();
1025
1031
  httpServer.close();
1026
1032
  process.exit(0);
@@ -1,4 +1,6 @@
1
1
  import { createPlanExtension } from "./plan/extension.js";
2
+ import { createPdfMarkdownExtension } from "./document-conversion/extension.js";
3
+ import { createOkfExtension } from "./okf/extension.js";
2
4
  import { SettingsManager, createCodemodeExtension, createMcpExtension, createToolSearchExtension } from "@earendil-works/pi-coding-agent";
3
5
  const runOverrides = new WeakMap();
4
6
  export function setConversationRunSettings(settings, values) {
@@ -21,6 +23,8 @@ export function conversationSettings(cwd, agentDir) {
21
23
  export function nativeToolExtensions() {
22
24
  return [
23
25
  { name: "pi-harness-plan", replaceable: true, factory: createPlanExtension() },
26
+ { name: "pi-harness-pdf-markdown", replaceable: true, factory: createPdfMarkdownExtension() },
27
+ { name: "pi-harness-okf", replaceable: true, factory: createOkfExtension() },
24
28
  { name: "codemode", builtin: true, replaceable: true, factory: createCodemodeExtension() },
25
29
  { name: "tool-search", builtin: true, replaceable: true, factory: createToolSearchExtension() },
26
30
  { name: "mcp", builtin: true, replaceable: true, factory: createMcpExtension() },