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,227 @@
1
+ // Browser-safe model only. Paths/revisions are claims, never filesystem grants or save authority.
2
+ export const STUDIO_BUFFER_LIMITS = Object.freeze({
3
+ buffers: 16,
4
+ textChars: 900_000,
5
+ totalTextChars: 3_000_000, // Includes baselines, not just visible editor text.
6
+ pendingOperations: 64,
7
+ });
8
+ const ID = /^[a-zA-Z0-9_-]{1,128}$/;
9
+ const WORKSPACE_ID = /^[a-zA-Z0-9_-]{20,128}$/;
10
+ const DISK_REVISION = /^sha256:[a-f0-9]{64}$/;
11
+ const BUFFER_KEYS = ["id", "role", "revision", "text", "baselineText", "sourceState", "diskRevision", "resourceDir", "view", "metadata"];
12
+ const PATCH_KEYS = BUFFER_KEYS.filter((key) => !["id", "role", "revision"].includes(key));
13
+
14
+ export class StudioBufferStateError extends Error {
15
+ constructor(reason, message) { super(message); this.name = "StudioBufferStateError"; this.reason = reason; }
16
+ }
17
+ function requireState(condition, reason, message) {
18
+ if (!condition) throw new StudioBufferStateError(reason, message);
19
+ }
20
+ function object(value, keys, label) {
21
+ requireState(value && typeof value === "object" && !Array.isArray(value), "invalid-state", `${label} must be an object.`);
22
+ requireState(Object.keys(value).every((key) => keys.includes(key)), "unknown-field", `${label} has unsupported fields; preserve the original recovery data.`);
23
+ }
24
+ function string(value, max, label, nullable = false) {
25
+ if (nullable && value === null) return null;
26
+ requireState(typeof value === "string", "invalid-state", `${label} must be text.`);
27
+ requireState(value.length <= max, "limit-exceeded", `${label} exceeds its limit; nothing was changed.`);
28
+ return value;
29
+ }
30
+ function integer(value, label, min = 0) {
31
+ requireState(Number.isSafeInteger(value) && value >= min, "invalid-state", `${label} must be a safe integer.`);
32
+ return value;
33
+ }
34
+ function scroll(value) {
35
+ requireState(typeof value === "number" && Number.isFinite(value) && value >= 0, "invalid-state", "Invalid scroll position.");
36
+ return value;
37
+ }
38
+ function freeze(value) {
39
+ if (value && typeof value === "object") { Object.values(value).forEach(freeze); Object.freeze(value); }
40
+ return value;
41
+ }
42
+ function failure(error) {
43
+ return { ok: false, reason: error.reason || "invalid-state", message: error instanceof StudioBufferStateError ? error.message : "Invalid buffer state; nothing was changed." };
44
+ }
45
+ function provenance(value) {
46
+ object(value, ["version", "kind", "responseId", "responseNumber", "annotated"], "Source provenance");
47
+ requireState(value.version === 1 && value.kind === "response" && typeof value.annotated === "boolean", "invalid-state", "Unsupported source provenance; keep the original recovery data.");
48
+ const responseId = string(value.responseId, 256, "Response identity", true);
49
+ requireState(responseId === null || (responseId.length > 0 && !responseId.includes("\0")), "invalid-state", "Invalid response identity.");
50
+ const responseNumber = value.responseNumber === null ? null : integer(value.responseNumber, "Response position", 1);
51
+ requireState(responseNumber === null || responseNumber <= 1_000_000_000, "limit-exceeded", "Response position exceeds its limit.");
52
+ return { version: 1, kind: "response", responseId, responseNumber, annotated: value.annotated };
53
+ }
54
+ function source(value) {
55
+ object(value, ["source", "label", "path", "draftId", "provenance"], "Source identity");
56
+ const path = string(value.path, 16_384, "Source path", true);
57
+ requireState(path === null || (path.length > 0 && !path.includes("\0")), "invalid-state", "Invalid source path.");
58
+ const draftId = string(value.draftId, 256, "Source draft ID", true);
59
+ requireState(!path || draftId === null, "invalid-state", "A file source cannot also have a draft identity.");
60
+ return { source: string(value.source, 100, "Source kind"), label: string(value.label, 4_000, "Source label"), path, draftId,
61
+ ...(Object.prototype.hasOwnProperty.call(value, "provenance") ? { provenance: provenance(value.provenance) } : {}) };
62
+ }
63
+ function buffer(value) {
64
+ object(value, BUFFER_KEYS, "Buffer");
65
+ requireState(typeof value.id === "string" && ID.test(value.id), "invalid-state", "Invalid buffer ID.");
66
+ requireState(["prompt", "document"].includes(value.role), "invalid-state", "Invalid buffer role.");
67
+ const text = string(value.text, STUDIO_BUFFER_LIMITS.textChars, "Buffer text");
68
+ const baselineText = string(value.baselineText, STUDIO_BUFFER_LIMITS.textChars, "Buffer baseline", true);
69
+ const sourceState = source(value.sourceState);
70
+ requireState(value.diskRevision === null || (typeof value.diskRevision === "string" && DISK_REVISION.test(value.diskRevision)), "invalid-state", "Invalid disk revision.");
71
+ requireState(sourceState.path !== null || value.diskRevision === null, "invalid-state", "Detached buffers cannot carry a disk revision.");
72
+ const v = value.view;
73
+ object(v, ["editorView", "rightView", "editorLanguage", "selectionStart", "selectionEnd", "selectionDirection", "scrollTop", "previewScrollTop", "rightScrollTop", "followLatest", "responseHistoryIndex"], "Buffer view");
74
+ const start = integer(v.selectionStart, "Selection start");
75
+ const end = integer(v.selectionEnd, "Selection end");
76
+ requireState(start <= end && end <= text.length, "invalid-state", "Selection lies outside the buffer.");
77
+ requireState(["none", "forward", "backward"].includes(v.selectionDirection) && typeof v.followLatest === "boolean", "invalid-state", "Invalid buffer view state.");
78
+ const m = value.metadata;
79
+ object(m, ["annotationsEnabled", "reviewNotesKey", "scratchpadKey"], "Buffer metadata");
80
+ requireState(m.annotationsEnabled === null || typeof m.annotationsEnabled === "boolean", "invalid-state", "Invalid annotation preference.");
81
+ return {
82
+ id: value.id, role: value.role, revision: integer(value.revision, "Buffer revision"), text, baselineText, sourceState,
83
+ diskRevision: value.diskRevision, resourceDir: string(value.resourceDir, 16_384, "Resource directory"),
84
+ view: {
85
+ editorView: string(v.editorView, 100, "Editor view"), rightView: string(v.rightView, 100, "Right view"),
86
+ editorLanguage: string(v.editorLanguage, 100, "Editor language"), selectionStart: start, selectionEnd: end,
87
+ selectionDirection: v.selectionDirection, scrollTop: scroll(v.scrollTop), previewScrollTop: scroll(v.previewScrollTop),
88
+ ...(Object.prototype.hasOwnProperty.call(v, "rightScrollTop") ? { rightScrollTop: scroll(v.rightScrollTop) } : {}),
89
+ followLatest: v.followLatest, responseHistoryIndex: integer(v.responseHistoryIndex, "Response position", -1),
90
+ },
91
+ metadata: { annotationsEnabled: m.annotationsEnabled, reviewNotesKey: string(m.reviewNotesKey, 24_000, "Review association", true), scratchpadKey: string(m.scratchpadKey, 24_000, "Scratchpad association", true) },
92
+ };
93
+ }
94
+
95
+ export function createStudioBuffer(options) {
96
+ object(options, BUFFER_KEYS, "New buffer");
97
+ const path = options.sourceState?.path ?? null;
98
+ return freeze(buffer({
99
+ id: options.id, role: options.role, revision: 0, text: "", baselineText: path ? null : "", diskRevision: null, resourceDir: "",
100
+ ...options,
101
+ sourceState: { source: "blank", label: options.role === "prompt" ? "Prompt" : "Untitled", path, draftId: path ? null : options.id, ...options.sourceState },
102
+ view: { editorView: "markdown", rightView: options.role === "prompt" ? "preview" : "editor-preview", editorLanguage: "markdown",
103
+ selectionStart: 0, selectionEnd: 0, selectionDirection: "none", scrollTop: 0, previewScrollTop: 0, followLatest: false, responseHistoryIndex: -1, ...options.view },
104
+ metadata: { annotationsEnabled: null, reviewNotesKey: null, scratchpadKey: null, ...options.metadata },
105
+ }));
106
+ }
107
+
108
+ // Strict codec boundary: no identity/text truncation, unknown-field dropping or partial recovery.
109
+ export function validateStudioBufferWorkspace(value, expected = {}) {
110
+ try {
111
+ requireState(value && value.version === 2, "unsupported-version", "Unsupported workspace schema; preserve the original recovery data.");
112
+ object(value, ["version", "workspaceId", "mode", "revision", "savedAt", "selectedBufferId", "activePromptId", "order", "buffers"], "Workspace");
113
+ requireState(typeof value.workspaceId === "string" && WORKSPACE_ID.test(value.workspaceId), "invalid-state", "Invalid workspace ID.");
114
+ requireState(["full", "editor-only"].includes(value.mode), "invalid-state", "Watched/read-only views do not own editable buffer stores.");
115
+ requireState((expected.workspaceId === undefined || expected.workspaceId === value.workspaceId) && (expected.mode === undefined || expected.mode === value.mode), "wrong-workspace", "Recovery belongs to a different workspace or mode.");
116
+ requireState(Array.isArray(value.buffers) && value.buffers.length >= 1 && value.buffers.length <= STUDIO_BUFFER_LIMITS.buffers, "limit-exceeded", "Workspace buffer count is outside its limit.");
117
+ const buffers = value.buffers.map(buffer);
118
+ const ids = new Set(buffers.map((entry) => entry.id));
119
+ requireState(ids.size === buffers.length, "invalid-state", "Duplicate buffer identity.");
120
+ requireState(Array.isArray(value.order) && value.order.length === ids.size && new Set(value.order).size === ids.size && value.order.every((id) => ids.has(id)), "invalid-state", "Invalid buffer ordering.");
121
+ requireState(ids.has(value.selectedBufferId), "invalid-state", "Selected buffer is missing.");
122
+ const active = buffers.find((entry) => entry.id === value.activePromptId);
123
+ requireState(value.mode === "full" ? active?.role === "prompt" : value.activePromptId === null && buffers.every((entry) => entry.role === "document"), "invalid-state", "Invalid active Prompt or companion role.");
124
+ const total = buffers.reduce((sum, entry) => sum + entry.text.length + (entry.baselineText?.length || 0), 0);
125
+ requireState(total <= STUDIO_BUFFER_LIMITS.totalTextChars, "limit-exceeded", "Workspace text and baselines exceed the aggregate limit; no buffers were evicted.");
126
+ return { ok: true, state: freeze({ version: 2, workspaceId: value.workspaceId, mode: value.mode,
127
+ revision: integer(value.revision, "Workspace revision"), savedAt: integer(value.savedAt, "Recovery timestamp"),
128
+ selectedBufferId: value.selectedBufferId, activePromptId: value.activePromptId, order: [...value.order], buffers }) };
129
+ } catch (error) { return failure(error); }
130
+ }
131
+
132
+ export function isStudioBufferDirty(entry) {
133
+ return entry.baselineText === null || entry.text !== entry.baselineText;
134
+ }
135
+
136
+ export function createStudioBufferStore(initialState, options = {}) {
137
+ const initial = validateStudioBufferWorkspace(initialState);
138
+ if (!initial.ok) throw new StudioBufferStateError(initial.reason, initial.message);
139
+ let state = initial.state;
140
+ const now = typeof options.now === "function" ? options.now : Date.now;
141
+ const pending = new Map(); // Page-memory only: never recovered, persisted or copied with a document.
142
+ const get = (id) => state.buffers.find((entry) => entry.id === id) || null;
143
+ const fail = (reason, message) => ({ ok: false, reason, message });
144
+ function commit(next) {
145
+ const candidate = validateStudioBufferWorkspace({ ...next, revision: state.revision + 1, savedAt: Math.max(state.savedAt + 1, now()) });
146
+ if (!candidate.ok) return candidate;
147
+ state = candidate.state;
148
+ return { ok: true };
149
+ }
150
+ function update(id, expectedRevision, patch) {
151
+ const current = get(id);
152
+ if (!current || current.revision !== expectedRevision) return fail("stale-buffer", "The originating buffer changed or closed; retry explicitly.");
153
+ try { object(patch, PATCH_KEYS, "Buffer update"); } catch (error) { return failure(error); }
154
+ const next = { ...current, ...patch, revision: current.revision + 1 };
155
+ if (patch.sourceState && patch.sourceState.path !== current.sourceState.path) {
156
+ // Never carry a previous file's baseline/revision to a different path by accident.
157
+ if (!("baselineText" in patch)) next.baselineText = null;
158
+ if (!("diskRevision" in patch)) next.diskRevision = null;
159
+ }
160
+ if (typeof next.text === "string" && !("view" in patch)) {
161
+ next.view = { ...next.view, selectionStart: Math.min(next.view.selectionStart, next.text.length), selectionEnd: Math.min(next.view.selectionEnd, next.text.length) };
162
+ }
163
+ return commit({ ...state, buffers: state.buffers.map((entry) => entry.id === id ? next : entry) });
164
+ }
165
+ function targetMatches(target) {
166
+ return Boolean(target && pending.get(target.requestId) === target && get(target.bufferId)?.revision === target.revision
167
+ && (!target.requireActivePrompt || state.activePromptId === target.bufferId));
168
+ }
169
+ return Object.freeze({
170
+ snapshot: () => state,
171
+ get,
172
+ update,
173
+ add(entry) {
174
+ if (get(entry?.id)) return fail("duplicate-buffer", "This buffer ID is already open.");
175
+ return commit({ ...state, buffers: [...state.buffers, entry], order: [...state.order, entry?.id] });
176
+ },
177
+ select(id) {
178
+ if (!get(id)) return fail("missing-buffer", "The selected buffer is not open.");
179
+ return id === state.selectedBufferId ? { ok: true } : commit({ ...state, selectedBufferId: id });
180
+ },
181
+ activatePrompt(id) {
182
+ if (state.mode !== "full" || get(id)?.role !== "prompt") return fail("not-a-prompt", "Only a full workspace Prompt can be activated.");
183
+ if (id === state.activePromptId) return { ok: true };
184
+ const result = commit({ ...state, activePromptId: id });
185
+ // Switching away and back must not revive a transfer aimed at an earlier active destination.
186
+ if (result.ok) for (const [requestId, target] of pending) if (target.requireActivePrompt) pending.delete(requestId);
187
+ return result;
188
+ },
189
+ close(target, { discard = false, replacementPromptId = null } = {}) {
190
+ if (!targetMatches(target)) {
191
+ if (target && pending.get(target.requestId) === target) pending.delete(target.requestId);
192
+ return fail("stale-operation", "The buffer changed, closed or reopened; confirm again.");
193
+ }
194
+ const id = target.bufferId;
195
+ const entry = get(id);
196
+ if (discard !== true && (isStudioBufferDirty(entry) || (entry.role === "prompt" && entry.text.length > 0))) return fail("confirmation-required", "Closing this buffer needs an explicit discard decision.");
197
+ const activePromptId = id === state.activePromptId ? replacementPromptId : state.activePromptId;
198
+ if (id === state.activePromptId && (!replacementPromptId || replacementPromptId === id || get(replacementPromptId)?.role !== "prompt")) return fail("replacement-prompt-required", "Choose the next active Prompt explicitly before closing this one.");
199
+ const order = state.order.filter((other) => other !== id);
200
+ const result = commit({ ...state, activePromptId, selectedBufferId: state.selectedBufferId === id ? activePromptId || order[0] : state.selectedBufferId,
201
+ order, buffers: state.buffers.filter((other) => other.id !== id) });
202
+ if (result.ok) for (const [requestId, target] of pending) if (target.bufferId === id) pending.delete(requestId);
203
+ return result;
204
+ },
205
+ beginOperation(bufferId, requestId, { requireActivePrompt = false } = {}) {
206
+ const entry = get(bufferId);
207
+ if (!entry || (requireActivePrompt && state.activePromptId !== bufferId)) return fail("missing-origin", "The requested originating buffer/Prompt is not available.");
208
+ if (typeof requestId !== "string" || !requestId.length || requestId.length > 256 || pending.has(requestId)) return fail("invalid-request", "Request IDs must be bounded and unique among pending operations.");
209
+ if (pending.size >= STUDIO_BUFFER_LIMITS.pendingOperations) return fail("limit-exceeded", "Too many pending buffer operations; nothing was evicted.");
210
+ const target = Object.freeze({ bufferId, revision: entry.revision, requestId, requireActivePrompt: Boolean(requireActivePrompt) });
211
+ pending.set(requestId, target);
212
+ return { ok: true, target };
213
+ },
214
+ isCurrent: targetMatches,
215
+ finishOperation(target, patch) {
216
+ const matches = targetMatches(target);
217
+ if (target && pending.get(target.requestId) === target) pending.delete(target.requestId);
218
+ if (!matches) return fail("stale-operation", "The originating operation changed, closed or completed; retry explicitly.");
219
+ return update(target.bufferId, target.revision, patch);
220
+ },
221
+ cancelOperation(target) {
222
+ return Boolean(target && pending.get(target.requestId) === target && pending.delete(target.requestId));
223
+ },
224
+ clearPendingOperations: () => pending.clear(),
225
+ get pendingOperationCount() { return pending.size; },
226
+ });
227
+ }
@@ -0,0 +1,53 @@
1
+ import { createStudioBuffer, validateStudioBufferWorkspace } from "./studio-buffer-store.js";
2
+
3
+ // Deliberately narrower than the store: no parked Prompts or general tab management.
4
+ export function isStudioPromptDocumentBufferState(state) {
5
+ return state?.mode === "full" && state.buffers.length <= 2
6
+ && state.buffers.filter(b => b.role === "prompt").length === 1
7
+ && state.buffers.filter(b => b.role === "document").length <= 1
8
+ && state.buffers.some(b => b.id === state.activePromptId && b.role === "prompt");
9
+ }
10
+
11
+ // This is only a reason to ASK about recovered v2, never permission to change its
12
+ // submission target. v1/fresh editor launches have explicit visible-editor intent.
13
+ export function needsStudioLegacyPromptChoice(state) {
14
+ if (!isStudioPromptDocumentBufferState(state) || state.selectedBufferId === state.activePromptId) return false;
15
+ const prompt = state.buffers.find(b => b.id === state.activePromptId);
16
+ return prompt.revision === 0 && prompt.text === "" && prompt.baselineText === ""
17
+ && prompt.sourceState.source === "blank" && prompt.sourceState.label === "Prompt"
18
+ && prompt.sourceState.path === null && prompt.sourceState.draftId === prompt.id
19
+ && prompt.diskRevision === null && prompt.resourceDir === ""
20
+ && prompt.metadata.annotationsEnabled === null && prompt.metadata.reviewNotesKey === null && prompt.metadata.scratchpadKey === null;
21
+ }
22
+
23
+ export function prepareStudioPromptDocumentWorkspace(value, { makeBufferId, roleChoice, now = Date.now } = {}) {
24
+ const checked = validateStudioBufferWorkspace(value);
25
+ if (!checked.ok) return checked;
26
+ const state = checked.state;
27
+ if (!isStudioPromptDocumentBufferState(state)) return { ok: false, reason: "unsupported-collection",
28
+ message: "Studio shows one Prompt and one Document. Other buffers were retained; export them or use a compatible build." };
29
+ let buffers = state.buffers, activePromptId = state.activePromptId;
30
+ if (roleChoice === "visible-prompt" && state.selectedBufferId !== activePromptId) {
31
+ if (!needsStudioLegacyPromptChoice(state)) return { ok: false, reason: "role-decision-required", message: "The existing Prompt cannot be replaced by a role migration. Both texts were kept." };
32
+ buffers = buffers.map(b => b.id === state.selectedBufferId
33
+ ? createStudioBuffer({ ...b, role: "prompt", revision: b.revision + 1 })
34
+ : createStudioBuffer({ ...b, role: "document", revision: b.revision + 1,
35
+ sourceState: { ...b.sourceState, label: "Document" }, view: { ...b.view, rightView: "editor-preview" } }));
36
+ activePromptId = state.selectedBufferId;
37
+ } else if (roleChoice === "keep-roles" && needsStudioLegacyPromptChoice(state)) {
38
+ // Explicitly chosen Prompt provenance, not an inferred association/grant.
39
+ // Remember the choice without a new recovery schema or a disposable sidecar.
40
+ buffers = buffers.map(b => b.id === activePromptId ? createStudioBuffer({ ...b, revision: b.revision + 1,
41
+ sourceState: { ...b.sourceState, source: "prompt" } }) : b);
42
+ }
43
+ if (!buffers.some(b => b.role === "document")) {
44
+ let document;
45
+ try { document = createStudioBuffer({ id: makeBufferId(), role: "document", sourceState: { label: "Document" }, resourceDir: buffers[0].resourceDir }); }
46
+ catch (error) { return { ok: false, reason: error.reason || "invalid-state", message: "The document buffer could not be created; existing text was kept." }; }
47
+ buffers = [...buffers, document];
48
+ }
49
+ if (buffers === state.buffers) return checked;
50
+ return validateStudioBufferWorkspace({ ...state, buffers, activePromptId,
51
+ order: [activePromptId, ...buffers.filter(b => b.id !== activePromptId).map(b => b.id)],
52
+ revision: state.revision + 1, savedAt: Math.max(state.savedAt + 1, now()) });
53
+ }
@@ -295,6 +295,17 @@ function throwStudioDiskConflict(conflict) {
295
295
  throw error;
296
296
  }
