@zseven-w/dsh-openpencil 0.1.0-rc.1

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 (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -0
  3. package/THIRD_PARTY_NOTICES.md +69 -0
  4. package/cordis.patch.yml +6 -0
  5. package/docs/images/dsh-openpencil-overview.png +0 -0
  6. package/lib/client/details-compat.d.ts +28 -0
  7. package/lib/client/details-compat.js +10 -0
  8. package/lib/client/editor-bridge.d.ts +84 -0
  9. package/lib/client/editor-bridge.js +127 -0
  10. package/lib/client/editor-dock-layout.d.ts +15 -0
  11. package/lib/client/editor-dock-layout.js +43 -0
  12. package/lib/client/editor-recovery.d.ts +27 -0
  13. package/lib/client/editor-recovery.js +83 -0
  14. package/lib/client/editor-successor.d.ts +28 -0
  15. package/lib/client/editor-successor.js +109 -0
  16. package/lib/client/presentation-hydration.d.ts +36 -0
  17. package/lib/client/presentation-hydration.js +168 -0
  18. package/lib/client/selection-polling.d.ts +24 -0
  19. package/lib/client/selection-polling.js +79 -0
  20. package/lib/client/selection-store.d.ts +26 -0
  21. package/lib/client/selection-store.js +85 -0
  22. package/lib/client.js +4033 -0
  23. package/lib/design-tools.d.ts +21 -0
  24. package/lib/design-tools.js +214 -0
  25. package/lib/editor-host.d.ts +62 -0
  26. package/lib/editor-host.js +1147 -0
  27. package/lib/editor-recovery.d.ts +38 -0
  28. package/lib/editor-recovery.js +319 -0
  29. package/lib/index.d.ts +24 -0
  30. package/lib/index.js +132 -0
  31. package/lib/mcp-client.d.ts +47 -0
  32. package/lib/mcp-client.js +148 -0
  33. package/lib/new-tool.d.ts +23 -0
  34. package/lib/new-tool.js +136 -0
  35. package/lib/presentation-hydration.d.ts +55 -0
  36. package/lib/presentation-hydration.js +561 -0
  37. package/lib/renderer.d.ts +203 -0
  38. package/lib/renderer.js +772 -0
  39. package/lib/tool-names.d.ts +13 -0
  40. package/lib/tool-names.js +19 -0
  41. package/lib/tool.d.ts +27 -0
  42. package/lib/tool.js +221 -0
  43. package/lib/types/client/details-compat.d.ts +28 -0
  44. package/lib/types/client/editor-bridge.d.ts +84 -0
  45. package/lib/types/client/editor-dock-layout.d.ts +15 -0
  46. package/lib/types/client/editor-modal.d.ts +54 -0
  47. package/lib/types/client/editor-panel.d.ts +127 -0
  48. package/lib/types/client/editor-recovery.d.ts +27 -0
  49. package/lib/types/client/editor-successor.d.ts +28 -0
  50. package/lib/types/client/editor-workbench-host.d.ts +43 -0
  51. package/lib/types/client/frame-gallery.d.ts +98 -0
  52. package/lib/types/client/index.d.ts +176 -0
  53. package/lib/types/client/presentation-hydration.d.ts +36 -0
  54. package/lib/types/client/selection-dock.d.ts +18 -0
  55. package/lib/types/client/selection-polling.d.ts +24 -0
  56. package/lib/types/client/selection-store.d.ts +26 -0
  57. package/lib/types/tool-names.d.ts +13 -0
  58. package/lib/viewer-assets/canvaskit/canvaskit.js +288 -0
  59. package/lib/viewer-assets/canvaskit/canvaskit.wasm +0 -0
  60. package/lib/viewer-assets/manifest.json +22 -0
  61. package/lib/viewer-assets/op_web_sdk_bg.wasm +0 -0
  62. package/lib/viewer-assets/sdk.js +225 -0
  63. package/lib/viewer-assets.d.ts +74 -0
  64. package/lib/viewer-assets.js +303 -0
  65. package/package.json +95 -0
  66. package/scripts/build-client.mjs +27 -0
  67. package/scripts/run-tool.mjs +63 -0
  68. package/scripts/sync-viewer-assets.mjs +304 -0
  69. package/scripts/test-host.mjs +311 -0
  70. package/scripts/test-viewer-assets.mjs +54 -0
  71. package/src/client/details-compat.ts +44 -0
  72. package/src/client/editor-bridge.ts +170 -0
  73. package/src/client/editor-dock-layout.ts +56 -0
  74. package/src/client/editor-modal.tsx +607 -0
  75. package/src/client/editor-panel.tsx +823 -0
  76. package/src/client/editor-recovery.ts +116 -0
  77. package/src/client/editor-successor.ts +136 -0
  78. package/src/client/editor-workbench-host.tsx +280 -0
  79. package/src/client/frame-gallery.tsx +546 -0
  80. package/src/client/index.tsx +987 -0
  81. package/src/client/presentation-hydration.ts +210 -0
  82. package/src/client/react-dom-compat.d.ts +22 -0
  83. package/src/client/selection-dock.tsx +89 -0
  84. package/src/client/selection-polling.ts +93 -0
  85. package/src/client/selection-store.ts +108 -0
  86. package/src/design-tools.ts +242 -0
  87. package/src/editor-host.ts +1238 -0
  88. package/src/editor-recovery.ts +374 -0
  89. package/src/index.ts +219 -0
  90. package/src/mcp-client.ts +193 -0
  91. package/src/new-tool.ts +160 -0
  92. package/src/presentation-hydration.ts +634 -0
  93. package/src/renderer.ts +884 -0
  94. package/src/tool-names.ts +21 -0
  95. package/src/tool.ts +258 -0
  96. package/src/viewer-assets.ts +353 -0
@@ -0,0 +1,1147 @@
1
+ /** Lazy managed OpenPencil editor sessions for the DSH details panel. */
2
+ import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes, randomUUID, } from 'node:crypto';
3
+ import { constants as fsConstants, statSync } from 'node:fs';
4
+ import { lstat, mkdtemp, open, rename, rm, writeFile } from 'node:fs/promises';
5
+ import { spawn } from 'node:child_process';
6
+ import { homedir, tmpdir } from 'node:os';
7
+ import { basename, delimiter, dirname, isAbsolute, join, resolve } from 'node:path';
8
+ import { callOpenPencilMcp, getOpenPencilMcpVersion, selectionSnapshotFromMcp, } from './mcp-client.js';
9
+ import { EditorRecoveryStore, readManagedDaemonDocument, restoreManagedDaemonDocument, } from './editor-recovery.js';
10
+ import { OPENPENCIL_RENDER_TOOL_NAME } from './tool-names.js';
11
+ export const EDITOR_ROUTE_PREFIX = '/_dsh/dsh-openpencil/editor';
12
+ const CAPABILITY_TTL_MS = 2 * 60 * 60 * 1000;
13
+ const CAPABILITY_REFRESH_TTL_MS = 24 * 60 * 60 * 1000;
14
+ const SESSION_IDLE_MS = 4 * 60 * 60 * 1000;
15
+ const START_TIMEOUT_MS = 20_000;
16
+ const READY_TIMEOUT_MS = 15_000;
17
+ const STOP_TIMEOUT_MS = 3_000;
18
+ const OPERATION_RETIRE_TIMEOUT_MS = 25_000;
19
+ const MAX_DOCUMENT_BYTES = 64 * 1024 * 1024;
20
+ const MAX_HANDSHAKE_BYTES = 16 * 1024;
21
+ const MAX_DIAGNOSTIC_BYTES = 64 * 1024;
22
+ const TOKEN_PATTERN = /^[A-Za-z0-9_-]{24,128}$/;
23
+ const EDITOR_CAPABILITY_AAD = Buffer.from('dsh-openpencil/editor-capability/v1');
24
+ const EDITOR_CAPABILITY_PREFIX = 'v1.';
25
+ const EDITOR_CAPABILITY_MAX_LENGTH = 16 * 1024;
26
+ const EMPTY_DOCUMENT_JSON = '{\n "version": "1.0.0",\n "children": []\n}\n';
27
+ function isSha256(value) {
28
+ return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
29
+ }
30
+ function deriveEditorKey(masterKey) {
31
+ if (masterKey.length !== 32)
32
+ throw new TypeError('editor master key must be 32 bytes');
33
+ return Buffer.from(hkdfSync('sha256', masterKey, Buffer.from('dsh-openpencil/editor-key/v1'), Buffer.from('aes-256-gcm'), 32));
34
+ }
35
+ function capabilityFrom(value) {
36
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
37
+ return undefined;
38
+ const payload = value;
39
+ if (payload.v !== 1
40
+ || payload.scope !== 'edit-source'
41
+ || typeof payload.sourcePath !== 'string'
42
+ || !isAbsolute(payload.sourcePath)
43
+ || !payload.sourcePath.toLowerCase().endsWith('.op')
44
+ || !isSha256(payload.sourceSha256)
45
+ || typeof payload.issuedAt !== 'number'
46
+ || !Number.isSafeInteger(payload.issuedAt)
47
+ || typeof payload.launchExpiresAt !== 'number'
48
+ || !Number.isSafeInteger(payload.launchExpiresAt)
49
+ || typeof payload.refreshExpiresAt !== 'number'
50
+ || !Number.isSafeInteger(payload.refreshExpiresAt)
51
+ || payload.launchExpiresAt <= payload.issuedAt
52
+ || payload.launchExpiresAt - payload.issuedAt > CAPABILITY_TTL_MS
53
+ || payload.refreshExpiresAt < payload.launchExpiresAt
54
+ || payload.refreshExpiresAt - payload.issuedAt > CAPABILITY_REFRESH_TTL_MS)
55
+ return undefined;
56
+ return payload;
57
+ }
58
+ function json(res, status, value) {
59
+ const body = JSON.stringify(value);
60
+ res.statusCode = status;
61
+ res.setHeader('content-type', 'application/json; charset=utf-8');
62
+ res.setHeader('cache-control', 'no-store');
63
+ res.setHeader('x-content-type-options', 'nosniff');
64
+ res.end(body);
65
+ }
66
+ async function waitForResponseFinish(res) {
67
+ if (res.writableFinished)
68
+ return true;
69
+ await new Promise(resolveFinished => {
70
+ const finish = () => {
71
+ res.off('finish', finish);
72
+ res.off('close', finish);
73
+ resolveFinished();
74
+ };
75
+ res.once('finish', finish);
76
+ res.once('close', finish);
77
+ });
78
+ return res.writableFinished;
79
+ }
80
+ function errorMessage(error) {
81
+ return error instanceof Error ? error.message : String(error);
82
+ }
83
+ function sha256(bytes) {
84
+ return createHash('sha256').update(bytes).digest('hex');
85
+ }
86
+ async function readSourceDocument(path) {
87
+ const before = await lstat(path);
88
+ if (before.isSymbolicLink() || !before.isFile())
89
+ throw new HttpError(409, 'OpenPencil source is no longer a regular file');
90
+ if (before.size <= 0 || before.size > MAX_DOCUMENT_BYTES)
91
+ throw new HttpError(413, 'OpenPencil source size is invalid');
92
+ const noFollow = typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0;
93
+ const handle = await open(path, fsConstants.O_RDONLY | noFollow);
94
+ try {
95
+ const opened = await handle.stat();
96
+ if (!opened.isFile() || opened.size <= 0 || opened.size > MAX_DOCUMENT_BYTES) {
97
+ throw new HttpError(413, 'OpenPencil source size is invalid');
98
+ }
99
+ const bytes = await handle.readFile();
100
+ const after = await lstat(path);
101
+ if (after.isSymbolicLink()
102
+ || !after.isFile()
103
+ || (opened.dev !== 0 && opened.ino !== 0 && (opened.dev !== after.dev || opened.ino !== after.ino)))
104
+ throw new HttpError(409, 'OpenPencil source changed while it was opened');
105
+ return bytes;
106
+ }
107
+ finally {
108
+ await handle.close().catch(() => { });
109
+ }
110
+ }
111
+ function isRegularFile(path) {
112
+ try {
113
+ return statSync(path).isFile();
114
+ }
115
+ catch {
116
+ return false;
117
+ }
118
+ }
119
+ function expandUserHome(path) {
120
+ if (path === '~')
121
+ return homedir();
122
+ if (path.startsWith('~/'))
123
+ return join(homedir(), path.slice(2));
124
+ return path;
125
+ }
126
+ /** Locate the GUI-free managed host used by op-vscode. */
127
+ export function findEditorHostBinary() {
128
+ const override = process.env.DSH_OPENPENCIL_EDITOR_BINARY?.trim();
129
+ const sourceOverride = process.env.DSH_OPENPENCIL_SOURCE_ROOT?.trim()
130
+ || process.env.OPENPENCIL_SOURCE_ROOT?.trim();
131
+ const roots = [
132
+ ...(sourceOverride === undefined || sourceOverride.length === 0 ? [] : [expandUserHome(sourceOverride)]),
133
+ join(homedir(), 'workspace', 'openpencil'),
134
+ ];
135
+ const candidates = [
136
+ ...(override === undefined || override.length === 0 ? [] : [expandUserHome(override)]),
137
+ ...roots.flatMap(root => [
138
+ join(root, 'target', 'release', 'op-host-web-server'),
139
+ join(root, 'target', 'debug', 'op-host-web-server'),
140
+ ]),
141
+ ];
142
+ for (const dir of (process.env.PATH ?? '').split(delimiter)) {
143
+ if (dir.length > 0)
144
+ candidates.push(join(dir, 'op-host-web-server'));
145
+ }
146
+ // The desktop binary shares the serve-web CLI. It is useful only when the
147
+ // web bundle paths below can be resolved from an OpenPencil source root.
148
+ candidates.push('/Applications/OpenPencil.app/Contents/MacOS/openpencil-desktop');
149
+ return candidates.find(isRegularFile);
150
+ }
151
+ function sourceRootForBinary(binary) {
152
+ const configured = process.env.DSH_OPENPENCIL_SOURCE_ROOT?.trim()
153
+ || process.env.OPENPENCIL_SOURCE_ROOT?.trim();
154
+ const candidates = [
155
+ ...(configured === undefined || configured.length === 0 ? [] : [expandUserHome(configured)]),
156
+ resolve(dirname(binary), '..', '..'),
157
+ join(homedir(), 'workspace', 'openpencil'),
158
+ ];
159
+ return candidates.find(root => (isRegularFile(join(root, 'crates', 'op-host-web', 'pkg', 'op_host_web.js'))
160
+ && isRegularFile(join(root, 'crates', 'op-host-web', 'assets', 'canvaskit', 'canvaskit.wasm'))));
161
+ }
162
+ function requestOrigin(req) {
163
+ const raw = req.headers.origin;
164
+ if (typeof raw !== 'string')
165
+ throw new HttpError(403, 'editor launch requires a browser Origin header');
166
+ let url;
167
+ try {
168
+ url = new URL(raw);
169
+ }
170
+ catch {
171
+ throw new HttpError(403, 'editor launch origin is invalid');
172
+ }
173
+ const host = req.headers.host;
174
+ if (host === undefined || url.host !== host || (url.protocol !== 'http:' && url.protocol !== 'https:')) {
175
+ throw new HttpError(403, 'editor launch must be same-origin with DSH');
176
+ }
177
+ const loopback = url.hostname === '127.0.0.1' || url.hostname === 'localhost' || url.hostname === '::1';
178
+ if (!loopback)
179
+ throw new HttpError(403, 'editable OpenPencil sessions are restricted to loopback DSH');
180
+ return url.origin;
181
+ }
182
+ function isIpv4LoopbackAddress(address) {
183
+ const parts = address.split('.');
184
+ return parts.length === 4
185
+ && parts[0] === '127'
186
+ && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255);
187
+ }
188
+ /**
189
+ * Trust the transport peer, never forwarded or caller-controlled host data.
190
+ * Node may expose an IPv4 peer either directly or as an IPv4-mapped IPv6
191
+ * address, including the compact hexadecimal form used by some platforms.
192
+ */
193
+ export function isLoopbackRemoteAddress(address) {
194
+ if (address === undefined)
195
+ return false;
196
+ const normalized = address.toLowerCase().split('%', 1)[0];
197
+ if (normalized === '::1' || isIpv4LoopbackAddress(normalized))
198
+ return true;
199
+ if (!normalized.startsWith('::ffff:'))
200
+ return false;
201
+ const mapped = normalized.slice('::ffff:'.length);
202
+ if (isIpv4LoopbackAddress(mapped))
203
+ return true;
204
+ const hexadecimal = /^([a-f0-9]{1,4}):([a-f0-9]{1,4})$/.exec(mapped);
205
+ if (hexadecimal === null)
206
+ return false;
207
+ return (Number.parseInt(hexadecimal[1], 16) >>> 8) === 127;
208
+ }
209
+ function requireLoopbackPeer(req) {
210
+ if (!isLoopbackRemoteAddress(req.socket?.remoteAddress)) {
211
+ throw new HttpError(403, 'editable OpenPencil sessions require a loopback network peer');
212
+ }
213
+ }
214
+ class HttpError extends Error {
215
+ status;
216
+ constructor(status, message) {
217
+ super(message);
218
+ this.status = status;
219
+ }
220
+ }
221
+ async function readRequestBody(req) {
222
+ const chunks = [];
223
+ let size = 0;
224
+ for await (const chunk of req) {
225
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
226
+ size += bytes.length;
227
+ if (size > MAX_DOCUMENT_BYTES + 1024 * 1024)
228
+ throw new HttpError(413, 'editor request is too large');
229
+ chunks.push(bytes);
230
+ }
231
+ return Buffer.concat(chunks);
232
+ }
233
+ function parseHandshake(line) {
234
+ let value;
235
+ try {
236
+ value = JSON.parse(line);
237
+ }
238
+ catch {
239
+ throw new Error('OpenPencil editor host returned an invalid handshake');
240
+ }
241
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
242
+ throw new Error('OpenPencil editor host returned an invalid handshake');
243
+ }
244
+ const record = value;
245
+ if (record.ok !== true
246
+ || typeof record.port !== 'number' || !Number.isSafeInteger(record.port) || record.port < 1 || record.port > 65535
247
+ || typeof record.token !== 'string' || record.token.length < 16
248
+ || !((typeof record.version === 'number' && Number.isSafeInteger(record.version) && record.version >= 0)
249
+ || (typeof record.version === 'string' && record.version.length > 0 && record.version.length <= 64))) {
250
+ throw new Error('OpenPencil editor host returned an incomplete handshake');
251
+ }
252
+ return { port: record.port, token: record.token, version: record.version };
253
+ }
254
+ async function waitForHandshake(child, diagnostics, signal) {
255
+ signal?.throwIfAborted();
256
+ return new Promise((resolveHandshake, rejectHandshake) => {
257
+ let settled = false;
258
+ let stdout = '';
259
+ const finish = (error, handshake) => {
260
+ if (settled)
261
+ return;
262
+ settled = true;
263
+ clearTimeout(timer);
264
+ child.stdout.off('data', onData);
265
+ child.off('error', onError);
266
+ child.off('close', onClose);
267
+ signal?.removeEventListener('abort', onAbort);
268
+ if (error !== undefined)
269
+ rejectHandshake(error);
270
+ else
271
+ resolveHandshake(handshake);
272
+ };
273
+ const onData = (chunk) => {
274
+ stdout += chunk.toString('utf8');
275
+ if (stdout.length > MAX_HANDSHAKE_BYTES) {
276
+ finish(new Error('OpenPencil editor host handshake exceeded its size limit'));
277
+ return;
278
+ }
279
+ const newline = stdout.indexOf('\n');
280
+ if (newline < 0)
281
+ return;
282
+ try {
283
+ finish(undefined, parseHandshake(stdout.slice(0, newline).trim()));
284
+ }
285
+ catch (error) {
286
+ finish(error instanceof Error ? error : new Error(String(error)));
287
+ }
288
+ };
289
+ const onError = (error) => { finish(error); };
290
+ const onAbort = () => {
291
+ const reason = signal?.reason;
292
+ finish(reason instanceof Error ? reason : new Error('OpenPencil editor startup was cancelled'));
293
+ };
294
+ const onClose = (code) => {
295
+ finish(new Error(`OpenPencil editor host exited before startup (${String(code)})${diagnostics() === '' ? '' : `: ${diagnostics()}`}`));
296
+ };
297
+ const timer = setTimeout(() => {
298
+ finish(new Error(`OpenPencil editor host did not start within ${START_TIMEOUT_MS} ms${diagnostics() === '' ? '' : `: ${diagnostics()}`}`));
299
+ }, START_TIMEOUT_MS);
300
+ child.stdout.on('data', onData);
301
+ child.once('error', onError);
302
+ child.once('close', onClose);
303
+ signal?.addEventListener('abort', onAbort, { once: true });
304
+ if (signal?.aborted)
305
+ onAbort();
306
+ });
307
+ }
308
+ async function waitForEditorReady(baseUrl, signal) {
309
+ const deadline = Date.now() + READY_TIMEOUT_MS;
310
+ let last = '';
311
+ while (Date.now() < deadline) {
312
+ signal?.throwIfAborted();
313
+ try {
314
+ const requestSignal = signal === undefined
315
+ ? AbortSignal.timeout(2_000)
316
+ : AbortSignal.any([signal, AbortSignal.timeout(2_000)]);
317
+ const [root, glue] = await Promise.all([
318
+ fetch(`${baseUrl}/`, { signal: requestSignal }),
319
+ fetch(`${baseUrl}/pkg/op_host_web.js`, { signal: requestSignal }),
320
+ ]);
321
+ await Promise.all([root.arrayBuffer().catch(() => undefined), glue.arrayBuffer().catch(() => undefined)]);
322
+ if (root.status === 200 && glue.status === 200)
323
+ return;
324
+ last = `root=${root.status}, bundle=${glue.status}`;
325
+ }
326
+ catch (error) {
327
+ signal?.throwIfAborted();
328
+ last = errorMessage(error);
329
+ }
330
+ await new Promise(resolveDelay => setTimeout(resolveDelay, 150));
331
+ }
332
+ throw new Error(`OpenPencil editor web bundle was not ready${last === '' ? '' : `: ${last}`}`);
333
+ }
334
+ const stoppingChildren = new WeakMap();
335
+ function waitForChildClose(child, timeoutMs) {
336
+ if (child.exitCode !== null || child.signalCode !== null)
337
+ return Promise.resolve(true);
338
+ return new Promise(resolveClosed => {
339
+ let settled = false;
340
+ const finish = (value) => {
341
+ if (settled)
342
+ return;
343
+ settled = true;
344
+ clearTimeout(timer);
345
+ child.off('close', onClose);
346
+ resolveClosed(value);
347
+ };
348
+ const onClose = () => { finish(true); };
349
+ const timer = setTimeout(() => { finish(false); }, timeoutMs);
350
+ child.once('close', onClose);
351
+ });
352
+ }
353
+ function stopChild(child) {
354
+ const current = stoppingChildren.get(child);
355
+ if (current !== undefined)
356
+ return current;
357
+ if (child.exitCode !== null || child.signalCode !== null)
358
+ return Promise.resolve();
359
+ const stopping = (async () => {
360
+ if (!child.stdin.writableEnded)
361
+ child.stdin.end();
362
+ const closed = await waitForChildClose(child, STOP_TIMEOUT_MS);
363
+ if (!closed && child.exitCode === null && child.signalCode === null) {
364
+ child.kill('SIGKILL');
365
+ await waitForChildClose(child, STOP_TIMEOUT_MS);
366
+ }
367
+ })();
368
+ stoppingChildren.set(child, stopping);
369
+ void stopping.finally(() => {
370
+ if (stoppingChildren.get(child) === stopping)
371
+ stoppingChildren.delete(child);
372
+ });
373
+ return stopping;
374
+ }
375
+ async function atomicWriteDocument(path, text) {
376
+ const bytes = Buffer.from(text);
377
+ if (bytes.length === 0 || bytes.length > MAX_DOCUMENT_BYTES)
378
+ throw new HttpError(413, 'OpenPencil document size is invalid');
379
+ let parsed;
380
+ try {
381
+ parsed = JSON.parse(text);
382
+ }
383
+ catch {
384
+ throw new HttpError(400, 'OpenPencil save payload is not valid JSON');
385
+ }
386
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
387
+ throw new HttpError(400, 'OpenPencil save payload must be a document object');
388
+ }
389
+ const info = await lstat(path);
390
+ if (info.isSymbolicLink() || !info.isFile())
391
+ throw new HttpError(409, 'OpenPencil source is no longer a regular file');
392
+ const temp = join(dirname(path), `.${basename(path)}.dsh-save-${randomUUID()}.tmp`);
393
+ let handle;
394
+ try {
395
+ handle = await open(temp, 'wx', info.mode & 0o777);
396
+ await handle.writeFile(bytes);
397
+ await handle.sync();
398
+ await handle.close();
399
+ handle = undefined;
400
+ await rename(temp, path);
401
+ }
402
+ finally {
403
+ await handle?.close().catch(() => { });
404
+ await rm(temp, { force: true }).catch(() => { });
405
+ }
406
+ return sha256(bytes);
407
+ }
408
+ /** Owns opaque launch capabilities and all live managed editor children. */
409
+ export class EditorHostController {
410
+ binary = findEditorHostBinary();
411
+ #routeRefs = 0;
412
+ #routeGeneration = 0;
413
+ #editorKey;
414
+ #recoveryStore;
415
+ #sessions = new Map();
416
+ #pendingChildren = new Set();
417
+ #launchQueue = Promise.resolve();
418
+ #disposePromise;
419
+ constructor(masterKey) {
420
+ this.#editorKey = deriveEditorKey(masterKey);
421
+ this.#recoveryStore = new EditorRecoveryStore(masterKey);
422
+ }
423
+ get available() { return this.binary !== undefined; }
424
+ get routeAvailable() { return this.#routeRefs > 0; }
425
+ attachRoute() {
426
+ this.#routeRefs += 1;
427
+ let attached = true;
428
+ return () => {
429
+ if (!attached)
430
+ return;
431
+ attached = false;
432
+ this.#routeRefs = Math.max(0, this.#routeRefs - 1);
433
+ };
434
+ }
435
+ /** Mint an opaque, runtime-only launch URL; no source path enters metadata. */
436
+ grantFor(sourcePath, sourceSha256) {
437
+ if (!this.available || !this.routeAvailable || sourcePath === undefined || sourceSha256 === undefined)
438
+ return undefined;
439
+ if (!isAbsolute(sourcePath) || !sourcePath.toLowerCase().endsWith('.op') || !isSha256(sourceSha256))
440
+ return undefined;
441
+ const now = Date.now();
442
+ const token = this.#sealCapability({
443
+ v: 1,
444
+ scope: 'edit-source',
445
+ sourcePath: resolve(sourcePath),
446
+ sourceSha256,
447
+ issuedAt: now,
448
+ launchExpiresAt: now + CAPABILITY_TTL_MS,
449
+ refreshExpiresAt: now + CAPABILITY_REFRESH_TTL_MS,
450
+ });
451
+ return {
452
+ enabled: true,
453
+ launchUrl: `${EDITOR_ROUTE_PREFIX}/${token}/launch`,
454
+ refreshUrl: `${EDITOR_ROUTE_PREFIX}/${token}/refresh`,
455
+ };
456
+ }
457
+ async handle(req, res) {
458
+ try {
459
+ this.#prune();
460
+ // Host and Origin are caller-controlled HTTP data. Apply the transport
461
+ // fence before resolving any bearer capability or session identifier so
462
+ // a LAN client cannot spoof localhost to launch, refresh, read, mutate,
463
+ // recover, or close a managed editor session.
464
+ requireLoopbackPeer(req);
465
+ const url = new URL(req.url ?? '/', 'http://dsh.invalid');
466
+ const launch = new RegExp(`^${EDITOR_ROUTE_PREFIX}/([A-Za-z0-9_.-]+)/launch$`).exec(url.pathname);
467
+ const refresh = new RegExp(`^${EDITOR_ROUTE_PREFIX}/([A-Za-z0-9_.-]+)/refresh$`).exec(url.pathname);
468
+ const legacyRefresh = url.pathname === `${EDITOR_ROUTE_PREFIX}/refresh`;
469
+ const save = new RegExp(`^${EDITOR_ROUTE_PREFIX}/session/([A-Za-z0-9_-]+)/save$`).exec(url.pathname);
470
+ const selection = new RegExp(`^${EDITOR_ROUTE_PREFIX}/session/([A-Za-z0-9_-]+)/selection$`).exec(url.pathname);
471
+ const recovery = new RegExp(`^${EDITOR_ROUTE_PREFIX}/session/([A-Za-z0-9_-]+)/recovery$`).exec(url.pathname);
472
+ const recoveryItem = new RegExp(`^${EDITOR_ROUTE_PREFIX}/session/([A-Za-z0-9_-]+)/recovery/([A-Za-z0-9_-]+)$`).exec(url.pathname);
473
+ const close = new RegExp(`^${EDITOR_ROUTE_PREFIX}/session/([A-Za-z0-9_-]+)$`).exec(url.pathname);
474
+ if (launch !== null && req.method === 'POST') {
475
+ const body = await readRequestBody(req);
476
+ let ownerSessionId;
477
+ if (body.length > 0) {
478
+ try {
479
+ const value = JSON.parse(body.toString('utf8'));
480
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
481
+ const candidate = value.sessionId;
482
+ if (typeof candidate === 'string' && candidate.length > 0 && candidate.length <= 256)
483
+ ownerSessionId = candidate;
484
+ }
485
+ }
486
+ catch {
487
+ throw new HttpError(400, 'editor launch request is not valid JSON');
488
+ }
489
+ }
490
+ const routeGeneration = this.#routeGeneration;
491
+ await this.#serializeLaunch(() => this.#launch(launch[1], requestOrigin(req), res, routeGeneration, ownerSessionId));
492
+ return;
493
+ }
494
+ if (refresh !== null && req.method === 'POST') {
495
+ requestOrigin(req);
496
+ await readRequestBody(req).catch(() => Buffer.alloc(0));
497
+ await this.#refresh(refresh[1], res);
498
+ return;
499
+ }
500
+ if (legacyRefresh && req.method === 'POST') {
501
+ requestOrigin(req);
502
+ await readRequestBody(req).catch(() => Buffer.alloc(0));
503
+ throw new HttpError(410, `This editor card predates restart-safe editing; rerun ${OPENPENCIL_RENDER_TOOL_NAME} once`);
504
+ }
505
+ if (save !== null && req.method === 'POST') {
506
+ requestOrigin(req);
507
+ await this.#save(save[1], req, res);
508
+ return;
509
+ }
510
+ if (selection !== null && req.method === 'GET') {
511
+ const snapshot = await this.#selectionForSession(selection[1]);
512
+ json(res, 200, { ok: true, selection: snapshot });
513
+ return;
514
+ }
515
+ if (recovery !== null && req.method === 'POST') {
516
+ requestOrigin(req);
517
+ await readRequestBody(req).catch(() => Buffer.alloc(0));
518
+ const snapshot = await this.#captureRecoveryForSession(recovery[1], 'client-dispose');
519
+ json(res, 200, { ok: true, recovery: snapshot ?? null });
520
+ return;
521
+ }
522
+ if (recoveryItem !== null && req.method === 'POST') {
523
+ requestOrigin(req);
524
+ await readRequestBody(req).catch(() => Buffer.alloc(0));
525
+ const restored = await this.#restoreRecoveryForSession(recoveryItem[1], recoveryItem[2]);
526
+ json(res, 200, { ok: true, ...restored });
527
+ return;
528
+ }
529
+ if (recoveryItem !== null && req.method === 'DELETE') {
530
+ requestOrigin(req);
531
+ await readRequestBody(req).catch(() => Buffer.alloc(0));
532
+ const discarded = await this.#discardRecoveryForSession(recoveryItem[1], recoveryItem[2]);
533
+ json(res, 200, { ok: true, discarded });
534
+ return;
535
+ }
536
+ if (close !== null && req.method === 'DELETE') {
537
+ requestOrigin(req);
538
+ await readRequestBody(req).catch(() => Buffer.alloc(0));
539
+ await this.#close(close[1]);
540
+ json(res, 200, { ok: true });
541
+ return;
542
+ }
543
+ json(res, 404, { ok: false, error: 'editor capability not found' });
544
+ }
545
+ catch (error) {
546
+ const status = error instanceof HttpError ? error.status : 500;
547
+ json(res, status, { ok: false, error: errorMessage(error) });
548
+ }
549
+ }
550
+ dispose() {
551
+ if (this.#disposePromise !== undefined)
552
+ return this.#disposePromise;
553
+ this.#disposePromise = (async () => {
554
+ // Invalidate work accepted by the route before it was detached. Pending
555
+ // launches check this generation around every asynchronous startup phase,
556
+ // and their child is stopped here so a handshake wait cannot leak.
557
+ this.#routeGeneration += 1;
558
+ const sessions = [...this.#sessions.values()];
559
+ this.#sessions.clear();
560
+ const pending = [...this.#pendingChildren];
561
+ await Promise.all([
562
+ ...sessions.map(session => this.#captureThenDispose(session, 'plugin-dispose')),
563
+ ...pending.map(child => stopChild(child)),
564
+ ]);
565
+ // A launch can already own a retired session after removing it from the
566
+ // public session map. Wait for that serialized retirement too, so plugin
567
+ // disposal does not return while its previous editor is still alive.
568
+ await this.#launchQueue;
569
+ })();
570
+ return this.#disposePromise;
571
+ }
572
+ /**
573
+ * Build one brand-new document without requiring a browser-owned editor.
574
+ * The managed daemon is transient and never enters the visible-session map,
575
+ * so this operation neither depends on nor retires an existing workbench.
576
+ * Callers publish the returned authoritative JSON through DSH's filesystem
577
+ * capability only after the whole batch succeeds.
578
+ */
579
+ async createDocumentBatch(options) {
580
+ const binary = this.binary;
581
+ if (binary === undefined)
582
+ throw new Error('OpenPencil editor host binary is unavailable');
583
+ options.signal.throwIfAborted();
584
+ if (this.#disposePromise !== undefined)
585
+ throw new Error('OpenPencil editor host is shutting down');
586
+ return this.#serializeLaunch(async () => {
587
+ options.signal.throwIfAborted();
588
+ if (this.#disposePromise !== undefined)
589
+ throw new Error('OpenPencil editor host is shutting down');
590
+ return this.#createDocumentBatch(binary, options);
591
+ });
592
+ }
593
+ async #createDocumentBatch(binary, options) {
594
+ const tempRoot = await mkdtemp(join(tmpdir(), 'dsh-openpencil-new-'));
595
+ const sourcePath = join(tempRoot, 'starter.op');
596
+ let child;
597
+ try {
598
+ await writeFile(sourcePath, EMPTY_DOCUMENT_JSON, { flag: 'wx', mode: 0o600 });
599
+ options.signal.throwIfAborted();
600
+ if (this.#disposePromise !== undefined)
601
+ throw new Error('OpenPencil editor host is shutting down');
602
+ const env = { ...process.env };
603
+ const sourceRoot = sourceRootForBinary(binary);
604
+ if (sourceRoot !== undefined) {
605
+ env.OPENPENCIL_WEB_BUNDLE_DIR ??= join(sourceRoot, 'crates', 'op-host-web', 'pkg');
606
+ env.OPENPENCIL_CANVASKIT_DIR ??= join(sourceRoot, 'crates', 'op-host-web', 'assets', 'canvaskit');
607
+ }
608
+ child = spawn(binary, [
609
+ '--serve-web', '--managed', '--port', '0', '--file', sourcePath,
610
+ '--allow-origin', 'http://127.0.0.1',
611
+ ], { stdio: ['pipe', 'pipe', 'pipe'], env });
612
+ this.#pendingChildren.add(child);
613
+ let diagnostics = '';
614
+ child.stderr.on('data', (chunk) => {
615
+ if (diagnostics.length < MAX_DIAGNOSTIC_BYTES) {
616
+ diagnostics += chunk.toString('utf8').slice(0, MAX_DIAGNOSTIC_BYTES - diagnostics.length);
617
+ }
618
+ });
619
+ const onAbort = () => { if (child !== undefined)
620
+ void stopChild(child); };
621
+ options.signal.addEventListener('abort', onAbort, { once: true });
622
+ try {
623
+ const handshake = await waitForHandshake(child, () => diagnostics.trim(), options.signal);
624
+ options.signal.throwIfAborted();
625
+ const baseUrl = `http://127.0.0.1:${handshake.port}`;
626
+ await waitForEditorReady(baseUrl, options.signal);
627
+ const beforeVersion = await getOpenPencilMcpVersion({
628
+ baseUrl,
629
+ token: handshake.token,
630
+ signal: options.signal,
631
+ });
632
+ const result = await callOpenPencilMcp({
633
+ baseUrl,
634
+ token: handshake.token,
635
+ tool: 'batch_design',
636
+ arguments: {
637
+ operations: options.operations,
638
+ ...(options.canvasWidth === undefined ? {} : { canvasWidth: options.canvasWidth }),
639
+ ...(options.postProcess === undefined ? {} : { postProcess: options.postProcess }),
640
+ },
641
+ signal: options.signal,
642
+ });
643
+ const afterVersion = await getOpenPencilMcpVersion({
644
+ baseUrl,
645
+ token: handshake.token,
646
+ signal: options.signal,
647
+ });
648
+ if (afterVersion <= beforeVersion) {
649
+ throw new Error('OpenPencil MCP batch_design reported success but did not create a document change');
650
+ }
651
+ options.signal.throwIfAborted();
652
+ const authoritative = await readManagedDaemonDocument(baseUrl, handshake.token, fetch, options.signal);
653
+ options.signal.throwIfAborted();
654
+ if (authoritative.version < afterVersion) {
655
+ throw new Error('OpenPencil managed document snapshot is older than the applied design batch');
656
+ }
657
+ return { documentJson: authoritative.documentJson, result: result.value };
658
+ }
659
+ finally {
660
+ options.signal.removeEventListener('abort', onAbort);
661
+ }
662
+ }
663
+ finally {
664
+ if (child !== undefined) {
665
+ this.#pendingChildren.delete(child);
666
+ await stopChild(child);
667
+ }
668
+ await rm(tempRoot, { recursive: true, force: true }).catch(() => { });
669
+ }
670
+ }
671
+ /** Current live editor selection, suitable for Agent context and UI chips. */
672
+ async getActiveSelection(options = {}) {
673
+ const session = this.#activeSession(options.sourcePath, options.ownerSessionId);
674
+ return this.#selectionFor(session, options.signal);
675
+ }
676
+ /**
677
+ * Drive one allowlisted first-party MCP tool on the currently visible
678
+ * editor. The managed daemon token never crosses this controller boundary.
679
+ */
680
+ async callActiveMcp(tool, args, options = {}) {
681
+ const session = this.#activeSession(options.sourcePath, options.ownerSessionId);
682
+ const mutating = tool === 'update_node' || tool === 'batch_design';
683
+ if ('filePath' in args || 'sourceFilePath' in args || 'source_file_path' in args) {
684
+ throw new Error('OpenPencil live tools cannot target a filesystem path through MCP arguments');
685
+ }
686
+ const execute = async () => {
687
+ let beforeVersion;
688
+ if (mutating) {
689
+ const current = await readSourceDocument(session.sourcePath);
690
+ if (sha256(current) !== session.baselineSha256) {
691
+ throw new Error('OpenPencil source changed outside the active editor; rerender before applying Agent changes');
692
+ }
693
+ beforeVersion = await getOpenPencilMcpVersion({
694
+ baseUrl: new URL(session.iframeUrl).origin,
695
+ token: session.daemonToken,
696
+ signal: options.signal,
697
+ });
698
+ }
699
+ const result = await callOpenPencilMcp({
700
+ baseUrl: new URL(session.iframeUrl).origin,
701
+ token: session.daemonToken,
702
+ tool,
703
+ arguments: args,
704
+ signal: options.signal,
705
+ });
706
+ if (beforeVersion !== undefined) {
707
+ const afterVersion = await getOpenPencilMcpVersion({
708
+ baseUrl: new URL(session.iframeUrl).origin,
709
+ token: session.daemonToken,
710
+ signal: options.signal,
711
+ });
712
+ if (afterVersion <= beforeVersion) {
713
+ throw new Error(`OpenPencil MCP ${tool} reported success but did not apply a document change`);
714
+ }
715
+ }
716
+ session.createdAt = Date.now();
717
+ return result;
718
+ };
719
+ if (!mutating)
720
+ return execute();
721
+ return this.#enqueueSessionOperation(session, async () => {
722
+ session.mutating = true;
723
+ try {
724
+ return await execute();
725
+ }
726
+ finally {
727
+ session.mutating = false;
728
+ }
729
+ });
730
+ }
731
+ async #serializeLaunch(task) {
732
+ const run = this.#launchQueue.then(task, task);
733
+ // A failed launch must not poison the lifecycle queue for later requests.
734
+ this.#launchQueue = run.then(() => undefined, () => undefined);
735
+ return await run;
736
+ }
737
+ #assertLaunchLifecycle(routeGeneration) {
738
+ if (routeGeneration !== this.#routeGeneration || !this.routeAvailable) {
739
+ throw new HttpError(410, 'editor route was unloaded during launch');
740
+ }
741
+ }
742
+ async #launch(token, origin, res, routeGeneration, ownerSessionId) {
743
+ let disconnected = res.destroyed;
744
+ let launchChild;
745
+ const onResponseClose = () => {
746
+ if (res.writableFinished)
747
+ return;
748
+ disconnected = true;
749
+ if (launchChild !== undefined)
750
+ void stopChild(launchChild);
751
+ };
752
+ res.once('close', onResponseClose);
753
+ const assertConnected = () => {
754
+ this.#assertLaunchLifecycle(routeGeneration);
755
+ if (disconnected || res.destroyed)
756
+ throw new HttpError(499, 'editor launch client disconnected');
757
+ };
758
+ try {
759
+ assertConnected();
760
+ const capability = this.#openCapability(token);
761
+ if (Date.now() > capability.launchExpiresAt)
762
+ throw new HttpError(410, `editor capability expired; rerun ${OPENPENCIL_RENDER_TOOL_NAME}`);
763
+ const current = await readSourceDocument(capability.sourcePath);
764
+ assertConnected();
765
+ if (sha256(current) !== capability.sourceSha256) {
766
+ throw new HttpError(409, `source changed since this preview; rerun ${OPENPENCIL_RENDER_TOOL_NAME} before editing`);
767
+ }
768
+ const binary = this.binary;
769
+ if (binary === undefined)
770
+ throw new HttpError(503, 'OpenPencil editor host binary is unavailable');
771
+ // The details panel hosts one editor. Retire an earlier daemon before a
772
+ // successor starts so stale transcript cards cannot retain authority.
773
+ const old = [...this.#sessions.values()];
774
+ this.#sessions.clear();
775
+ await Promise.all(old.map(session => this.#captureThenDispose(session, 'plugin-dispose')));
776
+ assertConnected();
777
+ const env = { ...process.env };
778
+ const sourceRoot = sourceRootForBinary(binary);
779
+ if (sourceRoot !== undefined) {
780
+ env.OPENPENCIL_WEB_BUNDLE_DIR ??= join(sourceRoot, 'crates', 'op-host-web', 'pkg');
781
+ env.OPENPENCIL_CANVASKIT_DIR ??= join(sourceRoot, 'crates', 'op-host-web', 'assets', 'canvaskit');
782
+ }
783
+ const child = spawn(binary, [
784
+ '--serve-web', '--managed', '--port', '0', '--file', capability.sourcePath,
785
+ '--allow-origin', origin,
786
+ ], { stdio: ['pipe', 'pipe', 'pipe'], env });
787
+ launchChild = child;
788
+ this.#pendingChildren.add(child);
789
+ let diagnostics = '';
790
+ child.stderr.on('data', (chunk) => {
791
+ if (diagnostics.length < MAX_DIAGNOSTIC_BYTES) {
792
+ diagnostics += chunk.toString('utf8').slice(0, MAX_DIAGNOSTIC_BYTES - diagnostics.length);
793
+ }
794
+ });
795
+ try {
796
+ const handshake = await waitForHandshake(child, () => diagnostics.trim());
797
+ assertConnected();
798
+ const baseUrl = `http://127.0.0.1:${handshake.port}`;
799
+ await waitForEditorReady(baseUrl);
800
+ assertConnected();
801
+ const id = randomBytes(24).toString('base64url');
802
+ const session = {
803
+ id,
804
+ sourcePath: capability.sourcePath,
805
+ ...(ownerSessionId === undefined ? {} : { ownerSessionId }),
806
+ baselineSha256: capability.sourceSha256,
807
+ child,
808
+ iframeUrl: `${baseUrl}/?embed=vscode`,
809
+ daemonToken: handshake.token,
810
+ refreshExpiresAt: capability.refreshExpiresAt,
811
+ createdAt: Date.now(),
812
+ closed: false,
813
+ saving: false,
814
+ mutating: false,
815
+ recoveryCaptureRequested: false,
816
+ };
817
+ this.#sessions.set(id, session);
818
+ this.#pendingChildren.delete(child);
819
+ child.once('close', () => {
820
+ if (this.#sessions.get(id) === session)
821
+ this.#sessions.delete(id);
822
+ session.closed = true;
823
+ });
824
+ let recovery;
825
+ try {
826
+ recovery = await this.#recoveryStore.find(session.sourcePath, session.baselineSha256, current.toString('utf8'));
827
+ }
828
+ catch {
829
+ // Recovery is additive. A damaged/unwritable cache must never block
830
+ // the primary managed editor from opening.
831
+ }
832
+ json(res, 200, {
833
+ sessionId: id,
834
+ iframeUrl: session.iframeUrl,
835
+ token: session.daemonToken,
836
+ saveUrl: `${EDITOR_ROUTE_PREFIX}/session/${id}/save`,
837
+ selectionUrl: `${EDITOR_ROUTE_PREFIX}/session/${id}/selection`,
838
+ closeUrl: `${EDITOR_ROUTE_PREFIX}/session/${id}`,
839
+ recoveryUrl: `${EDITOR_ROUTE_PREFIX}/session/${id}/recovery`,
840
+ ...(recovery === undefined ? {} : { recovery }),
841
+ docJson: current.toString('utf8'),
842
+ });
843
+ if (!await waitForResponseFinish(res)) {
844
+ throw new HttpError(499, 'editor launch client disconnected');
845
+ }
846
+ launchChild = undefined;
847
+ }
848
+ catch (error) {
849
+ this.#pendingChildren.delete(child);
850
+ await stopChild(child);
851
+ throw error;
852
+ }
853
+ }
854
+ finally {
855
+ res.off('close', onResponseClose);
856
+ }
857
+ }
858
+ async #refresh(token, res) {
859
+ const capability = this.#openCapability(token);
860
+ const now = Date.now();
861
+ if (now > capability.refreshExpiresAt)
862
+ throw new HttpError(410, `editor capability expired; rerun ${OPENPENCIL_RENDER_TOOL_NAME}`);
863
+ const current = await readSourceDocument(capability.sourcePath);
864
+ if (sha256(current) !== capability.sourceSha256) {
865
+ throw new HttpError(409, `source changed since this preview; rerun ${OPENPENCIL_RENDER_TOOL_NAME} before editing`);
866
+ }
867
+ const next = this.#sealCapability({
868
+ ...capability,
869
+ issuedAt: now,
870
+ launchExpiresAt: Math.min(now + CAPABILITY_TTL_MS, capability.refreshExpiresAt),
871
+ });
872
+ json(res, 200, { launchUrl: `${EDITOR_ROUTE_PREFIX}/${next}/launch` });
873
+ }
874
+ async #save(id, req, res) {
875
+ if (!TOKEN_PATTERN.test(id))
876
+ throw new HttpError(404, 'editor session not found');
877
+ const session = this.#sessions.get(id);
878
+ if (session === undefined || session.closed)
879
+ throw new HttpError(410, 'editor session has ended');
880
+ await this.#enqueueSessionOperation(session, async () => {
881
+ session.saving = true;
882
+ try {
883
+ const bytes = await readRequestBody(req);
884
+ if (session.closed)
885
+ throw new HttpError(410, 'editor session ended before save completed');
886
+ let value;
887
+ try {
888
+ value = JSON.parse(bytes.toString('utf8'));
889
+ }
890
+ catch {
891
+ throw new HttpError(400, 'editor save request is not valid JSON');
892
+ }
893
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
894
+ throw new HttpError(400, 'editor save request is invalid');
895
+ const record = value;
896
+ if (record.sessionId !== id || typeof record.docJson !== 'string')
897
+ throw new HttpError(400, 'editor save request is incomplete');
898
+ if (typeof record.generation !== 'number' || !Number.isSafeInteger(record.generation) || record.generation < 0
899
+ || typeof record.revision !== 'number' || !Number.isSafeInteger(record.revision) || record.revision < 0)
900
+ throw new HttpError(400, 'editor save revision is invalid');
901
+ const current = await readSourceDocument(session.sourcePath);
902
+ if (sha256(current) !== session.baselineSha256) {
903
+ throw new HttpError(409, 'source changed outside this editor; save was stopped');
904
+ }
905
+ const nextHash = await atomicWriteDocument(session.sourcePath, record.docJson);
906
+ await this.#recoveryStore.discardFor(session.sourcePath).catch(() => false);
907
+ session.baselineSha256 = nextHash;
908
+ const now = Date.now();
909
+ session.createdAt = now;
910
+ const successor = session.refreshExpiresAt > now
911
+ ? this.#sealCapability({
912
+ v: 1,
913
+ scope: 'edit-source',
914
+ sourcePath: session.sourcePath,
915
+ sourceSha256: nextHash,
916
+ issuedAt: now,
917
+ launchExpiresAt: Math.min(now + CAPABILITY_TTL_MS, session.refreshExpiresAt),
918
+ refreshExpiresAt: session.refreshExpiresAt,
919
+ })
920
+ : undefined;
921
+ json(res, 200, {
922
+ ok: true,
923
+ sha256: nextHash,
924
+ ...(successor === undefined ? {} : {
925
+ editor: {
926
+ enabled: true,
927
+ launchUrl: `${EDITOR_ROUTE_PREFIX}/${successor}/launch`,
928
+ refreshUrl: `${EDITOR_ROUTE_PREFIX}/${successor}/refresh`,
929
+ },
930
+ }),
931
+ });
932
+ }
933
+ finally {
934
+ session.saving = false;
935
+ }
936
+ });
937
+ }
938
+ async #close(id) {
939
+ const session = this.#sessions.get(id);
940
+ if (session === undefined)
941
+ return;
942
+ // An ordinary user close remains guarded while a write is active. Once a
943
+ // recovery capture has been requested, however, close becomes the commit
944
+ // edge of that retirement transaction: remove authority for new work,
945
+ // await all work accepted after/before capture, recapture the final daemon
946
+ // document, then stop the child.
947
+ if (!session.recoveryCaptureRequested && session.activeOperation !== undefined) {
948
+ throw new HttpError(409, 'OpenPencil editor is still saving or applying a change');
949
+ }
950
+ this.#sessions.delete(id);
951
+ if (session.recoveryCaptureRequested) {
952
+ await this.#captureThenDispose(session, 'client-dispose');
953
+ }
954
+ else {
955
+ await this.#disposeSession(session);
956
+ }
957
+ }
958
+ #sessionForControl(id) {
959
+ if (!TOKEN_PATTERN.test(id))
960
+ throw new HttpError(404, 'editor session not found');
961
+ const session = this.#sessions.get(id);
962
+ if (session === undefined || session.closed)
963
+ throw new HttpError(410, 'editor session has ended');
964
+ return session;
965
+ }
966
+ async #captureRecoveryForSession(id, reason) {
967
+ const session = this.#sessionForControl(id);
968
+ // Set this before queueing so a close arriving immediately after this
969
+ // request joins the atomic capture/write/close retirement path.
970
+ session.recoveryCaptureRequested = true;
971
+ return this.#enqueueSessionOperation(session, () => this.#captureRecovery(session, reason));
972
+ }
973
+ async #captureRecovery(session, reason) {
974
+ if (session.closed)
975
+ return undefined;
976
+ const [source, daemonDocument] = await Promise.all([
977
+ readSourceDocument(session.sourcePath),
978
+ readManagedDaemonDocument(new URL(session.iframeUrl).origin, session.daemonToken),
979
+ ]);
980
+ return this.#recoveryStore.capture({
981
+ sourcePath: session.sourcePath,
982
+ // Bind the recovery to the source revision this editor was launched
983
+ // from. The current disk bytes are used only for the clean comparison.
984
+ // If the source changed externally from A to B while the daemon became
985
+ // C, reopening B must surface that the C recovery was based on A.
986
+ sourceSha256: session.baselineSha256,
987
+ sourceDocumentJson: source.toString('utf8'),
988
+ daemonDocument,
989
+ reason,
990
+ });
991
+ }
992
+ async #restoreRecoveryForSession(id, recoveryId) {
993
+ const session = this.#sessionForControl(id);
994
+ return this.#enqueueSessionOperation(session, async () => {
995
+ session.mutating = true;
996
+ try {
997
+ const recovery = await this.#recoveryStore.read(session.sourcePath, recoveryId);
998
+ if (recovery === undefined)
999
+ throw new HttpError(404, 'OpenPencil recovery snapshot not found');
1000
+ const current = await readManagedDaemonDocument(new URL(session.iframeUrl).origin, session.daemonToken);
1001
+ const version = await restoreManagedDaemonDocument(new URL(session.iframeUrl).origin, session.daemonToken, { documentJson: recovery.documentJson, version: current.version });
1002
+ session.createdAt = Date.now();
1003
+ return { version, docJson: recovery.documentJson };
1004
+ }
1005
+ finally {
1006
+ session.mutating = false;
1007
+ }
1008
+ });
1009
+ }
1010
+ async #discardRecoveryForSession(id, recoveryId) {
1011
+ const session = this.#sessionForControl(id);
1012
+ return this.#recoveryStore.discard(session.sourcePath, recoveryId);
1013
+ }
1014
+ #activeSession(expectedSourcePath, ownerSessionId) {
1015
+ this.#prune();
1016
+ const sessions = [...this.#sessions.values()].filter(session => !session.closed);
1017
+ if (sessions.length !== 1) {
1018
+ throw new Error('No active OpenPencil editor. Render with editable=true and open “Edit in sidebar” first.');
1019
+ }
1020
+ const session = sessions[0];
1021
+ if (ownerSessionId !== undefined && session.ownerSessionId !== ownerSessionId) {
1022
+ throw new Error('The active OpenPencil editor belongs to a different DSH session');
1023
+ }
1024
+ if (expectedSourcePath !== undefined && resolve(expectedSourcePath) !== session.sourcePath) {
1025
+ throw new Error(`The active OpenPencil editor is ${session.sourcePath}, not ${resolve(expectedSourcePath)}`);
1026
+ }
1027
+ return session;
1028
+ }
1029
+ async #selectionForSession(id) {
1030
+ if (!TOKEN_PATTERN.test(id))
1031
+ throw new HttpError(404, 'editor session not found');
1032
+ const session = this.#sessions.get(id);
1033
+ if (session === undefined || session.closed)
1034
+ throw new HttpError(410, 'editor session has ended');
1035
+ return this.#selectionFor(session);
1036
+ }
1037
+ async #selectionFor(session, signal) {
1038
+ const result = await callOpenPencilMcp({
1039
+ baseUrl: new URL(session.iframeUrl).origin,
1040
+ token: session.daemonToken,
1041
+ tool: 'get_selection',
1042
+ arguments: { readDepth: 0 },
1043
+ signal,
1044
+ });
1045
+ session.createdAt = Date.now();
1046
+ return selectionSnapshotFromMcp(session.sourcePath, result.value);
1047
+ }
1048
+ async #disposeSession(session) {
1049
+ if (session.closed)
1050
+ return;
1051
+ session.closed = true;
1052
+ await stopChild(session.child);
1053
+ }
1054
+ #enqueueSessionOperation(session, task) {
1055
+ const previous = session.activeOperation ?? Promise.resolve();
1056
+ const result = previous.then(async () => {
1057
+ if (session.closed)
1058
+ throw new HttpError(410, 'editor session ended before operation completed');
1059
+ return task();
1060
+ });
1061
+ const settled = result.then(() => { }, () => { });
1062
+ session.activeOperation = settled;
1063
+ void settled.then(() => {
1064
+ if (session.activeOperation === settled)
1065
+ delete session.activeOperation;
1066
+ });
1067
+ return result;
1068
+ }
1069
+ async #waitForSessionOperation(session) {
1070
+ const operation = session.activeOperation;
1071
+ if (operation === undefined)
1072
+ return true;
1073
+ let timer;
1074
+ try {
1075
+ return await Promise.race([
1076
+ operation.then(() => true),
1077
+ new Promise(resolveTimeout => {
1078
+ timer = setTimeout(() => resolveTimeout(false), OPERATION_RETIRE_TIMEOUT_MS);
1079
+ timer.unref();
1080
+ }),
1081
+ ]);
1082
+ }
1083
+ finally {
1084
+ if (timer !== undefined)
1085
+ clearTimeout(timer);
1086
+ }
1087
+ }
1088
+ async #captureThenDispose(session, reason) {
1089
+ try {
1090
+ // A mutation updates the daemon immediately before its promise settles.
1091
+ // Waiting here guarantees recovery observes that final document instead
1092
+ // of an earlier version. A bounded failure skips capture rather than
1093
+ // persisting a known-racy snapshot, then still reaps the child below.
1094
+ if (await this.#waitForSessionOperation(session)) {
1095
+ await this.#captureRecovery(session, reason);
1096
+ }
1097
+ }
1098
+ catch {
1099
+ // Recovery is best-effort, but disposal is mandatory. In particular an
1100
+ // unavailable daemon or cache must never leave an idle/HMR child alive.
1101
+ }
1102
+ finally {
1103
+ await this.#disposeSession(session);
1104
+ }
1105
+ }
1106
+ #prune() {
1107
+ const now = Date.now();
1108
+ for (const [id, session] of this.#sessions) {
1109
+ if (now - session.createdAt > SESSION_IDLE_MS) {
1110
+ this.#sessions.delete(id);
1111
+ void this.#captureThenDispose(session, 'plugin-dispose');
1112
+ }
1113
+ }
1114
+ }
1115
+ #sealCapability(capability) {
1116
+ const nonce = randomBytes(12);
1117
+ const cipher = createCipheriv('aes-256-gcm', this.#editorKey, nonce);
1118
+ cipher.setAAD(EDITOR_CAPABILITY_AAD);
1119
+ const ciphertext = Buffer.concat([cipher.update(JSON.stringify(capability), 'utf8'), cipher.final()]);
1120
+ return `${EDITOR_CAPABILITY_PREFIX}${Buffer.concat([nonce, cipher.getAuthTag(), ciphertext]).toString('base64url')}`;
1121
+ }
1122
+ #openCapability(token) {
1123
+ if (!token.startsWith(EDITOR_CAPABILITY_PREFIX)) {
1124
+ // Compatibility boundary for pre-fix transcript cards: their random
1125
+ // in-memory token cannot safely be recreated after a plugin reload.
1126
+ throw new HttpError(410, `editor capability expired; rerun ${OPENPENCIL_RENDER_TOOL_NAME}`);
1127
+ }
1128
+ if (token.length > EDITOR_CAPABILITY_MAX_LENGTH)
1129
+ throw new HttpError(404, 'editor capability not found');
1130
+ try {
1131
+ const packed = Buffer.from(token.slice(EDITOR_CAPABILITY_PREFIX.length), 'base64url');
1132
+ if (packed.length < 29)
1133
+ throw new Error('short token');
1134
+ const decipher = createDecipheriv('aes-256-gcm', this.#editorKey, packed.subarray(0, 12));
1135
+ decipher.setAAD(EDITOR_CAPABILITY_AAD);
1136
+ decipher.setAuthTag(packed.subarray(12, 28));
1137
+ const plain = Buffer.concat([decipher.update(packed.subarray(28)), decipher.final()]);
1138
+ const capability = capabilityFrom(JSON.parse(plain.toString('utf8')));
1139
+ if (capability === undefined)
1140
+ throw new Error('invalid payload');
1141
+ return capability;
1142
+ }
1143
+ catch {
1144
+ throw new HttpError(404, 'editor capability not found');
1145
+ }
1146
+ }
1147
+ }