pi-studio 0.9.63 → 0.10.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 (53) hide show
  1. package/BUFFER-DESIGN.md +197 -0
  2. package/CHANGELOG.md +31 -0
  3. package/PRERELEASE-CHECKLIST.md +100 -0
  4. package/README.md +110 -193
  5. package/ROADMAP.md +63 -6
  6. package/WORKFLOW.md +32 -2
  7. package/assets/screenshots/dark-workspace.png +0 -0
  8. package/assets/screenshots/light-workspace.png +0 -0
  9. package/assets/screenshots/theme-zen-code.png +0 -0
  10. package/client/studio-client.js +6694 -942
  11. package/client/studio-editor-draft-helpers.js +162 -0
  12. package/client/studio-navigation-helpers.js +31 -5
  13. package/client/studio.css +460 -12
  14. package/index.ts +1178 -79
  15. package/package.json +5 -3
  16. package/shared/STUDIO_BUFFER_FOUNDATION.md +164 -0
  17. package/shared/studio-buffer-client.js +663 -0
  18. package/shared/studio-buffer-decisions.js +129 -0
  19. package/shared/studio-buffer-lineage.js +48 -0
  20. package/shared/studio-buffer-recovery-panel.js +132 -0
  21. package/shared/studio-buffer-recovery.js +181 -0
  22. package/shared/studio-buffer-server.js +700 -0
  23. package/shared/studio-buffer-store.js +227 -0
  24. package/shared/studio-buffer-switching.js +53 -0
  25. package/shared/studio-disk-revisions.js +15 -0
  26. package/shared/studio-document-entrypoints.js +38 -0
  27. package/shared/studio-document-escrow.js +147 -0
  28. package/shared/studio-document-file-claims.js +215 -0
  29. package/shared/studio-document-host-client.js +268 -0
  30. package/shared/studio-document-hosting.js +319 -0
  31. package/shared/studio-document-launch-client.js +153 -0
  32. package/shared/studio-document-open-client.js +96 -0
  33. package/shared/studio-document-open-storage.js +21 -0
  34. package/shared/studio-document-paths.js +29 -0
  35. package/shared/studio-editor-mode.js +48 -0
  36. package/shared/studio-linked-reader-ui.js +232 -0
  37. package/shared/studio-linked-reader.js +110 -0
  38. package/shared/studio-metadata-write-order.js +35 -0
  39. package/shared/studio-read-only-media.js +24 -0
  40. package/shared/studio-resource-grants.js +3 -0
  41. package/shared/studio-run-lifecycle.js +47 -0
  42. package/shared/studio-system-theme.js +9 -0
  43. package/assets/screenshots/dark-code-mode.png +0 -0
  44. package/assets/screenshots/dark-critique.png +0 -0
  45. package/assets/screenshots/dark-fenced-code.png +0 -0
  46. package/assets/screenshots/dark-focus-mode.png +0 -0
  47. package/assets/screenshots/dark-julia-fenced.png +0 -0
  48. package/assets/screenshots/dark-math.png +0 -0
  49. package/assets/screenshots/dark-mermaid.png +0 -0
  50. package/assets/screenshots/dark-zen.png +0 -0
  51. package/assets/screenshots/light-code-mode.png +0 -0
  52. package/assets/screenshots/light-fenced-code.png +0 -0
  53. package/assets/screenshots/light-math.png +0 -0