297
297
 
298
+ // Trusted, synchronous host authorization supplements (never replaces) filesystem
299
+ // and revision checks. Run before creating a temporary file and again immediately
300
+ // before commit, with the disk helper's actual canonical destination.
301
+ function authorizeStudioDiskCommit(options, path) {
302
+ if (typeof options?.authorizeCommit !== "function") return;
303
+ const result = options.authorizeCommit(path);
304
+ if (result?.ok !== true) throwStudioDiskConflict(result?.ok === false ? result : {
305
+ ok: false, reason: "authority-required", message: "Document ownership must be confirmed synchronously before saving.",
306
+ });
307
+ }
308
+
298
309
  function createStudioNewFileMetadata() {
299
310
  return { mode: 0o666 & ~process.umask() };
300
311
  }
@@ -375,6 +386,7 @@ export function saveStudioDiskFileIfRevision(options) {
375
386
  const targetExisted = target.exists;
376
387
  const metadata = targetExisted ? target : createStudioNewFileMetadata();
377
388
  try {
389
+ authorizeStudioDiskCommit(options, stableTargetPath);
378
390
  const written = writeStudioDiskFileAtomically(stableTargetPath, options?.content, metadata, {
379
391
  replace: targetExisted,
380
392
  beforeCommit: () => {
@@ -414,6 +426,7 @@ export function saveStudioDiskFileIfRevision(options) {
414
426
  "A new file appeared at this path while Studio was preparing to recreate it.",
415
427
  ));
416
428
  }
429
+ authorizeStudioDiskCommit(options, stableTargetPath);
417
430
  },
418
431
  });