@@ -0,0 +1,319 @@
1
+ // Process-scoped document ownership, not durable storage or filesystem authority.
2
+ // The adapter must authenticate workspace IDs and supply a trusted canonical
3
+ // identity resolver. These claims do not authorize filesystem reads or writes.
4
+ // Checkpoint/move commits are synchronous compare-and-swap.
5
+ import { createStudioBuffer, validateStudioBufferWorkspace } from "./studio-buffer-store.js";
6
+
7
+ const fail = (reason, message, extra = {}) => ({ ok: false, reason, message, ...extra });
8
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
9
+
10
+ export function studioDocumentMetadataKey(entry, field) {
11
+ if (entry.metadata[field] !== null) return entry.metadata[field];
12
+ const source = entry.sourceState;
13
+ if (source.path) return "file:" + source.path;
14
+ if (source.draftId) return "draft:" + source.draftId;
15
+ const kind = source.source || "blank", label = (source.label || "blank").trim().replace(/\s+/g, " ") || kind;
16
+ return "doc:" + kind + ":" + label;
17
+ }
18
+ export function studioDocumentAuthorityIdentity(entry) {
19
+ return entry ? [entry.sourceState, entry.resourceDir, studioDocumentMetadataKey(entry, "reviewNotesKey"), studioDocumentMetadataKey(entry, "scratchpadKey")] : null;
20
+ }
21
+
22
+ export function isStudioPristineBuffer(entry) {
23
+ return entry.text === "" && entry.baselineText === "" && entry.sourceState.path === null
24
+ && entry.sourceState.source === "blank" && ["", "Prompt", "Untitled", "Document"].includes(entry.sourceState.label)
25
+ && entry.resourceDir === "" && entry.metadata.reviewNotesKey === null && entry.metadata.scratchpadKey === null;
26
+ }
27
+ export const isStudioPristineWorkspace = state => state.buffers.every(isStudioPristineBuffer);
28
+
29
+ export function createStudioDocumentCopy(entry, id) {
30
+ return createStudioBuffer({ ...entry, id, role: "document", revision: 0, baselineText: "", diskRevision: null,
31
+ sourceState: { source: "blank", label: "Copy of " + (entry.sourceState.label || "document"), path: null, draftId: id,
32
+ ...(entry.sourceState.provenance ? { provenance: entry.sourceState.provenance } : {}) },
33
+ metadata: { ...entry.metadata, reviewNotesKey: null, scratchpadKey: null } });
34
+ }
35
+
36
+ export function createStudioDocumentHostingStore(options) {
37
+ const { makeId, canonicalPath } = options;
38
+ const now = options.now ?? Date.now;
39
+ const maxDocuments = options.maxDocuments ?? 256, maxMoves = options.maxMoves ?? 128;
40
+ const offerLifetimeMs = options.offerLifetimeMs ?? 120_000;
41
+ const terminalLifetimeMs = options.terminalLifetimeMs ?? 600_000;
42
+ const workspaces = new Map(), owners = new Map(), paths = new Map(), retired = new Set(), moves = new Map(), pending = new Map();
43
+ let nextDocumentEpoch = 0;
44
+ const ownerInfo = owner => owner ? { workspaceId: owner.workspaceId, bufferId: owner.bufferId, mode: owner.mode } : null;
45
+ function expireOffers() {
46
+ for (const move of moves.values()) if (move.status === "offered" && now() >= move.expiresAt) cancel(move, "expired");
47
+ for (const [id, move] of moves) if (move.status !== "offered" && now() - move.terminalAt >= terminalLifetimeMs) moves.delete(id);
48
+ }
49
+ function makeMoveRoom() {
50
+ // Terminal operations advanced the source workspace revision. Forgetting
51
+ // their explanation cannot make an original delayed Begin current again.
52
+ for (const [id, move] of moves) {
53
+ if (moves.size < maxMoves) break;
54
+ if (move.status !== "offered") moves.delete(id);
55
+ }
56
+ }
57
+ function cancel(move, reason) {
58
+ if (move.status !== "offered") return outcome(move);
59
+ const before = workspaces.get(move.sourceWorkspaceId);
60
+ if (!before) return fail("missing-workspace", "The source workspace is unavailable.");
61
+ const after = { ...before, revision: before.revision + 1, savedAt: Math.max(now(), before.savedAt + 1) };
62
+ const planned = plan([after], move.id); if (!planned.ok) return planned;
63
+ if (options.commitMove) {
64
+ const stored = options.commitMove({ before: [before], after: planned.states, kind: "cancel", move: outcome(move) });
65
+ if (!stored?.ok) return stored || fail("unavailable", "Cancellation could not be checkpointed; keep the editor frozen.");
66
+ }
67
+ commit(planned); move.status = "cancelled"; move.reason = reason; move.terminalAt = now();
68
+ if (pending.get(move.sourceWorkspaceId) === move.id) pending.delete(move.sourceWorkspaceId);
69
+ delete move.source;
70
+ return outcome(move);
71
+ }
72
+ function outcome(move) {
73
+ return { ok: true, moveId: move.id, status: move.status, bufferId: move.bufferId,
74
+ sourceWorkspaceId: move.sourceWorkspaceId, targetWorkspaceId: move.targetWorkspaceId ?? null,
75
+ ...(move.reason ? { reason: move.reason } : {}), ...(move.adopted ? { adopted: true } : {}) };
76
+ }
77
+ function plan(states, movingId = null) {
78
+ const checked = [], ids = new Set();
79
+ for (const value of states) {
80
+ const result = validateStudioBufferWorkspace(value); if (!result.ok) return result;
81
+ const state = result.state;
82
+ if (ids.has(state.workspaceId)) return fail("duplicate-workspace", "One checkpoint per workspace is required.");
83
+ ids.add(state.workspaceId);
84
+ const before = workspaces.get(state.workspaceId);
85
+ if (before && !same(before, state) && (before.mode !== state.mode || state.revision <= before.revision || state.savedAt < before.savedAt)) {
86
+ return fail("stale-workspace", "The workspace changed. Keep current text and retry explicitly.");
87
+ }
88
+ const held = pending.get(state.workspaceId);
89
+ if (held && held !== movingId) return fail("move-pending", "Finish or cancel the document move before changing this workspace.");
90
+ checked.push(state);
91
+ }
92
+ const nextOwners = new Map(owners), nextPaths = new Map(paths), nextRetired = new Set(retired);
93
+ const previouslyOwned = new Set();
94
+ for (const [id, owner] of owners) if (ids.has(owner.workspaceId)) {
95
+ previouslyOwned.add(id); nextOwners.delete(id); if (owner.path) nextPaths.delete(owner.path);
96
+ }
97
+ const added = new Set();
98
+ for (const state of checked) for (const entry of state.buffers) {
99
+ if (added.has(entry.id)) return fail("duplicate-document", "A document cannot have two editing locations.");
100
+ added.add(entry.id);
101
+ const existing = owners.get(entry.id);
102
+ if (retired.has(entry.id)) return fail("retired-document", "That document identity was retired. Recover its text as an explicit new copy.");
103
+ if (existing && existing.workspaceId !== state.workspaceId && (!movingId || moves.get(movingId)?.bufferId !== entry.id)) {
104
+ return fail("already-open", "This document already has an editing location.", { owner: ownerInfo(existing) });
105
+ }
106
+ let path = null;
107
+ const previous = existing && workspaces.get(existing.workspaceId)?.buffers.find(b => b.id === entry.id);
108
+ // Capture file identity when backing changes, not on every keystroke or
109
+ // move. A retargeted/broken symlink must not silently rebind this draft.
110
+ try { if (entry.sourceState.path) path = previous?.sourceState.path === entry.sourceState.path ? existing.path : canonicalPath(entry.sourceState.path); }
111
+ catch { return fail("invalid-path", "The document's canonical file identity could not be checked."); }
112
+ if (path !== null && (typeof path !== "string" || !path || path.includes("\0"))) return fail("invalid-path", "Invalid canonical document path.");
113
+ let otherId = path && nextPaths.get(path);
114
+ if (path && !otherId && options.sameFile) {
115
+ try { otherId = [...nextOwners.values()].find(owner => owner.path && options.sameFile(path, owner.path))?.bufferId; }
116
+ catch { return fail("invalid-path", "The document's file aliases could not be checked."); }
117
+ }
118
+ if (otherId && otherId !== entry.id) return fail("already-open", "This file is already open. Use its existing editor, move it, or make a detached copy.", { owner: ownerInfo(nextOwners.get(otherId)) });
119
+ const epoch = existing?.workspaceId === state.workspaceId && same(studioDocumentAuthorityIdentity(previous), studioDocumentAuthorityIdentity(entry))
120
+ ? existing.epoch : ++nextDocumentEpoch;
121
+ const owner = { workspaceId: state.workspaceId, bufferId: entry.id, mode: state.mode, path, epoch };
122
+ nextOwners.set(entry.id, owner); if (path) nextPaths.set(path, entry.id);
123
+ }
124
+ for (const id of previouslyOwned) if (!added.has(id)) {
125
+ const owner = owners.get(id), entry = workspaces.get(owner.workspaceId).buffers.find(b => b.id === id);
126
+ if (!isStudioPristineBuffer(entry)) nextRetired.add(id);
127
+ }
128
+ if (nextOwners.size + nextRetired.size > maxDocuments) return fail("capacity", "Document ownership capacity reached. No document was evicted.");
129
+ return { ok: true, states: checked, owners: nextOwners, paths: nextPaths, retired: nextRetired };
130
+ }
131
+ function commit(planned) {
132
+ for (const state of planned.states) workspaces.set(state.workspaceId, state);
133
+ owners.clear(); for (const [key, value] of planned.owners) owners.set(key, value);
134
+ paths.clear(); for (const [key, value] of planned.paths) paths.set(key, value);
135
+ retired.clear(); for (const key of planned.retired) retired.add(key);
136
+ }
137
+ const replaceSlot = (state, id, replacement) => ({ ...state, revision: state.revision + 1, savedAt: Math.max(now(), state.savedAt + 1),
138
+ selectedBufferId: state.selectedBufferId === id ? replacement.id : state.selectedBufferId,
139
+ order: state.order.map(value => value === id ? replacement.id : value), buffers: state.buffers.map(value => value.id === id ? replacement : value) });
140
+ const api = {
141
+ sweep() { expireOffers(); },
142
+ hasPending: workspaceId => pending.has(workspaceId),
143
+ pendingId: workspaceId => pending.get(workspaceId) ?? null,
144
+ pendingMoves: workspaceId => [...moves.values()].filter(m => m.status === "offered" && (m.sourceWorkspaceId === workspaceId || m.targetWorkspaceId === workspaceId)).map(outcome),
145
+ cancelPending(workspaceId) {
146
+ const move = moves.get(pending.get(workspaceId));
147
+ return move ? cancel(move, "connection-closed") : { ok: true };
148
+ },
149
+ reclaimPristine(workspaceId) {
150
+ const state = workspaces.get(workspaceId);
151
+ if (!state || pending.has(workspaceId) || [...moves.values()].some(m => m.status === "offered" && m.targetWorkspaceId === workspaceId)
152
+ || !isStudioPristineWorkspace(state)) return false;
153
+ for (const entry of state.buffers) owners.delete(entry.id);
154
+ workspaces.delete(workspaceId);
155
+ return true;
156
+ },
157
+ checkpoint(state) {
158
+ expireOffers();
159
+ const before = workspaces.get(state?.workspaceId);
160
+ if (before && same(before, state)) return { ok: true }; // Exact acknowledgement retry, including while frozen.
161
+ const planned = plan([state]); if (!planned.ok) return planned;
162
+ commit(planned); return { ok: true };
163
+ },
164
+ workspace: id => workspaces.get(id) ?? null,
165
+ owner: id => ownerInfo(owners.get(id)),
166
+ documentAuthorities(workspaceId) {
167
+ return Object.fromEntries([...owners].filter(([, owner]) => owner.workspaceId === workspaceId).map(([id, owner]) => [id, owner.epoch]));
168
+ },
169
+ // Trusted synchronous file-commit hook. Even a same-byte save retires its
170
+ // original mutation authority, so terminal save receipts can be reclaimed.
171
+ advanceAuthority(workspaceId, bufferId) {
172
+ const owner = owners.get(bufferId);
173
+ if (!owner || owner.workspaceId !== workspaceId) throw new Error("Cannot advance an unowned Document");
174
+ owner.epoch = ++nextDocumentEpoch;
175
+ return owner.epoch;
176
+ },
177
+ checkAuthority(workspaceId, bufferId, epoch) {
178
+ const owner = owners.get(bufferId);
179
+ if (!owner || owner.workspaceId !== workspaceId) return fail("not-owner", "This editing location no longer owns the document.");
180
+ if (epoch !== owner.epoch) return fail("document-stale", "This request belongs to an earlier document ownership or source generation.");
181
+ if (moves.get(pending.get(workspaceId))?.bufferId === bufferId) return fail("move-pending", "The moving document is frozen.");
182
+ return { ok: true };
183
+ },
184
+ findFile(path) {
185
+ try {
186
+ const canonical = canonicalPath(path);
187
+ return ownerInfo(owners.get(paths.get(canonical)) || (options.sameFile
188
+ ? [...owners.values()].find(owner => owner.path && options.sameFile(canonical, owner.path)) : null));
189
+ } catch { return null; }
190
+ },
191
+ // Trusted external writers may inspect coordination, not acquire ownership,
192
+ // file grants or editor authority. Unlike findFile, uncertainty is refusal.
193
+ checkUnownedFile(path) {
194
+ try {
195
+ // The server supplies a normalized absolute target. Keep this shared
196
+ // identity module browser-loadable (Document clients import it too).
197
+ const canonical = canonicalPath(path);
198
+ if (typeof canonical !== "string" || !canonical || canonical.includes("\0")) throw new Error("Invalid canonical identity");
199
+ if ([...owners.values()].some(owner => owner.path && (owner.path === path || owner.path === canonical
200
+ || options.sameFile?.(canonical, owner.path)))) return fail("document-owned", "The HTML export target is owned or claimed by a Document; its file and work were kept.");
201
+ return { ok: true };
202
+ } catch { return fail("identity-unknown", "The HTML export target's Document identity could not be verified; nothing was written."); }
203
+ },
204
+ checkWrite(workspaceId, bufferId, path) {
205
+ expireOffers();
206
+ const owner = owners.get(bufferId), entry = workspaces.get(workspaceId)?.buffers.find(b => b.id === bufferId);
207
+ if (!owner || owner.workspaceId !== workspaceId || !entry) return fail("not-owner", "This page no longer owns that document.");
208
+ if (pending.has(workspaceId)) return fail("move-pending", "Resolve the pending move before writing a file.");
209
+ if (!owner.path || !entry.sourceState.path) return fail("not-file-backed", "Use Save As for a detached document.");
210
+ try {
211
+ if (canonicalPath(path) !== owner.path || canonicalPath(entry.sourceState.path) !== owner.path) return fail("source-changed", "The file location changed. Refresh explicitly or use Save As; nothing was written.");
212
+ } catch { return fail("source-changed", "The file identity could not be checked; nothing was written."); }
213
+ // Coordination only: the caller must still check disk revision and all
214
+ // ordinary filesystem authorization immediately before the actual write.
215
+ return { ok: true };
216
+ },
217
+ beginMove({ workspaceId, expectedRevision, bufferId, targetWorkspaceId = null, targetMode = "editor-only", moveId = null, kind = "offer" }) {
218
+ expireOffers();
219
+ if (moveId !== null && (typeof moveId !== "string" || !/^[a-f0-9]{48}$/.test(moveId))) return fail("invalid-move", "Invalid move request identity.");
220
+ const intent = { bufferId, expectedRevision, targetWorkspaceId, targetMode, kind };
221
+ const retry = moveId && moves.get(moveId);
222
+ if (retry) {
223
+ if (retry.sourceWorkspaceId !== workspaceId) return fail("forbidden", "Move identity belongs to another workspace.");
224
+ if (retry.intent && !same(retry.intent, intent)) return fail("invalid-move", "A move identity cannot be reused for different parameters.");
225
+ return outcome(retry);
226
+ }
227
+ const source = workspaces.get(workspaceId), entry = source?.buffers.find(b => b.id === bufferId);
228
+ if (!source || source.revision !== expectedRevision || source.selectedBufferId !== bufferId) return fail("stale-workspace", "The originating editor changed. Nothing was moved.");
229
+ if (entry?.role !== "document") return fail("not-document", "Move Document, not the active Prompt. Make a detached copy of Prompt instead.");
230
+ if (!["full", "editor-only"].includes(targetMode) || targetWorkspaceId === workspaceId || (targetMode === "full" && !targetWorkspaceId)
231
+ || (targetWorkspaceId !== null && (typeof targetWorkspaceId !== "string" || !/^[a-zA-Z0-9_-]{20,128}$/.test(targetWorkspaceId)))) return fail("invalid-target", "Choose a separate document editing location.");
232
+ if (pending.has(workspaceId)) return fail("move-pending", "This workspace already has a pending move.");
233
+ makeMoveRoom();
234
+ if (moves.size >= maxMoves) return fail("capacity", "Too many unresolved moves; current documents were kept.");
235
+ const id = moveId ?? makeId(); if (moves.has(id)) return fail("duplicate-id", "Move identity could not be allocated.");
236
+ const move = { id, sourceWorkspaceId: workspaceId, source, bufferId, targetWorkspaceId, targetMode, intent, status: "offered", expiresAt: now() + offerLifetimeMs };
237
+ moves.set(id, move); pending.set(workspaceId, id);
238
+ return { ...outcome(move), expiresAt: move.expiresAt };
239
+ },
240
+ moveOut({ workspaceId, expectedRevision, bufferId, moveId, destinationId }) {
241
+ if (workspaces.get(workspaceId)?.mode !== "full") return fail("wrong-mode", "Move out starts from Studio's Document slot.");
242
+ if (moves.get(moveId)?.status !== "offered" && moves.has(moveId)) return api.beginMove({ workspaceId, expectedRevision, bufferId, moveId, kind: "move-out" });
243
+ if (typeof destinationId !== "string" || !/^[a-zA-Z0-9_-]{20,128}$/.test(destinationId) || workspaces.has(destinationId)) return fail("invalid-target", "A fresh server-created destination is required.");
244
+ const started = api.beginMove({ workspaceId, expectedRevision, bufferId, moveId, kind: "move-out" });
245
+ if (!started.ok || started.status !== "offered") return started;
246
+ const move = moves.get(moveId), source = workspaces.get(workspaceId), entry = source.buffers.find(b => b.id === bufferId);
247
+ const nextSource = replaceSlot(source, bufferId, createStudioBuffer({ id: makeId(), role: "document" }));
248
+ const destination = { version: 2, workspaceId: destinationId, mode: "editor-only", revision: 1, savedAt: now(),
249
+ activePromptId: null, selectedBufferId: bufferId, order: [bufferId], buffers: [entry] };
250
+ const planned = plan([nextSource, destination], moveId); if (!planned.ok) return planned;
251
+ if (options.commitMove) {
252
+ const stored = options.commitMove({ before: [source, null], after: planned.states, kind: "move-out",
253
+ move: { ...outcome(move), targetWorkspaceId: destinationId } });
254
+ if (!stored?.ok) return stored || fail("unavailable", "Move could not be checkpointed; the source was retained.");
255
+ }
256
+ commit(planned); move.status = "committed"; move.targetWorkspaceId = destinationId; move.terminalAt = now();
257
+ pending.delete(workspaceId); delete move.source; return outcome(move);
258
+ },
259
+ moveStatus(workspaceId, moveId, incoming = false) {
260
+ expireOffers(); const move = moves.get(moveId);
261
+ if (!move || (!incoming && move.sourceWorkspaceId !== workspaceId && move.targetWorkspaceId !== workspaceId)) return fail("forbidden", "Move does not belong to this workspace.");
262
+ return outcome(move);
263
+ },
264
+ declineMove(workspaceId, moveId) {
265
+ expireOffers(); const move = moves.get(moveId);
266
+ if (!move || move.targetWorkspaceId !== workspaceId) return fail("forbidden", "Only the bound destination can decline this move.");
267
+ return cancel(move, "declined");
268
+ },
269
+ adoptMove(workspaceId, moveId, bufferId) {
270
+ const move = moves.get(moveId);
271
+ if (!move || move.status !== "committed" || move.targetWorkspaceId !== workspaceId || move.bufferId !== bufferId
272
+ || owners.get(bufferId)?.workspaceId !== workspaceId) return fail("not-owner", "Only the moved document's current destination can acknowledge adoption.");
273
+ move.adopted = true; return outcome(move);
274
+ },
275
+ cancelMove(workspaceId, moveId) {
276
+ expireOffers(); let move = moves.get(moveId);
277
+ if (!move && workspaces.has(workspaceId) && typeof moveId === "string" && /^[a-f0-9]{48}$/.test(moveId)) {
278
+ // Cancel may overtake an uncertain Begin request. Retain a tombstone so
279
+ // that late delivery can never re-freeze or move the resumed editor.
280
+ makeMoveRoom();
281
+ if (moves.size >= maxMoves) return fail("capacity", "Cancellation could not be recorded. Keep the editor frozen and retry.");
282
+ move = { id: moveId, sourceWorkspaceId: workspaceId, bufferId: null, status: "offered" };
283
+ const cancelled = cancel(move, "cancelled"); if (!cancelled.ok) return cancelled;
284
+ moves.set(moveId, move);
285
+ }
286
+ if (!move || move.sourceWorkspaceId !== workspaceId) return fail("forbidden", "Only the originating workspace can cancel this move.");
287
+ return cancel(move, "cancelled"); // A late Cancel cannot roll back a transfer.
288
+ },
289
+ acceptMove({ workspaceId, expectedRevision, targetBufferId, moveId, discardTarget = false }) {
290
+ expireOffers(); const move = moves.get(moveId);
291
+ if (!move) return fail("invalid-move", "Move is unavailable; the original editor was kept.");
292
+ if (move.targetWorkspaceId && move.targetWorkspaceId !== workspaceId) return fail("forbidden", "This move is for another workspace.");
293
+ if (move.status === "cancelled") return fail("cancelled", "Move was cancelled; the original editor was kept.");
294
+ if (move.status === "committed") return outcome(move); // Lost acknowledgement: never apply twice.
295
+ const target = workspaces.get(workspaceId), source = workspaces.get(move.sourceWorkspaceId);
296
+ if (source !== move.source || target?.revision !== expectedRevision || target?.mode !== move.targetMode || target.workspaceId === source.workspaceId) return fail("stale-workspace", "A participating workspace changed. Nothing was moved.");
297
+ const placeholder = target.buffers.find(b => b.id === targetBufferId);
298
+ if (placeholder?.role !== "document") return fail("invalid-target", "Only a Document slot can receive a moved document.");
299
+ if (discardTarget !== true && (placeholder.sourceState.path || placeholder.text || placeholder.baselineText !== ""
300
+ || placeholder.metadata.reviewNotesKey || placeholder.metadata.scratchpadKey)) return fail("target-not-empty", "The destination may contain work. Save/copy it or explicitly confirm replacement before moving here.");
301
+ if (pending.has(workspaceId)) return fail("move-pending", "The destination is participating in another move.");
302
+ const entry = source.buffers.find(b => b.id === move.bufferId);
303
+ const blank = createStudioBuffer({ id: makeId(), role: "document" });
304
+ const nextSource = replaceSlot(source, entry.id, blank), nextTarget = replaceSlot(target, placeholder.id, entry);
305
+ const planned = plan([nextSource, nextTarget], move.id); if (!planned.ok) return planned;
306
+ // The adapter can atomically checkpoint both recovery workspaces here.
307
+ // No registry ownership is changed if capacity/CAS/storage validation fails.
308
+ if (options.commitMove) {
309
+ const stored = options.commitMove({ before: [source, target], after: planned.states, kind: "move", move: { ...outcome(move), targetWorkspaceId: workspaceId } });
310
+ if (!stored?.ok) return stored || fail("unavailable", "Move could not be checkpointed. Original editors were kept.");
311
+ }
312
+ commit(planned); move.status = "committed"; move.targetWorkspaceId = workspaceId; move.terminalAt = now();
313
+ pending.delete(source.workspaceId); delete move.source;
314
+ return outcome(move);
315
+ },
316
+ clear() { workspaces.clear(); owners.clear(); paths.clear(); retired.clear(); moves.clear(); pending.clear(); },
317
+ };
318
+ return Object.freeze(api);
319
+ }
@@ -0,0 +1,153 @@
1
+ import { createStudioDocumentCreationIntent } from "./studio-document-escrow.js";
2
+
3
+ // Identity lookup, not a file read or a new editable owner. Resume is guarded
4
+ // for seen/disconnected tabs; after consent the exact current owner is rechecked.
5
+ export async function openStudioRetainedDocument(options, bufferId, focus = true) {
6
+ const generation = options.generation();
7
+ const current = () => generation > 0 && options.generation() === generation && options.current();
8
+ const query = { operation: "owner", bufferId, focus: false };
9
+ const validId = value => typeof value === "string" && /^[a-zA-Z0-9_-]{1,128}$/.test(value);
10
+ try {
11
+ if (!current()) return false;
12
+ options.observed?.(bufferId, { live: false });
13
+ let result = await options.request(query);
14
+ if (!current()) return false;
15
+ if (!result.ok || !validId(result.owner?.workspaceId) || result.owner?.bufferId !== bufferId) throw Error(result.message || "That Document is no longer retained. Keep any original tab or recovery copy.");
16
+ options.observed?.(bufferId, result);
17
+ if (!focus) return true;
18
+ const owner = result.owner;
19
+ const local = owner.workspaceId === options.workspaceId && typeof options.selectOwner === "function";
20
+ const matches = reply => reply.ok && reply.owner?.workspaceId === owner.workspaceId && reply.owner?.bufferId === owner.bufferId;
21
+ let resumed = false;
22
+ if (!local && !result.live && result.seen) {
23
+ if (!await options.confirm("Reopen the same Document?", "Its tab isn't connected and may have newer edits. Reconnect that tab if you can. Otherwise, reopen the same Document (not a copy)?")) return false;
24
+ if (!current()) return false;
25
+ resumed = true;
26
+ }
27
+ // Always recheck before opening, including unseen creation URLs and local owners.
28
+ result = await options.request({ ...query, expectedOwner: owner });
29
+ if (!current()) return false;
30
+ if (!matches(result)) throw Error(result.message || "The Document owner changed. Recheck; no new tab was opened.");
31
+ options.observed?.(bufferId, result);
32
+ if (!local && !result.live && result.seen && !resumed) throw Error("That editor connected and disconnected while checking. Recheck and choose whether to resume it.");
33
+ if (local && !result.live) {
34
+ if (!options.selectOwner(bufferId)) throw Error("The local Document could not be selected. Current work was kept.");
35
+ options.status("This Document already has an editor here. Its current text was kept; reconnect this window rather than opening another.", "warning");
36
+ } else if (result.live) {
37
+ const focused = await options.request({ ...query, focus: true, generation, expectedOwner: owner });
38
+ if (!current()) return false;
39
+ if (!matches(focused) || !focused.live) throw Error("The editor changed or disconnected before focus. Keep its original tab and recheck.");
40
+ options.observed?.(bufferId, focused);
41
+ if (owner.workspaceId === options.workspaceId && options.selectOwner && !options.selectOwner(bufferId)) throw Error("The local Document could not be selected. Current work was kept.");
42
+ options.status("Focus requested for the existing Document; switch to it if the browser did not focus it.", "success");
43
+ } else {
44
+ if (typeof result.url !== "string" || !result.url) throw Error("The retained editor URL is unavailable. Keep its identity and recheck.");
45
+ options.open(result.url, resumed);
46
+ options.status("Requested the same retained Document window. If the browser blocked it, use Show its window; no independent copy was created.", "success");
47
+ }
48
+ return true;
49
+ } catch (e) { if (current()) { options.observed?.(bufferId, { live: false }); options.status(e.message || "The Document owner could not be checked. Keep its identity and recheck.", "warning"); } return false; }
50
+ finally { if (current()) options.changed(); }
51
+ }
52
+
53
+ // The retained editor-request tracking. Also read, without writing, before a page may
54
+ // skip the resume question (Sol, candidate67).
55
+ export function createStudioDocumentLaunchIntent(storage, workspaceId) {
56
+ return createStudioDocumentCreationIntent(storage, "piStudio.bufferWorkspace.v2:" + workspaceId + ":launch", r => {
57
+ if (!r || Object.keys(r).some(k => !["operationId", "bufferId", "documentEpoch", "expectedRevision", "kind", "path", "sourcePath", "resourceDir", "exportId"].includes(k))
58
+ || !["blank", "file"].includes(r.kind) || !/^[a-f0-9]{48}$/.test(r.operationId || "") || !/^[a-f0-9]{48}$/.test(r.expectedRevision || "")
59
+ || !/^[a-zA-Z0-9_-]{1,128}$/.test(r.bufferId || "") || !Number.isSafeInteger(r.documentEpoch) || r.documentEpoch < 1
60
+ || ["path", "sourcePath", "resourceDir"].some(k => typeof r[k] !== "string" || r[k].length > 16384 || r[k].includes("\0"))
61
+ || (r.kind === "blank" ? r.path || r.sourcePath || r.resourceDir : !r.path)
62
+ || (r.exportId !== undefined && (typeof r.exportId !== "string" || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(r.exportId)))) throw Error("The retained editor request is invalid. Keep it for inspection; it was not replaced.");
63
+ return { ...r };
64
+ }, ["blank", "file"]);
65
+ }
66
+ // A verified creation is independent from opening, seen/live state and focus.
67
+ // Atomic retirement retains token-free destinations; uncertain replies retry v1/v2
68
+ // pending requests unchanged, without borrowing a newer source checkpoint.
69
+ export function createStudioDocumentLaunchController(options) {
70
+ let flight = null, closed = false, live = false, forgetting = false;
71
+ const observed = new Map(), ownerFlights = new Map();
72
+ const nonce = () => Array.from(crypto.getRandomValues(new Uint8Array(24)), b => b.toString(16).padStart(2, "0")).join("");
73
+ const intents = createStudioDocumentLaunchIntent(options.storage, options.workspaceId);
74
+ const peek = () => { const saved = intents.peek(); return { ...saved, live: saved.record ? false : live, busy: !!flight || !!ownerFlights.size,
75
+ destinations: saved.destinations.map(d => ({ ...d, live: observed.get(d.bufferId)?.generation === options.generation() && observed.get(d.bufferId)?.live === true })) }; };
76
+ const notify = (message, kind = "warning") => { if (!closed) { options.status(message, kind); options.changed(); } };
77
+ function show(destination, focus = true, presentation = null) {
78
+ if (closed || (presentation && !presentation.current()) || !destination || !intents.peek().destinations.some(d => d.operationId === destination.operationId && d.bufferId === destination.bufferId)) return Promise.resolve(false);
79
+ // A source-scoped presenter must not coalesce a fresh explicit Show behind
80
+ // an older export whose visible origin has since departed.
81
+ const key = presentation || (destination.bufferId + ":" + focus);
82
+ if (ownerFlights.has(key)) return ownerFlights.get(key);
83
+ // Order owner observations across both modes, without dropping or delaying
84
+ // explicit focus. Later revalidation/focus RPCs supersede older lookup replies.
85
+ let observation;
86
+ const beginObservation = () => { observation = { generation: options.generation(), live: false }; observed.set(destination.bufferId, observation); };
87
+ beginObservation();
88
+ const work = openStudioRetainedDocument({ ...options, ...(presentation ? { open: presentation.open } : {}),
89
+ current: () => !closed && (!presentation || presentation.current()),
90
+ request: body => { beginObservation(); return options.request(body); },
91
+ observed: (bufferId, result) => { if (observed.get(bufferId) !== observation) return; live = result.live === true; observation.live = live; },
92
+ }, destination.bufferId, focus).finally(() => { ownerFlights.delete(key); if (!closed) options.changed(); });
93
+ ownerFlights.set(key, work); return work;
94
+ }
95
+ function run(target, open = true, presentation = null) {
96
+ const current = () => !closed && (!presentation || presentation.current());
97
+ if (!current() || forgetting) return Promise.resolve(false);
98
+ if (flight) return flight;
99
+ flight = (async () => {
100
+ try {
101
+ const saved = intents.peek(); if (saved.error) throw Error(saved.error.message);
102
+ let record = saved.record;
103
+ if (record && target && ["kind", "path", "sourcePath", "resourceDir", "exportId"].some(k => record.request[k] !== target[k])) throw Error("A different editor request is still tracked. Recheck it or explicitly forget its tracking before opening another Document.");
104
+ if (!record) {
105
+ if (!open || !target || options.busy()) return false;
106
+ // A generated export already checkpointed before rendering. Its presenter
107
+ // carries that exact authority; a second checkpoint must not silently rebind
108
+ // older bytes to a newer source. Ordinary New/Open still prepares as before.
109
+ const retainedProof = target.exportId && presentation?.authority;
110
+ if (!retainedProof) await options.prepare();
111
+ if (!current()) return false;
112
+ const proof = retainedProof || options.authority(); if (!proof?.ok) throw Error(proof?.message || "Checkpoint this editor first.");
113
+ const stored = intents.capture({ ...target, operationId: nonce(), bufferId: proof.bufferId, documentEpoch: proof.documentEpoch, expectedRevision: proof.expectedRevision });
114
+ if (!stored.ok) throw Error(stored.message);
115
+ record = intents.peek().record;
116
+ } else { const checked = intents.current(); if (!checked.ok) throw Error(checked.message); }
117
+ if (!current()) return false;
118
+ const result = await options.request({ ...record.request, operation: "launch", generation: options.generation(), focus: false }, open);
119
+ if (closed) return false;
120
+ const completed = intents.complete(result); if (!completed.ok) throw Error(result.message || completed.message);
121
+ const destination = intents.peek().destinations.find(d => d.operationId === record.request.operationId);
122
+ presentation?.retained?.(destination ? Object.freeze({ ...destination }) : null);
123
+ options.changed();
124
+ // Creation proof/discovery is retained even if its original visible preview
125
+ // departed while the RPC was in flight. Cancel only automatic presentation.
126
+ if (!current()) return false;
127
+ if (open) return show(destination, true, presentation);
128
+ return show(destination, false);
129
+ } catch (e) { live = false; if (open) notify(e.message || "Editor outcome unknown. Keep tracking and recheck."); return false; }
130
+ })().finally(() => { flight = null; if (!closed) options.changed(); });
131
+ return flight;
132
+ }
133
+ const timer = setInterval(() => { if (!closed && !flight && !forgetting && options.generation() && intents.peek().record) void run(null, false); }, 1500);
134
+ return Object.freeze({
135
+ start: (target, presentation = null) => run({ kind: target.kind, path: target.path || "", sourcePath: target.sourcePath || "", resourceDir: target.resourceDir || "",
136
+ ...(target.exportId !== undefined ? { exportId: target.exportId } : {}) }, true, presentation),
137
+ recheck: () => intents.peek().record ? run(null, true) : show(intents.peek().destinations.at(-1)),
138
+ ready: async () => { if (intents.peek().record) await run(null, false); else await Promise.all(intents.peek().destinations.map(d => show(d, false))); },
139
+ openDestination: show, state: peek, busy: () => !!flight || !!ownerFlights.size,
140
+ needsUnloadConfirmation: () => Boolean(flight || forgetting || ownerFlights.size || peek().error || peek().record || peek().destinations.some(d => !d.live || !options.generation())),
141
+ async forget() {
142
+ // Oliver, 13 Oct: a click during an automatic recheck waits for it, then asks.
143
+ while (flight && !closed) await flight.catch(() => {});
144
+ if (closed || !intents.peek().record) return false; const before = intents.peek().record;
145
+ forgetting = true; let agreed;
146
+ try { agreed = await options.confirm("Forget editor request tracking?", "This does not delete any Document. An uncertain editor may become harder to find. Recheck first. Forget tracking to start another independent request?"); } finally { forgetting = false; }
147
+ if (!agreed || closed || flight || intents.peek().record !== before) return false;
148
+ const result = intents.clear(true); if (!result.ok) { notify(result.message); return false; }
149
+ live = false; notify("Tracking removed. Existing Documents and retained destinations were not deleted.", "success"); return true;
150
+ },
151
+ dispose() { closed = true; clearInterval(timer); }
152
+ });
153
+ }
@@ -0,0 +1,96 @@
1
+ // Same-workspace replacement is not a move or a new-tab launch. The buffer
2
+ // client owns its frozen slot and verified before-state; this controller owns
3
+ // transport flights, cancellation and the synchronous DOM-binding boundary.
4
+ export function createStudioDocumentOpenController(options) {
5
+ let closed = false, flight = null, flightKey = null, cancelFlight = null;
6
+ const nonce = options.makeOperationId ?? (() => Array.from(crypto.getRandomValues(new Uint8Array(24)), b => b.toString(16).padStart(2, "0")).join(""));
7
+ const client = () => options.client();
8
+ const pending = () => client()?.openBackup();
9
+ const notify = (message, severity = "warning") => { if (!closed) options.status?.(message, severity); };
10
+ const changed = () => { if (!closed) options.changed?.(); };
11
+ const fail = message => ({ ok: false, message });
12
+ // Refusals the server makes before recording anything: the open certainly didn't happen, so it
13
+ // is closed through the ordinary Cancel route and its reason shown, not kept as unknown (Oliver, 9 Oct).
14
+ const finalOpenRefusals = new Set(["too-large", "invalid-document"]);
15
+ async function deliver(owner, record, operation, settings = {}) {
16
+ const generation = options.generation();
17
+ const current = () => !closed && client() === owner && generation > 0 && generation === options.generation() && pending()?.record === record;
18
+ try {
19
+ if (!current()) return fail("Reconnect the owning editor before checking this open request.");
20
+ const result = await options.request({ ...record.request, operation, generation });
21
+ if (!current()) return fail("The opening editor changed; its backup was kept.");
22
+ if (!result?.ok && operation === "open" && finalOpenRefusals.has(result?.reason)) {
23
+ await deliver(owner, record, "open-cancel", { quiet: true });
24
+ notify(result.message || "That file can't be opened here. Current work was kept.");
25
+ return fail(result.message || "The file was not opened.");
26
+ }
27
+ if (!result?.ok) throw Error(result?.message || "The file-open outcome is unknown. Keep the backup and recheck.");
28
+ // Capture pre-debounce Prompt typing, or detect late affected DOM changes.
29
+ options.capture?.();
30
+ const applied = owner.finishOpen(result, () => current() && options.domCurrent(record));
31
+ if (!applied.ok) throw Error(applied.message);
32
+ if (result.status === "committed") options.replace?.(record.request.bufferId);
33
+ if (applied.selectedChanged) options.bind();
34
+ changed();
35
+ if (!settings.quiet) notify(result.status === "cancelled" ? "File opening cancelled. Current work was kept."
36
+ : result.status === "reused" ? "The file already has an editor. Current work was kept."
37
+ : "Opened the file in its existing buffer. Other work was kept; nothing was sent.", "success");
38
+ await owner.settled();
39
+ // Failure to release a server receipt cannot undo verified local adoption.
40
+ // Send no acknowledgements from an obsolete connection or disposed page.
41
+ if (!closed && client() === owner && generation === options.generation()) {
42
+ const proof = owner.hostingAuthority(record.request.bufferId);
43
+ if (proof.ok) try {
44
+ await options.request({ ...record.request, operation: "open-ack", generation,
45
+ acknowledgedRevision: proof.expectedRevision, acknowledgedEpoch: proof.documentEpoch });
46
+ } catch { /* The bounded server receipt remains retained. */ }
47
+ if (!closed && client() === owner && generation === options.generation() && result.status === "reused") await options.reuse(record.request, result.owner);
48
+ }
49
+ return { ...applied, bufferId: record.request.bufferId };
50
+ } catch (error) {
51
+ if (current()) notify((error?.message || "File opening could not be checked.") + " Keep this tab; use Recheck file opening or Cancel pending opening.");
52
+ return fail(error?.message || "Unknown file-open outcome.");
53
+ } finally { changed(); }
54
+ }
55
+ function tracked(run, key) {
56
+ flightKey = key;
57
+ flight = Promise.resolve().then(run).finally(() => { flight = null; flightKey = null; changed(); });
58
+ changed(); return flight;
59
+ }
60
+ return Object.freeze({
61
+ open(input, isCurrent = () => true) {
62
+ if (closed) return Promise.resolve(fail("This editor is closed."));
63
+ const key = JSON.stringify(input);
64
+ if (flight) return key === flightKey ? flight : Promise.resolve(fail("Resolve the current opening first."));
65
+ if (pending()?.record || pending()?.error || options.busy?.()) return Promise.resolve(fail("Resolve the retained Document operation first."));
66
+ const owner = client(), generation = options.generation();
67
+ return tracked(async () => {
68
+ try {
69
+ await options.prepare(input.bufferId, isCurrent);
70
+ if (closed || client() !== owner || generation !== options.generation() || !isCurrent()) return fail("The editor changed before opening. Current work was kept.");
71
+ const proof = owner.hostingAuthority(input.bufferId); if (!proof.ok) throw Error(proof.message);
72
+ const request = { operationId: nonce(), bufferId: input.bufferId, expectedRevision: proof.expectedRevision, documentEpoch: proof.documentEpoch,
73
+ path: input.path, sourcePath: input.sourcePath || "", resourceDir: input.resourceDir || "", discardTarget: true };
74
+ const begun = owner.beginOpen(request); if (!begun.ok) throw Error(begun.message);
75
+ changed(); return await deliver(owner, pending().record, "open");
76
+ } catch (error) { notify(error.message); return fail(error.message); }
77
+ }, key);
78
+ },
79
+ recheck() {
80
+ if (flight) return flight;
81
+ const owner = client(), record = pending()?.record;
82
+ if (closed || !record) return Promise.resolve(fail("No retained file opening to check."));
83
+ return tracked(() => deliver(owner, record, "open"), null);
84
+ },
85
+ cancel() {
86
+ if (cancelFlight) return cancelFlight;
87
+ const owner = client(), record = pending()?.record;
88
+ if (closed || !record) return Promise.resolve(fail("No retained file opening to cancel."));
89
+ cancelFlight = deliver(owner, record, "open-cancel").finally(() => { cancelFlight = null; changed(); });
90
+ return cancelFlight;
91
+ },
92
+ active: () => Boolean(flight || cancelFlight || pending()?.record || pending()?.error),
93
+ state: () => ({ ...pending(), busy: Boolean(flight), cancelling: Boolean(cancelFlight) }),
94
+ dispose() { closed = true; },
95
+ });
96
+ }
@@ -0,0 +1,21 @@
1
+ import { validateStudioBufferWorkspace } from "./studio-buffer-store.js";
2
+ // One noneditable pre-open workspace backup + immutable intent. No URLs/tokens,
3
+ // no filesystem grant. A timeout never clears it or authorizes a new request.
4
+ export function createStudioDocumentOpenStorage(storage, key, expected) {
5
+ let loaded=false,raw=null,record=null,error=null;
6
+ function normalize(value){
7
+ const r=value?.request,b=validateStudioBufferWorkspace(value?.before,expected);
8
+ if(value?.version!==1||Object.keys(value).some(k=>!['version','request','before'].includes(k))||!b.ok||!r
9
+ ||Object.keys(r).some(k=>!['operationId','bufferId','documentEpoch','expectedRevision','path','sourcePath','resourceDir','discardTarget'].includes(k))
10
+ ||typeof r.operationId!=='string'||!/^[a-f0-9]{48}$/.test(r.operationId)||typeof r.expectedRevision!=='string'||!/^[a-f0-9]{48}$/.test(r.expectedRevision)
11
+ ||!Number.isSafeInteger(r.documentEpoch)||r.documentEpoch<1||!b.state.buffers.some(n=>n.id===r.bufferId)||r.discardTarget!==true
12
+ ||['path','sourcePath','resourceDir'].some(k=>typeof r[k]!=='string'||r[k].length>16384||r[k].includes('\0'))||!r.path)throw Error('Invalid retained file-open record. Keep it for inspection.');
13
+ return Object.freeze({version:1,request:Object.freeze({...r}),before:b.state});
14
+ }
15
+ const failure=e=>({ok:false,reason:'open-storage',message:e.message||'File-open backup storage is unavailable.'});
16
+ function peek(){if(!loaded)try{raw=storage.getItem(key)||null;if(raw?.length>6100000)throw Error('Oversized file-open backup.');record=raw?normalize(JSON.parse(raw)):null;loaded=true;error=null;}catch(e){error=failure(e);}return {record,error};}
17
+ return Object.freeze({peek,
18
+ capture(value){try{peek();if(error)return error;if(record)throw Error('Resolve the existing file-open request first.');const next=normalize(value),encoded=JSON.stringify(next);if(encoded.length>6100000)throw Error('File-open backup exceeds its limit. Nothing was opened.');if((storage.getItem(key)||null)!==raw)throw Error('File-open tracking changed independently.');storage.setItem(key,encoded);if(storage.getItem(key)!==encoded)throw Error('File-open backup readback failed.');raw=encoded;record=next;return {ok:true,record};}catch(e){return failure(e);}},
19
+ clear(operationId){try{peek();if(error)return error;if(!record||record.request.operationId!==operationId)throw Error('The file-open backup changed.');if((storage.getItem(key)||null)!==raw)throw Error('File-open tracking changed independently.');storage.removeItem(key);if(storage.getItem(key))throw Error('File-open backup removal could not be verified.');raw=record=null;return {ok:true};}catch(e){return failure(e);}}
20
+ });
21
+ }
@@ -0,0 +1,29 @@
1
+ import { realpathSync, statSync } from "node:fs";
2
+ import { resolve, dirname, basename, join } from "node:path";
3
+
4
+ // Identity only, NOT file access or write authorization. Recovery text remains
5
+ // recoverable when its original file or ancestor directories no longer exist.
6
+ export function canonicalStudioDocumentPath(input, cwd = process.cwd()) {
7
+ if (typeof input !== "string" || !input.trim() || input.includes("\0")) throw new Error("Invalid Document path");
8
+ let candidate = resolve(cwd, input.trim());
9
+ const missing = [];
10
+ for (;;) {
11
+ try { return join(realpathSync(candidate), ...missing.reverse()); }
12
+ catch (error) {
13
+ if (!["ENOENT", "ENOTDIR"].includes(error.code)) throw error;
14
+ const parent = dirname(candidate); if (parent === candidate) throw error;
15
+ missing.push(basename(candidate)); candidate = parent;
16
+ }
17
+ }
18
+ }
19
+
20
+ // Detect existing hard-link aliases without keying ownership by inode: atomic
21
+ // saves replace inodes but keep the same logical filename. Never follow a
22
+ // retargeted captured canonical path to infer a new identity for an old draft.
23
+ export function studioDocumentPathsReferToSameFile(a, b) {
24
+ try {
25
+ if (canonicalStudioDocumentPath(a) !== a || canonicalStudioDocumentPath(b) !== b) return false;
26
+ const left = statSync(a), right = statSync(b);
27
+ return left.isFile() && right.isFile() && left.dev === right.dev && left.ino === right.ino;
28
+ } catch { return false; }
29
+ }