419
432
  return { ok: true, ...written };
@@ -497,6 +510,7 @@ export function saveStudioDiskFileAs(options) {
497
510
  const targetExisted = target.exists;
498
511
  const metadata = targetExisted ? target : createStudioNewFileMetadata();
499
512
  try {
513
+ authorizeStudioDiskCommit(options, stableTargetPath);
500
514
  const written = writeStudioDiskFileAtomically(stableTargetPath, options?.content, metadata, {
501
515
  replace: targetExisted,
502
516
  beforeCommit: () => {
@@ -535,6 +549,7 @@ export function saveStudioDiskFileAs(options) {
535
549
  "A file appeared at this path while Studio was preparing the save.",
536
550
  ));
537
551
  }
552
+ authorizeStudioDiskCommit(options, stableTargetPath);
538
553
  },
539
554
  });
540
555
  return { ok: true, ...written };
@@ -0,0 +1,38 @@
1
+ // Hosting pages consume an already registered workspace or a retained read-only
2
+ // watched capability. Descriptive URL fields never authorize a file read.
3
+ const unavailable = message => ({ ok: false, message });
4
+ export function resolveStudioHostingPage({ enabled, requestUrl, mode, workspace, transient }) {
5
+ if (!enabled) return { ok: true, legacy: true };
6
+ const params = requestUrl.searchParams;
7
+ const hostedId = params.get("hostedWorkspace") || "", tabId = params.get("studioTabState") || "";
8
+ if (hostedId && tabId && hostedId !== tabId) return unavailable("This link looks damaged, perhaps by a line break when it was copied. Copy the full link from Pi again; nothing was changed.");
9
+ const id = hostedId || tabId;
10
+ const state = id ? workspace(id) : null;
11
+ if (state) {
12
+ const selected = state.buffers.find(buffer => buffer.id === state.selectedBufferId);
13
+ if (state.mode !== mode || !selected || params.get("watchFile") === "1" || params.get("watchedFile") === "1") {
14
+ return unavailable("This retained workspace cannot change its editing mode through a URL.");
15
+ }
16
+ return { ok: true, workspaceId: id, state,
17
+ document: { text: selected.text, label: selected.sourceState.label, source: "blank" } };
18
+ }
19
+ if (hostedId) return unavailable("This retained Document host is unavailable. Keep browser recovery copies; do not reload an independent disk copy.");
20
+ const docId = params.get("docId") || "", document = docId ? transient(docId) : null;
21
+ if (mode === "editor-only" && document?.watchFile === true && document.source === "file" && document.path) {
22
+ if (params.has("docPath") && params.get("docPath") !== document.path) return unavailable("The watched preview path does not match its retained authorization.");
23
+ return { ok: true, watched: true, document };
24
+ }
25
+ return unavailable("This editing URL is not registered in dual editor mode. Start with /studio, then use Open Document, New editor tab, Move or Copy in that workspace. Existing work was kept.");
26
+ }
27
+ export function studioHostingCommandError({ enabled, mode, replace = false, watchedText = false, current = false }) {
28
+ if (!enabled) return null;
29
+ if (current || replace) return "Terminal replacement is unavailable in dual editor mode. Use Open Document in the owning Studio workspace, or switch with /studio --single; current work was kept.";
30
+ if (mode === "editor-only" && !watchedText) return "This standalone command is unavailable in dual editor mode (/studio --single switches). Use New editor tab, Open file in a new editor, Move or Copy in Studio. Read-only watched text previews remain available.";
31
+ return null;
32
+ }
33
+ export function studioHostingLocalLinkError({ enabled, action, kind }) {
34
+ if (!enabled) return null;
35
+ if (kind === "office" && ["document", "editor-url"].includes(action)) return "Office conversion is unavailable in dual editor mode. Convert separately and explicitly import a Markdown copy; the original document was not changed.";
36
+ if (["editor-url", "preview-url"].includes(action)) return "This legacy standalone-tab route is unavailable in dual editor mode. Use the owning Studio workspace's opening controls or an inline preview.";
37
+ return null;
38
+ }
@@ -0,0 +1,147 @@
1
+ import { createStudioBuffer } from "./studio-buffer-store.js";
2
+ const fail = (reason, message) => ({ ok: false, reason, message });
3
+ const validMove = value => typeof value === "string" && /^[a-f0-9]{48}$/.test(value);
4
+
5
+ // One non-editable handoff backup per host. It never participates in ordinary
6
+ // workspace recovery or grants a file/metadata writer. No durable-storage claim.
7
+ // A copy retries the same bounded identity, never a fresh intent after a lost
8
+ // acknowledgement. Persist only identifiers/preconditions, not URLs or tokens.
9
+ // One verified storage write retires a pending request AND retains its destination.
10
+ // v1 pending records remain readable; v2 adds a bounded, token-free identity list.
11
+ // No silent eviction: at capacity new creation is refused before dispatch.
12
+ export function createStudioDocumentCreationIntent(storage, key, normalizeRequest, kinds) {
13
+ let loaded = false, raw = null, record = null, error = null, destinations = [];
14
+ const validId = value => typeof value === "string" && /^[a-zA-Z0-9_-]{1,128}$/.test(value);
15
+ const pending = request => Object.freeze({ version: 1, request: Object.freeze(normalizeRequest(request)) });
16
+ function destination(value) {
17
+ if (!value || Object.keys(value).some(k => !["operationId", "sourceBufferId", "workspaceId", "bufferId", "kind", "created"].includes(k))
18
+ || !validMove(value.operationId) || ![value.sourceBufferId, value.workspaceId, value.bufferId].every(validId)
19
+ || !kinds.includes(value.kind) || typeof value.created !== "boolean") throw Error("Invalid retained Document identity.");
20
+ return Object.freeze({ ...value });
21
+ }
22
+ function load() {
23
+ if (loaded) return;
24
+ try {
25
+ raw = storage.getItem(key) || null;
26
+ if (raw) {
27
+ if (raw.length > 60000) throw Error("Oversized Document tracking record.");
28
+ const value = JSON.parse(raw), version2 = value?.version === 2;
29
+ if (!value || ![1, 2].includes(value.version) || Object.keys(value).some(k => !(version2 ? ["version", "request", "destinations"] : ["version", "request"]).includes(k))) throw Error("Unknown Document tracking record.");
30
+ const list = version2 ? value.destinations : [];
31
+ if (!Array.isArray(list) || list.length > 32) throw Error("Invalid Document destination list.");
32
+ const restored = list.map(destination);
33
+ if (new Set(restored.map(d => d.operationId)).size !== restored.length) throw Error("Duplicate Document destination identity.");
34
+ record = version2 && value.request === null ? null : pending(value.request);
35
+ destinations = restored;
36
+ }
37
+ loaded = true; error = null;
38
+ } catch (e) { error = fail("tracking-unavailable", e.message || "Document tracking needs inspection. Its bytes were kept."); }
39
+ }
40
+ function current() {
41
+ load(); if (error) return error;
42
+ try { return (storage.getItem(key) || null) === raw ? { ok: true } : fail("storage-changed", "Document tracking changed independently. Its bytes were kept."); }
43
+ catch { return fail("storage-unavailable", "Document tracking could not be checked. Keep this page open."); }
44
+ }
45
+ function write(nextRecord, nextDestinations) {
46
+ const checked = current(); if (!checked.ok) return checked;
47
+ const encoded = nextRecord || nextDestinations.length ? JSON.stringify({ version: 2, request: nextRecord?.request ?? null, destinations: nextDestinations }) : null;
48
+ if (encoded?.length > 60000) return fail("capacity", "Document tracking is full. Existing identities were kept.");
49
+ try {
50
+ if (encoded) storage.setItem(key, encoded); else storage.removeItem(key);
51
+ if ((storage.getItem(key) || null) !== encoded) return fail("storage-unverified", "Document tracking could not be verified. Keep the original attempt and recheck.");
52
+ raw = encoded; record = nextRecord; destinations = nextDestinations; return { ok: true };
53
+ } catch { return fail("storage-unavailable", "Document tracking could not be stored. Keep the original attempt and recheck."); }
54
+ }
55
+ function remember(value, nextRecord = record) {
56
+ const checked = current(); if (!checked.ok) return checked;
57
+ let next; try { next = destination(value); } catch (e) { return fail("invalid-receipt", e.message); }
58
+ const existing = destinations.find(d => d.operationId === next.operationId);
59
+ if (existing && JSON.stringify(existing) !== JSON.stringify(next)) return fail("destination-changed", "This operation belongs to another Document identity. Nothing was replaced.");
60
+ if (!existing && destinations.length >= 32) return fail("capacity", "The retained Document list is full. Existing identities were kept.");
61
+ return write(nextRecord, existing ? destinations : [...destinations, next]);
62
+ }
63
+ return Object.freeze({
64
+ peek() { load(); return { record, error, destinations: destinations.map(d => ({ ...d })) }; },
65
+ current,
66
+ capture(request) {
67
+ const checked = current(); if (!checked.ok) return checked;
68
+ let next; try { next = pending(request); } catch (e) { return fail("invalid-request", e.message); }
69
+ if (record) return JSON.stringify(record) === JSON.stringify(next) ? { ok: true } : fail("creation-pending", "Recheck the existing attempt before starting another one.");
70
+ if (destinations.length >= 32) return fail("capacity", "The retained Document list is full. No new Document was requested.");
71
+ return write(next, destinations);
72
+ },
73
+ complete(result) {
74
+ const checked = current(); if (!checked.ok) return checked;
75
+ const request = record?.request, kind = request?.kind || "copy";
76
+ if (!request || result?.ok !== true || result.operationId !== request.operationId || typeof result.created !== "boolean" || (kind !== "file" && !result.created)) return fail("unverified-creation", "Creation was not verified. Keep and recheck the original attempt.");
77
+ let verified;
78
+ try { verified = destination({ operationId: request.operationId, sourceBufferId: request.bufferId, workspaceId: result.workspaceId, bufferId: result.bufferId, kind, created: result.created }); }
79
+ catch (e) { return fail("invalid-receipt", e.message); }
80
+ return { ...remember(verified, null), destination: { ...verified } };
81
+ },
82
+ remember: value => { load(); return remember(value, record); },
83
+ clear(confirmed) {
84
+ if (confirmed !== true) return fail("confirmation-required", "Confirm forgetting this attempt. Existing Documents will not be deleted.");
85
+ const checked = current(); if (!checked.ok) return checked;
86
+ return write(null, destinations);
87
+ },
88
+ });
89
+ }
90
+
91
+ export function createStudioDocumentCopyIntent(storage, key) {
92
+ return createStudioDocumentCreationIntent(storage, key, p => {
93
+ if (!p || Object.keys(p).some(k => !["operationId", "bufferId", "documentEpoch", "expectedRevision"].includes(k))
94
+ || !validMove(p.operationId) || !validMove(p.expectedRevision) || !/^[a-zA-Z0-9_-]{1,128}$/.test(p.bufferId || "")
95
+ || !Number.isSafeInteger(p.documentEpoch) || p.documentEpoch < 1) throw Error("Invalid copy intent.");
96
+ return { operationId: p.operationId, bufferId: p.bufferId, documentEpoch: p.documentEpoch, expectedRevision: p.expectedRevision };
97
+ }, ["copy", "move"]);
98
+ }
99
+
100
+ export function createStudioDocumentEscrow(storage, key) {
101
+ let loaded = false, record = null, raw = null, readable = true, stored = false, error = null;
102
+ function load() {
103
+ if (loaded) return; loaded = true;
104
+ try { raw = storage.getItem(key); }
105
+ catch { readable = false; return; }
106
+ if (!raw) return;
107
+ try {
108
+ if (raw.length > 6_100_000) throw Error("Oversized handoff backup");
109
+ const value = JSON.parse(raw);
110
+ if (value.version !== 1 || !validMove(value.moveId)) throw Error("Unknown handoff backup");
111
+ const document = createStudioBuffer(value.document);
112
+ if (document.role !== "document") throw Error("Not a Document backup");
113
+ record = { version: 1, moveId: value.moveId, document }; stored = true;
114
+ } catch { error = fail("escrow-invalid", "An existing handoff backup needs inspection. It was not replaced."); }
115
+ }
116
+ function persist() {
117
+ if (!readable || !record) return;
118
+ try {
119
+ if ((storage.getItem(key) || null) !== (raw || null)) { stored = false; return fail("storage-changed", "The handoff backup changed independently. Both copies were kept."); }
120
+ const next = JSON.stringify(record); storage.setItem(key, next);
121
+ if (storage.getItem(key) !== next) { stored = false; return fail("storage-unverified", "The handoff backup could not be verified. Keep this page open."); }
122
+ raw = next; stored = true;
123
+ } catch { stored = false; }
124
+ }
125
+ return Object.freeze({
126
+ capture(moveId, document) {
127
+ load(); if (error) return error;
128
+ if (!validMove(moveId) || document?.role !== "document") return fail("invalid-move", "A Document move identity is required.");
129
+ if (record && (record.moveId !== moveId || JSON.stringify(record.document) !== JSON.stringify(document))) {
130
+ return fail("handoff-pending", "The earlier document still has a handoff backup. Open its destination or explicitly discard that backup before moving another document.");
131
+ }
132
+ record ??= { version: 1, moveId, document: createStudioBuffer(document) };
133
+ const result = persist(); if (result) return result;
134
+ return { ok: true, stored };
135
+ },
136
+ peek() { load(); return { record, stored, error }; },
137
+ resolve(moveId) {
138
+ load(); if (!record || record.moveId !== moveId) return fail("wrong-handoff", "The handoff backup belongs to another move.");
139
+ try {
140
+ if (readable && (storage.getItem(key) || null) !== (raw || null)) return fail("storage-changed", "The handoff backup changed. It was not removed.");
141
+ if (readable && raw) { if (storage.removeItem) storage.removeItem(key); else storage.setItem(key, ""); }
142
+ } catch { return fail("storage-unavailable", "The resolved backup could not be removed; inspect it before starting another move."); }
143
+ record = null; raw = null; stored = false; return { ok: true };
144
+ },
145
+ retry() { load(); return persist(); },
146
+ });
147
+ }