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
package/index.ts CHANGED
@@ -4,7 +4,7 @@ import { type ModelThinkingLevel, type ThinkingLevel } from "@earendil-works/pi-
4
4
  import { completeSimple } from "@earendil-works/pi-ai/compat";
5
5
  import { Type } from "@sinclair/typebox";
6
6
  import { spawn, spawnSync } from "node:child_process";
7
- import { createHash, randomUUID } from "node:crypto";
7
+ import { createHash, randomUUID, randomBytes } from "node:crypto";
8
8
  import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs";
9
9
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
10
10
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
@@ -24,6 +24,7 @@ import {
24
24
  } from "./shared/studio-annotation-scanner.js";
25
25
  import { stripStudioMarkdownHtmlComments } from "./shared/studio-markdown-html-comments.js";
26
26
  import { createStudioFileWatcher } from "./shared/studio-file-watcher.js";
27
+ import { createStudioRunLifecycle } from "./shared/studio-run-lifecycle.js";
27
28
  import {
28
29
  readStudioDiskFileSnapshot,
29
30
  saveStudioDiskFileAs,
@@ -83,6 +84,13 @@ import {
83
84
  isValidStudioLaunchId,
84
85
  normalizeStudioPendingKind,
85
86
  } from "./shared/studio-tab-launcher.js";
87
+ import { buildStudioHostingBootstrapState, createStudioBufferServerStore, STUDIO_BUFFER_REQUEST_MAX_BYTES } from "./shared/studio-buffer-server.js";
88
+ import { createStudioTurns, readStudioEditorMode, resolveStudioEditorFlags, writeStudioEditorMode } from "./shared/studio-editor-mode.js";
89
+ import { canonicalStudioDocumentPath, studioDocumentPathsReferToSameFile } from "./shared/studio-document-paths.js";
90
+ import { resolveStudioHostingPage, studioHostingCommandError, studioHostingLocalLinkError } from "./shared/studio-document-entrypoints.js";
91
+ import { systemThemeLacksTrueColor } from "./shared/studio-system-theme.js";
92
+ import { buildStudioReadOnlyMediaUrl, buildStudioReadOnlyImagePage } from "./shared/studio-read-only-media.js";
93
+ import { createStudioMetadataWriteOrderTracker } from "./shared/studio-metadata-write-order.js";
86
94
  import {
87
95
  createStudioWorkspaceStateStore,
88
96
  isValidStudioTabStateId,
@@ -198,6 +206,37 @@ const STUDIO_SHOW_ME_HELPERS_URL = new URL("./client/studio-show-me-helpers.js",
198
206
  const STUDIO_SIDE_QUESTION_HELPERS_URL = new URL("./client/studio-side-question-helpers.js", import.meta.url);
199
207
  const STUDIO_EDITOR_DRAFT_HELPERS_URL = new URL("./client/studio-editor-draft-helpers.js", import.meta.url);
200
208
  const STUDIO_CLIENT_URL = new URL("./client/studio-client.js", import.meta.url);
209
+ // Dual editor mode (Prompt and Document) unless this Pi profile chose single editor mode.
210
+ // The development flags, when set, still decide. Changes only while Studio's server is
211
+ // stopped, so one server lifetime sees one mode.
212
+ const STUDIO_EDITOR_MODE_PATH = join(getAgentDir(), "pi-studio", "editor-mode.json");
213
+ let STUDIO_EDITOR_FLAGS = resolveStudioEditorFlags(process.env, readStudioEditorMode(STUDIO_EDITOR_MODE_PATH));
214
+ let STUDIO_BUFFER_RECOVERY_ENABLED = STUDIO_EDITOR_FLAGS.recovery;
215
+ let STUDIO_BUFFER_SWITCHING_ENABLED = STUDIO_EDITOR_FLAGS.switching;
216
+ let STUDIO_DOCUMENT_HOSTING_ENABLED = STUDIO_EDITOR_FLAGS.hosting;
217
+ function applyStudioEditorMode(mode: "single" | "dual") {
218
+ STUDIO_EDITOR_FLAGS = resolveStudioEditorFlags(process.env, mode);
219
+ STUDIO_BUFFER_RECOVERY_ENABLED = STUDIO_EDITOR_FLAGS.recovery;
220
+ STUDIO_BUFFER_SWITCHING_ENABLED = STUDIO_EDITOR_FLAGS.switching;
221
+ STUDIO_DOCUMENT_HOSTING_ENABLED = STUDIO_EDITOR_FLAGS.hosting;
222
+ }
223
+ const STUDIO_BUFFER_MODULE_URLS = new Map([
224
+ ["studio-buffer-store.js", new URL("./shared/studio-buffer-store.js", import.meta.url)],
225
+ ["studio-buffer-recovery.js", new URL("./shared/studio-buffer-recovery.js", import.meta.url)],
226
+ ["studio-buffer-client.js", new URL("./shared/studio-buffer-client.js", import.meta.url)],
227
+ ["studio-buffer-lineage.js", new URL("./shared/studio-buffer-lineage.js", import.meta.url)],
228
+ ["studio-document-escrow.js", new URL("./shared/studio-document-escrow.js", import.meta.url)],
229
+ ["studio-document-host-client.js", new URL("./shared/studio-document-host-client.js", import.meta.url)],
230
+ ["studio-document-launch-client.js", new URL("./shared/studio-document-launch-client.js", import.meta.url)],
231
+ ["studio-document-open-client.js", new URL("./shared/studio-document-open-client.js", import.meta.url)],
232
+ ["studio-document-open-storage.js", new URL("./shared/studio-document-open-storage.js", import.meta.url)],
233
+ ["studio-linked-reader.js", new URL("./shared/studio-linked-reader.js", import.meta.url)],
234
+ ["studio-linked-reader-ui.js", new URL("./shared/studio-linked-reader-ui.js", import.meta.url)],
235
+ ["studio-document-hosting.js", new URL("./shared/studio-document-hosting.js", import.meta.url)],
236
+ ["studio-buffer-switching.js", new URL("./shared/studio-buffer-switching.js", import.meta.url)],
237
+ ["studio-buffer-decisions.js", new URL("./shared/studio-buffer-decisions.js", import.meta.url)],
238
+ ["studio-buffer-recovery-panel.js", new URL("./shared/studio-buffer-recovery-panel.js", import.meta.url)],
239
+ ]);
201
240
 
202
241
  interface StudioServerState {
203
242
  server: Server;
@@ -256,6 +295,7 @@ interface StudioTraceSnapshotSummary {
256
295
 
257
296
  interface StudioResponseHistoryItem extends StudioPromptDescriptor {
258
297
  id: string;
298
+ studioRunId?: string | null;
259
299
  markdown: string;
260
300
  thinking: string | null;
261
301
  timestamp: number;
@@ -339,6 +379,7 @@ interface PreparedStudioHtmlExport {
339
379
  filePath?: string;
340
380
  tempDirPath?: string;
341
381
  persistent?: boolean;
382
+ documentExport?: { workspaceId: string; bufferId: string; documentEpoch: number; expectedRevision: string; resourceDir: string; diskRevision: string | null };
342
383
  }
343
384
 
344
385
  interface StudioHtmlAnnotationPlaceholder {
@@ -401,6 +442,7 @@ interface StudioLaunchSelection {
401
442
  interface StudioUrlOptions {
402
443
  skipWorkspaceRestore?: boolean;
403
444
  paneFocus?: "left" | "right";
445
+ hostedWorkspaceId?: string;
404
446
  }
405
447
 
406
448
  type PersistedStudioReviewNoteAnchorKind = "source" | "html-selection" | "html-element" | "html-page";
@@ -435,7 +477,8 @@ interface StudioPersistentState {
435
477
  reviewNotesByDocument: Record<string, PersistedStudioReviewNote[]>;
436
478
  }
437
479
 
438
- type StudioTraceRunStatus = "idle" | "running" | "complete";
480
+ type StudioTraceRunStatus = "idle" | "running" | "complete" | "stopped" | "error";
481
+ type StudioRunOutcome = "completed" | "stopped" | "failed";
439
482
  type StudioTraceEntryStatus = "streaming" | "pending" | "complete" | "error";
440
483
 
441
484
  interface StudioTraceAssistantEntry {
@@ -901,6 +944,7 @@ interface OpenEditorOnlyRequestMessage {
901
944
  interface CancelRequestMessage {
902
945
  type: "cancel_request";
903
946
  requestId: string;
947
+ runId?: string;
904
948
  }
905
949
 
906
950
  interface WorkspaceStateUpdateMessage {
@@ -909,6 +953,20 @@ interface WorkspaceStateUpdateMessage {
909
953
  state: ReturnType<typeof normalizeStudioWorkspaceRecoveryState>;
910
954
  }
911
955
 
956
+ interface HostingCleanLeaveMessage {
957
+ type: "hosting_clean_leave";
958
+ generation: number;
959
+ revision: string;
960
+ }
961
+
962
+ interface EditorModeSwitchRequestMessage {
963
+ type: "editor_mode_switch_request";
964
+ requestId: string;
965
+ mode: "single" | "dual";
966
+ text: string;
967
+ sideQuestions: boolean;
968
+ }
969
+
912
970
  type IncomingStudioMessage =
913
971
  | HelloMessage
914
972
  | PingMessage
@@ -955,10 +1013,13 @@ type IncomingStudioMessage =
955
1013
  | GitChangesRequestMessage
956
1014
  | OpenEditorOnlyRequestMessage
957
1015
  | CancelRequestMessage
958
- | WorkspaceStateUpdateMessage;
1016
+ | WorkspaceStateUpdateMessage
1017
+ | HostingCleanLeaveMessage
1018
+ | EditorModeSwitchRequestMessage;
959
1019
 
960
1020
  const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
961
1021
  const PREVIEW_RENDER_MAX_CHARS = 400_000;
1022
+ const STUDIO_EDITOR_MODE_CARRY_MAX_CHARS = 4_000_000;
962
1023
  const STUDIO_WATCHED_FILE_MAX_CLIENTS = 16;
963
1024
  const STUDIO_COMPLETION_MAX_TEXT_CHARS = 250_000;
964
1025
  const STUDIO_COMPLETION_MAX_CONTEXT_CHARS = 12_000;
@@ -1301,7 +1362,12 @@ $body$
1301
1362
  `;
1302
1363
 
1303
1364
  let studioPersistentStateCache: StudioPersistentState | null = null;
1365
+ let studioPersistentStateRevision: string | null = null;
1366
+ let studioPersistentStateReadFailed = false;
1304
1367
  let studioPersistentStateQueue: Promise<void> = Promise.resolve();
1368
+ const studioMetadataWriteOrder = createStudioMetadataWriteOrderTracker();
1369
+ type StudioMetadataWriteOwnership = { writerId: string; writeVersion: number };
1370
+ type StudioMetadataWriteOrderClaim = { commit(): void };
1305
1371
  let transientStudioDocuments: Map<string, { document: InitialStudioDocument; createdAt: number }> = new Map();
1306
1372
  let studioReplJournalEntries: StudioReplJournalEntry[] = [];
1307
1373
  const studioReplUnsyncedJournalEntryIds = new Set<string>();
@@ -1410,14 +1476,31 @@ function normalizeStudioPersistentState(value: unknown): StudioPersistentState {
1410
1476
  };
1411
1477
  }
1412
1478
 
1479
+ // Adding default fields is harmless; dropping unknown fields, records, or
1480
+ // changing supplied values is not an authorized metadata transfer/migration.
1481
+ function studioMetadataNormalizationPreserves(value: unknown, normalized: unknown): boolean {
1482
+ if (Array.isArray(value)) return Array.isArray(normalized) && value.length === normalized.length
1483
+ && value.every((item, index) => studioMetadataNormalizationPreserves(item, normalized[index]));
1484
+ if (value && typeof value === "object") return Boolean(normalized && typeof normalized === "object" && !Array.isArray(normalized)
1485
+ && Object.entries(value).every(([key, item]) => Object.prototype.hasOwnProperty.call(normalized, key)
1486
+ && studioMetadataNormalizationPreserves(item, (normalized as Record<string, unknown>)[key])));
1487
+ return Object.is(value, normalized);
1488
+ }
1489
+
1413
1490
  async function loadStudioPersistentState(): Promise<StudioPersistentState> {
1414
1491
  if (studioPersistentStateCache) return studioPersistentStateCache;
1415
1492
  try {
1416
1493
  const raw = await readFile(STUDIO_PERSISTENT_STATE_PATH, "utf-8");
1417
- studioPersistentStateCache = normalizeStudioPersistentState(JSON.parse(raw));
1494
+ const parsed: unknown = JSON.parse(raw);
1495
+ studioPersistentStateCache = normalizeStudioPersistentState(parsed);
1496
+ if (!parsed || typeof parsed !== "object" || (parsed as { version?: unknown }).version !== 2
1497
+ || !studioMetadataNormalizationPreserves(parsed, studioPersistentStateCache)) studioPersistentStateReadFailed = true;
1498
+ studioPersistentStateRevision = "sha256:" + createHash("sha256").update(raw).digest("hex");
1418
1499
  } catch (error) {
1419
1500
  if (!(error && typeof error === "object" && "code" in error && (error as { code?: unknown }).code === "ENOENT")) {
1420
- // Ignore parse/read errors and fall back to a fresh local state blob.
1501
+ // Legacy callers retain their fallback; hosted transfer must not overwrite
1502
+ // an unreadable blob using that empty fallback as authoritative metadata.
1503
+ studioPersistentStateReadFailed = true;
1421
1504
  }
1422
1505
  studioPersistentStateCache = createEmptyStudioPersistentState();
1423
1506
  }
@@ -1426,15 +1509,63 @@ async function loadStudioPersistentState(): Promise<StudioPersistentState> {
1426
1509
 
1427
1510
  async function saveStudioPersistentState(state: StudioPersistentState): Promise<void> {
1428
1511
  await mkdir(STUDIO_PERSISTENT_STATE_DIR, { recursive: true });
1429
- await writeFile(STUDIO_PERSISTENT_STATE_PATH, `${JSON.stringify(state, null, 2)}\n`, "utf-8");
1512
+ const content = `${JSON.stringify(state, null, 2)}\n`;
1513
+ await writeFile(STUDIO_PERSISTENT_STATE_PATH, content, "utf-8");
1514
+ studioPersistentStateRevision = "sha256:" + createHash("sha256").update(content).digest("hex");
1515
+ studioPersistentStateCache = state;
1516
+ }
1517
+
1518
+ async function saveStudioTransferredMetadataAtomically(state: StudioPersistentState): Promise<void> {
1519
+ if (studioPersistentStateReadFailed) throw new Error("Existing metadata could not be read; transfer refused.");
1520
+ await mkdir(STUDIO_PERSISTENT_STATE_DIR, { recursive: true });
1521
+ const content = `${JSON.stringify(state, null, 2)}\n`;
1522
+ const saved = saveStudioDiskFileAs({ path: STUDIO_PERSISTENT_STATE_PATH, content, overwrite: true, expectedRevision: studioPersistentStateRevision });
1523
+ if (!saved.ok) throw new Error(saved.message || "Metadata changed or could not be atomically saved.");
1524
+ studioPersistentStateRevision = "sha256:" + createHash("sha256").update(content).digest("hex");
1430
1525
  studioPersistentStateCache = state;
1431
1526
  }
1432
1527
 
1433
- async function mutateStudioPersistentState(mutator: (state: StudioPersistentState) => void): Promise<void> {
1528
+ type StudioSavedMetadataStep = { persist?: boolean; result: { ok: boolean; [key: string]: unknown }; committed?: () => void };
1529
+ async function runStudioSavedMetadataTransaction(resolveMetadata: (state: StudioPersistentState) => StudioSavedMetadataStep) {
1530
+ let step: StudioSavedMetadataStep | undefined;
1531
+ await mutateStudioPersistentState(state => {
1532
+ if (studioPersistentStateReadFailed) throw new Error("Existing metadata could not be read; transfer refused.");
1533
+ let diskRevision: string | null = null;
1534
+ try { diskRevision = readStudioDiskFileSnapshot(STUDIO_PERSISTENT_STATE_PATH).revision; }
1535
+ catch (error) { if (!(error && typeof error === "object" && "code" in error && error.code === "ENOENT")) throw error; }
1536
+ if (diskRevision !== studioPersistentStateRevision) throw new Error("Metadata changed on disk; keep both copies and recheck before transfer.");
1537
+ step = resolveMetadata(state);
1538
+ if (!step.persist) { step.committed?.(); return false; }
1539
+ return step.committed;
1540
+ }, saveStudioTransferredMetadataAtomically);
1541
+ if (!step) throw new Error("Metadata transaction was not evaluated.");
1542
+ return step.result;
1543
+ }
1544
+
1545
+ function parseStudioMetadataWriteOwnership(value: unknown): { ok: true; ownership: StudioMetadataWriteOwnership | null } | { ok: false } {
1546
+ if (!value || typeof value !== "object") return { ok: true, ownership: null };
1547
+ const candidate = value as { metadataWriterId?: unknown; metadataWriteVersion?: unknown };
1548
+ const hasWriter = Object.prototype.hasOwnProperty.call(candidate, "metadataWriterId");
1549
+ const hasVersion = Object.prototype.hasOwnProperty.call(candidate, "metadataWriteVersion");
1550
+ if (!hasWriter && !hasVersion) return { ok: true, ownership: null };
1551
+ if (!hasWriter || !hasVersion || typeof candidate.metadataWriterId !== "string"
1552
+ || !isValidStudioTabStateId(candidate.metadataWriterId)
1553
+ || !Number.isSafeInteger(candidate.metadataWriteVersion)
1554
+ || Number(candidate.metadataWriteVersion) <= 0) return { ok: false };
1555
+ return { ok: true, ownership: { writerId: candidate.metadataWriterId, writeVersion: Number(candidate.metadataWriteVersion) } };
1556
+ }
1557
+
1558
+ function prepareStudioMetadataWrite(kind: "scratchpad" | "review-notes", documentKey: string, ownership: StudioMetadataWriteOwnership | null): StudioMetadataWriteOrderClaim | null {
1559
+ return studioMetadataWriteOrder.prepare(kind, documentKey, ownership);
1560
+ }
1561
+
1562
+ async function mutateStudioPersistentState(mutator: (state: StudioPersistentState) => void | false | (() => void), persist = saveStudioPersistentState): Promise<void> {
1434
1563
  const run = studioPersistentStateQueue.catch(() => undefined).then(async () => {
1435
1564
  const state = normalizeStudioPersistentState(await loadStudioPersistentState());
1436
- mutator(state);
1437
- await saveStudioPersistentState(state);
1565
+ const onPersisted = mutator(state);
1566
+ if (onPersisted === false) return;
1567
+ await persist(state);
1568
+ if (typeof onPersisted === "function") onPersisted();
1438
1569
  });
1439
1570
  studioPersistentStateQueue = run.then(() => undefined, () => undefined);
1440
1571
  await run;
@@ -1476,15 +1607,19 @@ async function listRecentPersistedStudioScratchpads(limit = 20): Promise<Array<{
1476
1607
  .slice(0, Math.max(1, Math.min(100, Math.floor(limit) || 20)));
1477
1608
  }
1478
1609
 
1479
- async function writePersistedStudioScratchpadText(documentKey: string, text: string, label?: string): Promise<void> {
1610
+ async function writePersistedStudioScratchpadText(documentKey: string, text: string, label?: string, ownership: StudioMetadataWriteOwnership | null = null, authorize?: () => void): Promise<void> {
1480
1611
  const key = String(documentKey ?? "").trim();
1481
1612
  if (!key) return;
1482
1613
  await mutateStudioPersistentState((state) => {
1614
+ authorize?.();
1615
+ if (authorize && studioPersistentStateReadFailed) throw new Error("Existing metadata could not be read; keep the unsynced browser copy.");
1616
+ const orderClaim = prepareStudioMetadataWrite("scratchpad", key, ownership);
1617
+ if (!orderClaim) return false;
1483
1618
  const normalized = String(text ?? "");
1484
1619
  if (normalized.length === 0) {
1485
1620
  delete state.scratchpadsByDocument[key];
1486
1621
  delete state.scratchpadMetadataByDocument[key];
1487
- return;
1622
+ return () => orderClaim.commit();
1488
1623
  }
1489
1624
  state.scratchpadsByDocument[key] = normalized;
1490
1625
  state.scratchpadMetadataByDocument[key] = {
@@ -1492,6 +1627,7 @@ async function writePersistedStudioScratchpadText(documentKey: string, text: str
1492
1627
  label: typeof label === "string" && label.trim() ? label.trim() : state.scratchpadMetadataByDocument[key]?.label,
1493
1628
  updatedAt: Date.now(),
1494
1629
  };
1630
+ return () => orderClaim.commit();
1495
1631
  });
1496
1632
  }
1497
1633
 
@@ -1507,7 +1643,7 @@ async function readPersistedStudioReviewNotes(documentKey: string): Promise<Pers
1507
1643
  return Array.isArray(notes) ? clonePersistedStudioReviewNotes(notes) : [];
1508
1644
  }
1509
1645
 
1510
- async function writePersistedStudioReviewNotes(documentKey: string, notes: PersistedStudioReviewNote[]): Promise<void> {
1646
+ async function writePersistedStudioReviewNotes(documentKey: string, notes: PersistedStudioReviewNote[], ownership: StudioMetadataWriteOwnership | null = null, authorize?: () => void): Promise<void> {
1511
1647
  const key = String(documentKey ?? "").trim();
1512
1648
  if (!key) return;
1513
1649
  const normalizedNotes = Array.isArray(notes)
@@ -1516,11 +1652,16 @@ async function writePersistedStudioReviewNotes(documentKey: string, notes: Persi
1516
1652
  .filter((note): note is PersistedStudioReviewNote => Boolean(note))
1517
1653
  : [];
1518
1654
  await mutateStudioPersistentState((state) => {
1655
+ authorize?.();
1656
+ if (authorize && studioPersistentStateReadFailed) throw new Error("Existing metadata could not be read; keep the unsynced browser copy.");
1657
+ const orderClaim = prepareStudioMetadataWrite("review-notes", key, ownership);
1658
+ if (!orderClaim) return false;
1519
1659
  if (normalizedNotes.length === 0) {
1520
1660
  delete state.reviewNotesByDocument[key];
1521
- return;
1661
+ return () => orderClaim.commit();
1522
1662
  }
1523
1663
  state.reviewNotesByDocument[key] = clonePersistedStudioReviewNotes(normalizedNotes);
1664
+ return () => orderClaim.commit();
1524
1665
  });
1525
1666
  }
1526
1667
 
@@ -2362,7 +2503,7 @@ function getStudioThemeStyle(theme?: Theme): StudioThemeStyle {
2362
2503
  const mode = getStudioThemeMode(theme);
2363
2504
  const fallback = mode === "light" ? LIGHT_STUDIO_PALETTE : DARK_STUDIO_PALETTE;
2364
2505
 
2365
- if (!theme) {
2506
+ if (!theme || systemThemeLacksTrueColor(theme)) {
2366
2507
  return {
2367
2508
  mode,
2368
2509
  palette: fallback,
@@ -2999,11 +3140,69 @@ function writeStudioPreviewExportFile(path: string | null, data: Buffer): { file
2999
3140
  }
3000
3141
  }
3001
3142
 
3002
- function writeStudioFile(pathArg: string, cwd: string, content: string, overwrite = false, expectedRevision?: string):
3143
+ // Classic HTML still replaces unowned files, but in hosting mode it uses the
3144
+ // existing canonical/revision-checked atomic writer and a final owner/claim fence.
3145
+ // This helper is never used by flags-off writers or by PDF export.
3146
+ function writeStudioClassicHtmlExportFile(
3147
+ path: string | null, data: Buffer, cwd: string,
3148
+ authorizeCommit: ((path: string) => { ok: boolean; message?: string }) | undefined,
3149
+ createParents = false,
3150
+ ): { filePath: string | null; error: string | null } {
3151
+ if (!path || path !== path.trim() || typeof authorizeCommit !== "function") {
3152
+ return { filePath: null, error: "An exact HTML target and synchronous Document ownership check are required." };
3153
+ }
3154
+ try {
3155
+ const requestedPath = resolve(cwd, path);
3156
+ const check = (canonical: string) => {
3157
+ const result = authorizeCommit(canonical);
3158
+ return result?.ok === true ? { ok: true } : { ok: false, message: result?.message || "The HTML export target's Document ownership could not be verified; nothing was written." };
3159
+ };
3160
+ const initial = check(requestedPath);
3161
+ if (!initial.ok) return { filePath: null, error: initial.message || "Document ownership could not be verified." };
3162
+ if (createParents) mkdirSync(dirname(requestedPath), { recursive: true });
3163
+ let expectedRevision: string | undefined;
3164
+ try { expectedRevision = readStudioDiskFileSnapshot(requestedPath).revision; }
3165
+ catch (error) { if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") throw error; }
3166
+ const result = saveStudioDiskFileAs({ path: requestedPath, cwd, content: data, overwrite: true, expectedRevision, authorizeCommit: check });
3167
+ return result.ok === true ? { filePath: result.path, error: null }
3168
+ : { filePath: null, error: result.message || "HTML project writing was refused; existing work was kept." };
3169
+ } catch (error) {
3170
+ return { filePath: null, error: error instanceof Error ? error.message : String(error) };
3171
+ }
3172
+ }
3173
+
3174
+ // Hosted HTML-to-Document exports must never overwrite an existing target.
3175
+ // The registered bridge supplies a synchronous final owner/claim check; absent
3176
+ // or ambiguous authority keeps the generated bytes unsaved instead of guessing.
3177
+ function writeStudioHostedHtmlDocumentExport(
3178
+ path: string | null,
3179
+ data: Buffer,
3180
+ cwd: string,
3181
+ authorizeCommit: (path: string) => { ok: boolean; message?: string },
3182
+ ): { filePath: string | null; diskRevision: string | null; error: string | null } {
3183
+ if (!path || typeof authorizeCommit !== "function") {
3184
+ return { filePath: null, diskRevision: null, error: "A resolved export target and current Document authority are required." };
3185
+ }
3186
+ try {
3187
+ const result = saveStudioDiskFileAs({
3188
+ path, cwd, content: data, overwrite: false,
3189
+ authorizeCommit: (canonical: string) => {
3190
+ const authority = authorizeCommit(canonical);
3191
+ return authority?.ok === true ? { ok: true } : { ok: false, message: authority?.message || "The export target's Document authority could not be verified." };
3192
+ },
3193
+ });
3194
+ if (result.ok === false) return { filePath: null, diskRevision: null, error: result.message || "Project writing failed; generated HTML was kept." };
3195
+ return { filePath: result.path, diskRevision: result.revision, error: null };
3196
+ } catch (error) {
3197
+ return { filePath: null, diskRevision: null, error: error instanceof Error ? error.message : String(error) };
3198
+ }
3199
+ }
3200
+
3201
+ function writeStudioFile(pathArg: string, cwd: string, content: string, overwrite = false, expectedRevision?: string, authorizeCommit?: (path: string) => { ok: boolean; message?: string }):
3003
3202
  | { ok: true; label: string; resolvedPath: string; diskRevision: string }
3004
3203
  | { ok: false; conflict?: boolean; reason?: string; path?: string; currentRevision?: string | null; message: string } {
3005
3204
  const normalizedLabel = normalizePathInput(pathArg);
3006
- const result = saveStudioDiskFileAs({ path: pathArg, cwd, content, overwrite, expectedRevision }) as StudioDiskWriteResult;
3205
+ const result = saveStudioDiskFileAs({ path: pathArg, cwd, content, overwrite, expectedRevision, authorizeCommit }) as StudioDiskWriteResult;
3007
3206
  if (result.ok === false) return result;
3008
3207
  return {
3009
3208
  ok: true,
@@ -3243,6 +3442,7 @@ const STUDIO_HTML_PREVIEW_MEDIA_MIME_BY_EXT = new Map<string, string>([
3243
3442
  [".jpeg", "image/jpeg"],
3244
3443
  [".gif", "image/gif"],
3245
3444
  [".webp", "image/webp"],
3445
+ [".svg", "image/svg+xml"],
3246
3446
  [".pdf", "application/pdf"],
3247
3447
  ]);
3248
3448
  const STUDIO_LOCAL_LINK_TEXT_EXTENSIONS = new Set([
@@ -3253,7 +3453,7 @@ const STUDIO_LOCAL_LINK_TEXT_EXTENSIONS = new Set([
3253
3453
  ".jl", ".f90", ".f95", ".f03", ".f", ".for", ".r", ".m", ".java", ".go", ".rb", ".swift", ".lua",
3254
3454
  ".diff", ".patch",
3255
3455
  ]);
3256
- const STUDIO_LOCAL_LINK_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
3456
+ const STUDIO_LOCAL_LINK_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]);
3257
3457
  const STUDIO_LOCAL_LINK_OFFICE_EXTENSIONS = new Set([".docx", ".odt"]);
3258
3458
  const STUDIO_LOCAL_LINK_TEXT_FILENAMES = new Set([
3259
3459
  ".dockerignore", ".editorconfig", ".env", ".env.example", ".eslintignore", ".gitattributes",
@@ -7972,6 +8172,7 @@ function respondPdfFile(req: IncomingMessage, res: ServerResponse, filePath: str
7972
8172
  const commonHeaders = {
7973
8173
  "Content-Type": "application/pdf",
7974
8174
  "Content-Disposition": `inline; filename="${basename(filePath).replace(/["\\]/g, "") || "document.pdf"}"`,
8175
+ "Referrer-Policy": "no-referrer",
7975
8176
  "Cache-Control": "no-store",
7976
8177
  "X-Content-Type-Options": "nosniff",
7977
8178
  "Cross-Origin-Resource-Policy": "same-origin",
@@ -8006,6 +8207,20 @@ function respondHtmlPreviewResourceJson(req: IncomingMessage, res: ServerRespons
8006
8207
  });
8007
8208
  }
8008
8209
 
8210
+ function respondStudioReadOnlyImage(req: IncomingMessage, res: ServerResponse, filePath: string, mimeType: string): void {
8211
+ const method = (req.method ?? "GET").toUpperCase();
8212
+ if (method !== "GET" && method !== "HEAD") { res.setHeader("Allow", "GET, HEAD"); respondText(res, 405, "Use GET or HEAD."); return; }
8213
+ const nonce = randomBytes(24).toString("hex");
8214
+ const headers = { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff",
8215
+ "Referrer-Policy": "no-referrer", "Cross-Origin-Resource-Policy": "same-origin",
8216
+ "Content-Security-Policy": `default-src 'none'; img-src data:; style-src 'nonce-${nonce}'; script-src 'nonce-${nonce}'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; sandbox allow-scripts` };
8217
+ if (method === "HEAD") { res.writeHead(200, headers); res.end(); return; }
8218
+ // Same bounded authorized image transport as the in-Studio media resolver;
8219
+ // SVG is only an img source, never markup in this trusted page.
8220
+ const html = buildStudioReadOnlyImagePage({ label: basename(filePath), dataUrl: `data:${mimeType};base64,${readFileSync(filePath).toString("base64")}`, nonce });
8221
+ res.writeHead(200, headers); res.end(html);
8222
+ }
8223
+
8009
8224
  function formatStudioMarkdownAngleTarget(pathText: string): string {
8010
8225
  return `<${String(pathText || "").replace(/>/g, "%3E")}>`;
8011
8226
  }
@@ -8091,6 +8306,8 @@ async function respondLocalPreviewLinkJson(
8091
8306
  }
8092
8307
 
8093
8308
  const action = (requestUrl.searchParams.get("action") ?? "resolve").trim().toLowerCase();
8309
+ const hostingError = studioHostingLocalLinkError({ enabled: STUDIO_DOCUMENT_HOSTING_ENABLED, action, kind: resource.kind });
8310
+ if (hostingError) { respondJson(res, 409, { ok: false, error: hostingError }); return; }
8094
8311
  const originDocId = (requestUrl.searchParams.get("docId") ?? "").trim();
8095
8312
  const originDocument = originDocId ? readTransientStudioDocument(originDocId) : null;
8096
8313
  const originatesFromWatchedPreview = requestUrl.searchParams.get("watchedFile") === "1" || originDocument?.watchFile === true;
@@ -8113,6 +8330,36 @@ async function respondLocalPreviewLinkJson(
8113
8330
  return;
8114
8331
  }
8115
8332
 
8333
+ if (action === "reader") {
8334
+ if (resource.kind !== "text") {
8335
+ respondJson(res, 400, { ok: false, error: "The linked reader accepts readable text files." });
8336
+ return;
8337
+ }
8338
+ const file = readStudioFile(resource.filePath, dirname(resource.filePath));
8339
+ if (file.ok === false) {
8340
+ respondJson(res, 400, { ok: false, error: file.message });
8341
+ return;
8342
+ }
8343
+ if (file.resolvedPath !== resource.filePath) {
8344
+ respondJson(res, 409, { ok: false, error: "The file identity changed while preparing the reading snapshot. Open its link again." });
8345
+ return;
8346
+ }
8347
+ if (file.text.length > PREVIEW_RENDER_MAX_CHARS) {
8348
+ respondJson(res, 413, { ok: false, error: `Reader source exceeds ${PREVIEW_RENDER_MAX_CHARS.toLocaleString()} characters.` });
8349
+ return;
8350
+ }
8351
+ // Resolution already checked the session's file/folder authority. Reading
8352
+ // this snapshot must not register an editor or grant its parent folder.
8353
+ respondJson(res, 200, { ...basePayload, text: file.text, readOnly: true });
8354
+ return;
8355
+ }
8356
+
8357
+ if (action === "viewer-url") {
8358
+ if (resource.kind !== "pdf" && resource.kind !== "image") { respondJson(res, 400, { ok: false, error: "Only PDF/images have a separate read-only viewer." }); return; }
8359
+ respondJson(res, 200, { ...basePayload, readOnly: true, relativeUrl: buildStudioReadOnlyMediaUrl({ token: serverState.token, kind: resource.kind, path: resource.filePath, resourceDir: resource.resourceDir, page: resource.page }) });
8360
+ return;
8361
+ }
8362
+
8116
8363
  if (action === "preview-url") {
8117
8364
  if (resource.kind !== "pdf" && resource.kind !== "image") {
8118
8365
  respondJson(res, 400, { ok: false, error: "This local resource cannot be opened in a Studio preview tab." });
@@ -9803,7 +10050,7 @@ function parseStudioReplJournalEntryInput(value: unknown): Partial<StudioReplJou
9803
10050
  };
9804
10051
  }
9805
10052
 
9806
- function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
10053
+ function parseIncomingMessage(data: RawData, captureEnvelope?: (message: Record<string, unknown>) => void): IncomingStudioMessage | null {
9807
10054
  let parsed: unknown;
9808
10055
  try {
9809
10056
  parsed = JSON.parse(rawDataToString(data));
@@ -9813,6 +10060,7 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
9813
10060
 
9814
10061
  if (!parsed || typeof parsed !== "object") return null;
9815
10062
  const msg = parsed as Record<string, unknown>;
10063
+ captureEnvelope?.(msg);
9816
10064
 
9817
10065
  if (msg.type === "hello") return { type: "hello" };
9818
10066
  if (msg.type === "ping") return { type: "ping" };
@@ -9820,6 +10068,12 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
9820
10068
  const state = normalizeStudioWorkspaceRecoveryState(msg.state);
9821
10069
  if (state) return { type: "workspace_state_update", tabStateId: msg.tabStateId, state };
9822
10070
  }
10071
+ if (msg.type === "hosting_clean_leave" && Number.isSafeInteger(msg.generation) && typeof msg.revision === "string" && /^[a-f0-9]{48}$/.test(msg.revision)) {
10072
+ return { type: "hosting_clean_leave", generation: msg.generation as number, revision: msg.revision };
10073
+ }
10074
+ if (msg.type === "editor_mode_switch_request" && typeof msg.requestId === "string" && (msg.mode === "single" || msg.mode === "dual") && typeof msg.text === "string") {
10075
+ return { type: "editor_mode_switch_request", requestId: msg.requestId, mode: msg.mode, text: msg.text, sideQuestions: msg.sideQuestions === true };
10076
+ }
9823
10077
  if (msg.type === "get_latest_response") return { type: "get_latest_response" };
9824
10078
  if (msg.type === "get_trace_snapshot" && typeof msg.responseHistoryId === "string") {
9825
10079
  return {
@@ -10303,9 +10557,11 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
10303
10557
  }
10304
10558
 
10305
10559
  if (msg.type === "cancel_request" && typeof msg.requestId === "string") {
10560
+ if (msg.runId !== undefined && (typeof msg.runId !== "string" || !msg.runId)) return null;
10306
10561
  return {
10307
10562
  type: "cancel_request",
10308
10563
  requestId: msg.requestId,
10564
+ ...(typeof msg.runId === "string" ? { runId: msg.runId } : {}),
10309
10565
  };
10310
10566
  }
10311
10567
 
@@ -11928,6 +12184,10 @@ function buildStudioRelativeUrl(
11928
12184
  if (doc?.watchFile) params.set("watchFile", "1");
11929
12185
  if (options?.skipWorkspaceRestore) params.set("skipWorkspaceRestore", "1");
11930
12186
  if (options?.paneFocus) params.set("paneFocus", options.paneFocus);
12187
+ if (options?.hostedWorkspaceId) {
12188
+ params.set("studioTabState", options.hostedWorkspaceId);
12189
+ params.set("hostedWorkspace", options.hostedWorkspaceId);
12190
+ }
11931
12191
  return `/?${params.toString()}`;
11932
12192
  }
11933
12193
 
@@ -11949,6 +12209,7 @@ interface StudioLaunchFlags {
11949
12209
  watchPdf: boolean;
11950
12210
  listenAll: boolean;
11951
12211
  port?: number;
12212
+ editorMode?: "single" | "dual";
11952
12213
  error?: string;
11953
12214
  }
11954
12215
 
@@ -11961,8 +12222,14 @@ function parseStudioLaunchOpenFlags(rawArgs: string): StudioLaunchFlags {
11961
12222
  let watchPdf = false;
11962
12223
  let listenAll = false;
11963
12224
  let port: number | undefined;
12225
+ let editorMode: "single" | "dual" | undefined;
11964
12226
  for (let i = 0; i < parsed.tokens.length; i += 1) {
11965
12227
  const token = parsed.tokens[i]!;
12228
+ if (token === "--single" || token === "--dual") {
12229
+ if (editorMode && editorMode !== token.slice(2)) return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, listenAll, error: "Use either --single or --dual, not both." };
12230
+ editorMode = token === "--single" ? "single" : "dual";
12231
+ continue;
12232
+ }
11966
12233
  if (token === "--open-remote" || token === "--open-remote-browser" || token === "--open-browser") {
11967
12234
  openRemoteBrowser = true;
11968
12235
  continue;
@@ -11996,7 +12263,7 @@ function parseStudioLaunchOpenFlags(rawArgs: string): StudioLaunchFlags {
11996
12263
  if (openRemoteBrowser && noBrowser) {
11997
12264
  return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, listenAll, port, error: "Use either --no-browser or --open-browser, not both." };
11998
12265
  }
11999
- return { args: remaining.join(" "), openRemoteBrowser, noBrowser, watchPdf, listenAll, port };
12266
+ return { args: remaining.join(" "), openRemoteBrowser, noBrowser, watchPdf, listenAll, port, ...(editorMode ? { editorMode } : {}) };
12000
12267
  }
12001
12268
 
12002
12269
  function shouldAutoOpenStudioBrowser(options?: { openRemoteBrowser?: boolean; noBrowser?: boolean }): boolean {
@@ -12102,10 +12369,8 @@ function formatStudioModelOptionLabel(model: { provider?: string; id?: string; n
12102
12369
 
12103
12370
  function buildTerminalSessionLabel(cwd: string, sessionName?: string): string {
12104
12371
  const cwdBase = basename(cwd || process.cwd() || "") || cwd || "~";
12105
- const termProgram = String(process.env.TERM_PROGRAM ?? "").trim();
12106
12372
  const name = String(sessionName ?? "").trim();
12107
12373
  const parts: string[] = [];
12108
- if (termProgram) parts.push(termProgram);
12109
12374
  if (name) parts.push(name);
12110
12375
  parts.push(cwdBase);
12111
12376
  return parts.join(" · ");
@@ -12328,6 +12593,7 @@ function buildStudioHtml(
12328
12593
  initialTerminalDetail?: string,
12329
12594
  initialContextUsage?: StudioContextUsageSnapshot,
12330
12595
  studioMode: StudioUiMode = "full",
12596
+ bufferRecovery: { workspaceId: string; capability: string; hosting?: boolean; resuming?: boolean } | null = null,
12331
12597
  ): string {
12332
12598
  const initialText = escapeHtmlForInline(initialDocument?.text ?? "");
12333
12599
  const initialSource = initialDocument?.source ?? "blank";
@@ -12414,7 +12680,7 @@ ${cssVarsBlock}
12414
12680
  </style>
12415
12681
  <link rel="stylesheet" href="${stylesheetHref}" />
12416
12682
  </head>
12417
- <body data-initial-source="${initialSource}" data-initial-label="${initialLabel}" data-initial-path="${initialPath}" data-initial-draft-id="${initialDraftId}" data-initial-resource-dir="${initialResourceDir}" data-initial-disk-revision="${initialDiskRevision}" data-watched-file-preview="${initialWatchFile}" data-model-label="${initialModel}" data-terminal-label="${initialTerminal}" data-terminal-detail="${initialTerminalDetailAttr}" data-theme-name="${initialTheme}" data-context-tokens="${initialContextTokens}" data-context-window="${initialContextWindow}" data-context-percent="${initialContextPercent}" data-studio-mode="${studioMode}" data-ssh-session="${initialSshSession}">
12683
+ <body data-editor-mode="${STUDIO_EDITOR_FLAGS.mode}" data-editor-mode-locked="${STUDIO_EDITOR_FLAGS.forced ? "1" : "0"}" data-hosting-resume="${bufferRecovery?.resuming ? "1" : "0"}" data-document-hosting="${bufferRecovery?.hosting ? "1" : "0"}" data-buffer-switching="${STUDIO_BUFFER_SWITCHING_ENABLED && bufferRecovery && studioMode === "full" && initialWatchFile !== "1" ? "1" : "0"}" data-buffer-workspace-id="${escapeHtmlForInline(bufferRecovery?.workspaceId ?? "")}" data-buffer-capability="${escapeHtmlForInline(bufferRecovery?.capability ?? "")}" data-initial-source="${initialSource}" data-initial-label="${initialLabel}" data-initial-path="${initialPath}" data-initial-draft-id="${initialDraftId}" data-initial-resource-dir="${initialResourceDir}" data-initial-disk-revision="${initialDiskRevision}" data-watched-file-preview="${initialWatchFile}" data-model-label="${initialModel}" data-terminal-label="${initialTerminal}" data-terminal-detail="${initialTerminalDetailAttr}" data-theme-name="${initialTheme}" data-context-tokens="${initialContextTokens}" data-context-window="${initialContextWindow}" data-context-percent="${initialContextPercent}" data-studio-mode="${studioMode}" data-ssh-session="${initialSshSession}">
12418
12684
  <header id="studioHeader">
12419
12685
  <h1><span class="app-logo" aria-hidden="true">π</span> Studio <span class="app-subtitle">${appSubtitle}</span></h1>
12420
12686
  <div class="controls">
@@ -12481,7 +12747,7 @@ ${cssVarsBlock}
12481
12747
  </select>
12482
12748
  </div>
12483
12749
  <div class="source-actions-row">
12484
- <button id="copyDraftBtn" type="button" title="Copy the current editor text to the clipboard.">Copy</button>
12750
+ <button id="copyDraftBtn" type="button" title="Copy the editor text to the clipboard.">Copy text</button>
12485
12751
  <button id="suggestCompletionBtn" type="button" title="Ask the current model for a short completion at the editor cursor. Shortcut: Option/Alt+Tab where available, or Cmd/Ctrl+Shift+Space from the editor.">Suggest</button>
12486
12752
  <button id="suggestCompletionOptionsBtn" type="button" hidden title="Suggestion context options">▾</button>
12487
12753
  <select id="completionContextSelect" class="studio-flat-select" hidden aria-label="Suggestion context mode" title="Choose how much context Suggest includes.">
@@ -12554,10 +12820,14 @@ ${cssVarsBlock}
12554
12820
  <option value="editor-top">Layout: Editor above</option>
12555
12821
  <option value="response-top">Layout: Response above</option>
12556
12822
  </select>
12557
- <select id="activityTrackingSelect" class="studio-flat-select" aria-label="Follow main Pi activity" aria-keyshortcuts="Meta+Alt+A Control+Alt+A" title="Optionally follow main Pi activity from Working to the response view; off by default. Shortcut: Cmd/Ctrl+Option/Alt+A.">
12823
+ <select id="activityTrackingSelect" class="studio-flat-select" aria-label="Follow main Pi activity" aria-keyshortcuts="Meta+Alt+A Control+Alt+A" title="Show Working temporarily during main Pi activity, then hand back. Full-workspace fresh default is On; explicit Off is remembered. Shortcut: Cmd/Ctrl+Option/Alt+A.">
12558
12824
  <option value="off">Follow activity: Off</option>
12559
12825
  <option value="on">Follow activity: On</option>
12560
12826
  </select>
12827
+ <select id="documentPreviewFollowSelect" class="studio-flat-select" aria-label="Follow Document preview" title="Show Document preview automatically when selecting Document. Manual view choices take precedence; turning this off restores Document's own view.">
12828
+ <option value="on">Document preview: On</option>
12829
+ <option value="off">Document preview: Off</option>
12830
+ </select>
12561
12831
  <select id="editorFontSizeSelect" class="studio-flat-select" aria-label="Editor text size" title="Adjust raw editor text size.">
12562
12832
  <option value="10">Editor text: 10px</option>
12563
12833
  <option value="11">Editor text: 11px</option>
@@ -12582,7 +12852,7 @@ ${cssVarsBlock}
12582
12852
  </div>
12583
12853
  <div id="lineNumberMeasure" class="editor-line-number-measure" aria-hidden="true"></div>
12584
12854
  <pre id="sourceHighlight" class="editor-highlight" aria-hidden="true"></pre>
12585
- <textarea id="sourceText" placeholder="Paste or edit text here.">${initialText}</textarea>
12855
+ <textarea id="sourceText" ${bufferRecovery?.hosting ? "readonly" : ""} placeholder="Paste or edit text here.">${initialText}</textarea>
12586
12856
  <div id="editorSelectionActions" class="editor-selection-actions" hidden>
12587
12857
  <button id="editorSelectionCommentBtn" type="button" class="editor-selection-action-btn" hidden title="Create a new local comment from the current editor selection.">Comment</button>
12588
12858
  <button id="editorSelectionJumpBtn" type="button" class="editor-selection-action-btn" hidden title="Jump to the current editor selection in the preview.">Jump</button>
@@ -12737,6 +13007,7 @@ ${cssVarsBlock}
12737
13007
  <button id="loadCritiqueNotesBtn" type="button" hidden>Load critique notes into editor</button>
12738
13008
  <button id="loadCritiqueFullBtn" type="button" hidden>Load full critique into editor</button>
12739
13009
  <button id="loadHistoryPromptBtn" type="button" title="Load the prompt that generated the selected response into the editor.">Load response prompt into editor</button>
13010
+ <button id="copyResponsePromptBtn" type="button" title="Select a response with a recorded prompt." disabled>Copy response prompt (unavailable)</button>
12740
13011
  <button id="copyResponseBtn" type="button">Copy response text</button>
12741
13012
  </div>
12742
13013
  </div>
@@ -12750,6 +13021,7 @@ ${cssVarsBlock}
12750
13021
  <div id="footerModelMenu" class="footer-model-menu" hidden></div>
12751
13022
  <div id="footerThemeMenu" class="footer-theme-menu" hidden></div>
12752
13023
  <span class="footer-actions">
13024
+ <button id="editorModeBtn" class="shortcut-hint" type="button" hidden></button>
12753
13025
  <button id="footerThemeBtn" class="shortcut-hint footer-theme-btn" type="button" aria-haspopup="menu" aria-expanded="false" title="Switch the active Pi theme. This affects terminal Pi and Studio.">Theme: ${initialTheme}</button>
12754
13026
  <button id="shortcutsBtn" class="shortcut-hint" type="button" title="Show Studio keyboard shortcuts. Press ? when not editing text.">Shortcuts (?)</button>
12755
13027
  </span>
@@ -12769,11 +13041,13 @@ ${cssVarsBlock}
12769
13041
  <h3>Navigation</h3>
12770
13042
  <dl>
12771
13043
  <div><dt>F6</dt><dd>Switch between editor and right pane</dd></div>
13044
+ <div class="shortcuts-full-only shortcuts-buffer-switching-only"><dt>Cmd/Ctrl+Shift+1 / 2</dt><dd>Select Prompt / Document in the left pane</dd></div>
12772
13045
  <div><dt>F7 / Shift+F7</dt><dd>Cycle the active pane's view</dd></div>
12773
13046
  <div><dt>Cmd/Ctrl+Option/Alt+1–8</dt><dd>Switch the right pane directly: Response Raw, Response Preview, Editor Preview, Working, Changes, Files, REPL, Side questions</dd></div>
12774
13047
  <div><dt>Cmd/Ctrl+Option/Alt+P</dt><dd>Switch the right pane directly to Response Preview; in editor-only views, Editor Preview</dd></div>
12775
13048
  <div><dt>Cmd/Ctrl+Option/Alt+E</dt><dd>Switch the right pane directly to Editor Preview</dd></div>
12776
13049
  <div><dt>Cmd/Ctrl+Option/Alt+W</dt><dd>Switch the right pane directly to Working</dd></div>
13050
+ <div><dt>Control+Shift+[ / ]</dt><dd>Collapse / expand displayed Working sections, while focused in Working (not editable controls)</dd></div>
12777
13051
  <div><dt>Cmd/Ctrl+Option/Alt+F</dt><dd>Switch the right pane directly to Files</dd></div>
12778
13052
  <div><dt>Cmd/Ctrl+Option/Alt+R</dt><dd>Switch the right pane directly to REPL and focus Quick send when a session is selected</dd></div>
12779
13053
  <div><dt>Cmd/Ctrl+Option/Alt+Q</dt><dd>Switch the right pane directly to Side questions</dd></div>
@@ -12806,7 +13080,7 @@ ${cssVarsBlock}
12806
13080
  <div><dt>Tab</dt><dd>Insert a visible completion suggestion; otherwise indent selected editor text</dd></div>
12807
13081
  <div><dt>Esc</dt><dd>Dismiss a visible completion suggestion, close overlays, exit pane focus, or stop an active request</dd></div>
12808
13082
  <div><dt>Shift+Tab</dt><dd>Unindent selected editor text</dd></div>
12809
- <div><dt>Ctrl+Shift+H</dt><dd>Toggle editor syntax highlighting</dd></div>
13083
+ <div><dt>Ctrl+Shift+H</dt><dd>Toggle editor syntax highlighting while editing</dd></div>
12810
13084
  </dl>
12811
13085
  </section>
12812
13086
  <section class="shortcuts-group shortcuts-full-only">
@@ -12880,11 +13154,37 @@ ${cssVarsBlock}
12880
13154
  export default function (pi: ExtensionAPI) {
12881
13155
  let serverState: StudioServerState | null = null;
12882
13156
  const studioWorkspaceStateStore = createStudioWorkspaceStateStore();
13157
+ // Hosting-capable unless the env flags turned hosting off; single editor mode doesn't use it.
13158
+ const studioBufferStateStore = createStudioBufferServerStore({ legacyState: (id: string) => studioWorkspaceStateStore.get(id),
13159
+ ...(!STUDIO_EDITOR_FLAGS.forced || STUDIO_DOCUMENT_HOSTING_ENABLED ? { hosting: { requireView: true,
13160
+ canonicalPath: (path: string) => canonicalStudioDocumentPath(path, studioCwd), sameFile: studioDocumentPathsReferToSameFile } } : {}) });
13161
+ const writeClassicStudioHtml = async (outputPath: string, html: Buffer, cwd: string): Promise<void> => {
13162
+ if (!STUDIO_DOCUMENT_HOSTING_ENABLED) {
13163
+ await writeFile(outputPath, html);
13164
+ return;
13165
+ }
13166
+ await studioPersistentStateQueue.catch(() => undefined);
13167
+ const result = writeStudioClassicHtmlExportFile(outputPath, html, cwd, canonical => studioBufferStateStore.checkUnownedHostedFile(canonical));
13168
+ if (!result.filePath) throw new Error(result.error || "HTML project writing was refused; existing work was kept.");
13169
+ };
13170
+ const hostedConnections = new Map<WebSocket, { capability: string; generation: number; workspaceId: string; continuation?: { revision: string } }>();
12883
13171
  const studioResourceGrantRegistry = createStudioResourceGrantRegistry();
12884
13172
  const studioFileWatchers = new Map<WebSocket, ReturnType<typeof createStudioFileWatcher>>();
12885
13173
  const studioWatchedClientPaths = new Map<WebSocket, string>();
12886
13174
  const studioFileWatchSubscriptionGenerations = new Map<WebSocket, number>();
12887
13175
  let activeRequest: ActiveStudioRequest | null = null;
13176
+ const studioRunLifecycle = createStudioRunLifecycle({ makeId: randomUUID });
13177
+ // Evidence for how the current run ends: the last assistant stop reason, and
13178
+ // every input receipt accepted during the run (steering included).
13179
+ let studioRunLastStopReason: string | null = null;
13180
+ const studioRunInputRequestIds = new Set<string>();
13181
+ // Informational correlation only; never used for SDK/run authority. Keep
13182
+ // per-input provenance so a later queued Studio input cannot credit an
13183
+ // earlier outside input with the same text.
13184
+ let studioPromptInputs: Array<{ text: string; requestId: string | null }> = [];
13185
+ // Once observation loses an input, same-text matching cannot prove origin
13186
+ // again in this server lifetime. Prefer unknown over borrowing later proof.
13187
+ let studioPromptInputProvenanceLost = false;
12888
13188
  let studioDirectRunChain: StudioDirectRunChain | null = null;
12889
13189
  let queuedStudioDirectRequests: QueuedStudioDirectRequest[] = [];
12890
13190
  let pendingStudioPromptMetadata: StudioPromptDescriptor | null = null;
@@ -12892,6 +13192,9 @@ export default function (pi: ExtensionAPI) {
12892
13192
  let preparedPdfExports = new Map<string, PreparedStudioPdfExport>();
12893
13193
  let preparedHtmlExports = new Map<string, PreparedStudioHtmlExport>();
12894
13194
  let initialStudioDocument: InitialStudioDocument | null = null;
13195
+ let lastHostedFullWorkspaceId: string | null = null;
13196
+ const currentFullStudioUrl = (state: StudioServerState) => buildStudioUrl(state.port, state.token, "full", undefined, undefined,
13197
+ STUDIO_DOCUMENT_HOSTING_ENABLED ? { hostedWorkspaceId: studioBufferStateStore.activeHostingWorkspaces("full")[0] || lastHostedFullWorkspaceId || undefined } : undefined);
12895
13198
  let studioCwd = process.cwd();
12896
13199
  let lastCommandCtx: ExtensionCommandContext | null = null;
12897
13200
  let latestModelRequestCtx: StudioModelRequestContext | null = null;
@@ -13299,10 +13602,11 @@ export default function (pi: ExtensionAPI) {
13299
13602
  const clearStudioDirectRunState = () => {
13300
13603
  studioDirectRunChain = null;
13301
13604
  queuedStudioDirectRequests = [];
13605
+ studioPromptInputs = [];
13302
13606
  pendingStudioPromptMetadata = null;
13303
13607
  };
13304
13608
 
13305
- const isStudioBusy = () => agentBusy || activeRequest !== null || compactInProgress;
13609
+ const isStudioBusy = () => agentBusy || activeRequest !== null || compactInProgress || studioRunLifecycle.snapshot().compaction !== null;
13306
13610
 
13307
13611
  const getSessionNameSafe = (): string | undefined => {
13308
13612
  try {
@@ -13618,7 +13922,7 @@ export default function (pi: ExtensionAPI) {
13618
13922
 
13619
13923
  const attachStudioTraceSummariesToHistory = (items: StudioResponseHistoryItem[]): StudioResponseHistoryItem[] => items.map((item) => {
13620
13924
  const stored = studioTraceHistory.get(item.id);
13621
- return stored ? { ...item, traceSummary: stored.summary } : item;
13925
+ return stored ? { ...item, traceSummary: stored.summary, studioRunId: stored.traceState.runId } : item;
13622
13926
  });
13623
13927
 
13624
13928
  const pruneStudioTraceHistory = () => {
@@ -13641,7 +13945,7 @@ export default function (pi: ExtensionAPI) {
13641
13945
  const summary = summarizeStudioTraceSnapshot(snapshot.traceState, snapshot.truncated);
13642
13946
  if (!summary.hasTrace) return null;
13643
13947
  studioTraceHistory.set(id, { traceState: snapshot.traceState, summary });
13644
- studioResponseHistory = studioResponseHistory.map((item) => item.id === id ? { ...item, traceSummary: summary } : item);
13948
+ studioResponseHistory = studioResponseHistory.map((item) => item.id === id ? { ...item, traceSummary: summary, studioRunId: snapshot.traceState.runId } : item);
13645
13949
  pruneStudioTraceHistory();
13646
13950
  return summary;
13647
13951
  };
@@ -14125,7 +14429,7 @@ export default function (pi: ExtensionAPI) {
14125
14429
  const resetStudioTraceForRun = () => {
14126
14430
  const now = Date.now();
14127
14431
  studioTraceState = {
14128
- runId: randomUUID(),
14432
+ runId: studioRunLifecycle.snapshot().run?.id ?? randomUUID(),
14129
14433
  requestId: activeRequest?.id ?? null,
14130
14434
  requestKind: activeRequest?.kind ?? null,
14131
14435
  status: "running",
@@ -14408,9 +14712,10 @@ export default function (pi: ExtensionAPI) {
14408
14712
  contextTokens: contextUsageSnapshot.tokens,
14409
14713
  contextWindow: contextUsageSnapshot.contextWindow,
14410
14714
  contextPercent: contextUsageSnapshot.percent,
14411
- compactInProgress,
14412
- activeRequestId: activeRequest?.id ?? compactRequestId ?? null,
14413
- activeRequestKind: activeRequest?.kind ?? (compactInProgress ? "compact" : null),
14715
+ compactInProgress: compactInProgress || studioRunLifecycle.snapshot().compaction !== null,
14716
+ runtimeActivity: studioRunLifecycle.snapshot(),
14717
+ activeRequestId: activeRequest?.id ?? studioRunLifecycle.snapshot().run?.requestId ?? compactRequestId ?? null,
14718
+ activeRequestKind: activeRequest?.kind ?? studioRunLifecycle.snapshot().run?.kind ?? (compactInProgress ? "compact" : null),
14414
14719
  studioRunChainActive: isStudioDirectRunChainActive(),
14415
14720
  queuedSteeringCount: getQueuedStudioSteeringCount(),
14416
14721
  });
@@ -14425,6 +14730,7 @@ export default function (pi: ExtensionAPI) {
14425
14730
  if (!activeRequest) return;
14426
14731
  const completedRequestId = activeRequest.id;
14427
14732
  const completedKind = activeRequest.kind;
14733
+ studioPromptInputs = studioPromptInputs.filter(input => input.requestId !== completedRequestId);
14428
14734
  clearTimeout(activeRequest.timer);
14429
14735
  activeRequest = null;
14430
14736
  syncCmuxStudioStatus();
@@ -14446,7 +14752,24 @@ export default function (pi: ExtensionAPI) {
14446
14752
  }
14447
14753
  };
14448
14754
 
14449
- const cancelActiveRequest = (requestId: string): { ok: true; kind: StudioRequestKind } | { ok: false; message: string } => {
14755
+ const cancelActiveRequest = (requestId: string, runId?: string): { ok: true; kind: StudioRequestKind } | { ok: false; message: string } => {
14756
+ const run = studioRunLifecycle.snapshot().run;
14757
+ if (run) {
14758
+ if (!lastCommandCtx) return { ok: false, message: "No interactive pi context is available to stop the request." };
14759
+ const result = studioRunLifecycle.stop(requestId, runId, () => lastCommandCtx!.abort());
14760
+ if (!result.ok) return { ok: false, message: result.message || "Could not request cancellation." };
14761
+ const kind = result.kind as StudioRequestKind;
14762
+ const stillTarget = studioRunLifecycle.snapshot().run?.id === run.id;
14763
+ if (stillTarget) {
14764
+ if (kind === "direct") clearStudioDirectRunState();
14765
+ clearPendingStudioCompletion();
14766
+ suppressedStudioResponse = { requestId, kind };
14767
+ }
14768
+ broadcastState(); // delivery does not clear an unsettled target
14769
+ broadcast({ type: "info", message: stillTarget ? "Stopping request…" : "Cancellation requested.", level: "warning" });
14770
+ return { ok: true, kind };
14771
+ }
14772
+ if (runId) return { ok: false, message: "That Studio run has already settled." };
14450
14773
  if (!activeRequest) {
14451
14774
  return { ok: false, message: "No studio request is currently running." };
14452
14775
  }
@@ -15272,12 +15595,40 @@ export default function (pi: ExtensionAPI) {
15272
15595
  }
15273
15596
  };
15274
15597
 
15275
- const handleStudioMessage = (client: WebSocket, msg: IncomingStudioMessage) => {
15598
+ const handleStudioMessage = (client: WebSocket, msg: IncomingStudioMessage, hostingProof?: Record<string, unknown>) => {
15599
+ const hosted = hostedConnections.get(client);
15600
+ if (STUDIO_DOCUMENT_HOSTING_ENABLED && msg.type === "open_editor_only_request") {
15601
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: "Use Move Document or explicitly Copy Document in the hosting controls." }); return;
15602
+ }
15603
+ if (hosted && ["save_as_request", "save_over_request", "refresh_from_disk_request", "open_editor_only_request"].includes(msg.type)) {
15604
+ const authority = hostingProof?.generation === hosted.generation
15605
+ ? studioBufferStateStore.hostingAuthority(hosted.capability, hosted.generation, hostingProof.bufferId, hostingProof.documentEpoch)
15606
+ : { ok: false, message: "The Document's owning connection is not ready." };
15607
+ if (!authority.ok) { sendToClient(client, { type: "error", requestId: "requestId" in msg ? msg.requestId : undefined, message: "message" in authority ? authority.message : "Document ownership could not be verified." }); return; }
15608
+ }
15276
15609
  if (msg.type === "ping") {
15277
15610
  sendToClient(client, { type: "pong", timestamp: Date.now() });
15278
15611
  return;
15279
15612
  }
15613
+ if (msg.type === "hosting_clean_leave") {
15614
+ // A tab forced off by a handover doesn't get to skip the resume question (Sol, candidate70).
15615
+ if (hosted && !studioMovedAwayCapabilities.has(hosted.capability)) studioBufferStateStore.hostingCleanLeave(hosted.capability, client, { generation: msg.generation, revision: msg.revision });
15616
+ return;
15617
+ }
15618
+ if (msg.type === "editor_mode_switch_request") {
15619
+ const refusal = studioEditorModeSwitchRefusal(client, msg);
15620
+ if (refusal) { sendToClient(client, { type: "editor_mode_switch_result", requestId: msg.requestId, ok: false, message: refusal }); return; }
15621
+ const state = serverState!, token = createSessionToken(), nonce = randomUUID();
15622
+ try { writeStudioEditorMode(STUDIO_EDITOR_MODE_PATH, msg.mode); }
15623
+ catch { sendToClient(client, { type: "editor_mode_switch_result", requestId: msg.requestId, ok: false, message: "Couldn't save the editor mode setting. Nothing changed." }); return; }
15624
+ studioRestarting = true;
15625
+ const switching = withStudioLifecycle(() => restartStudioInEditorModeNow(msg.mode, state.port, state.listenAll, token, { nonce, text: msg.text }));
15626
+ sendToClient(client, { type: "editor_mode_switch_result", requestId: msg.requestId, ok: true, landing: `/?token=${token}&modeSwitch=${nonce}` });
15627
+ void switching;
15628
+ return;
15629
+ }
15280
15630
  if (msg.type === "workspace_state_update") {
15631
+ if (STUDIO_DOCUMENT_HOSTING_ENABLED && (hosted || studioBufferStateStore.hostedWorkspace(msg.tabStateId))) return;
15281
15632
  studioWorkspaceStateStore.set(msg.tabStateId, msg.state);
15282
15633
  return;
15283
15634
  }
@@ -15322,15 +15673,17 @@ export default function (pi: ExtensionAPI) {
15322
15673
  contextTokens: contextUsageSnapshot.tokens,
15323
15674
  contextWindow: contextUsageSnapshot.contextWindow,
15324
15675
  contextPercent: contextUsageSnapshot.percent,
15325
- compactInProgress,
15326
- activeRequestId: activeRequest?.id ?? compactRequestId ?? null,
15327
- activeRequestKind: activeRequest?.kind ?? (compactInProgress ? "compact" : null),
15676
+ compactInProgress: compactInProgress || studioRunLifecycle.snapshot().compaction !== null,
15677
+ runtimeActivity: studioRunLifecycle.snapshot(),
15678
+ activeRequestId: activeRequest?.id ?? studioRunLifecycle.snapshot().run?.requestId ?? compactRequestId ?? null,
15679
+ activeRequestKind: activeRequest?.kind ?? studioRunLifecycle.snapshot().run?.kind ?? (compactInProgress ? "compact" : null),
15328
15680
  studioRunChainActive: isStudioDirectRunChainActive(),
15329
15681
  queuedSteeringCount: getQueuedStudioSteeringCount(),
15330
15682
  lastResponse: lastStudioResponse,
15331
15683
  responseHistory: studioResponseHistory,
15332
15684
  traceState: studioTraceState,
15333
- initialDocument: initialStudioDocument,
15685
+ initialDocument: hosted ? null : initialStudioDocument,
15686
+ ...(hosted ? { documentHosting: { workspaceId: hosted.workspaceId, generation: hosted.generation, ...(hosted.continuation ? { continuation: hosted.continuation } : {}) } } : {}),
15334
15687
  resourceGrants: studioResourceGrantRegistry.snapshot(),
15335
15688
  quartoPreview: getStudioQuartoPreviewSnapshot(),
15336
15689
  sideQuestion: getStudioSideQuestionPublicState(),
@@ -15669,9 +16022,9 @@ export default function (pi: ExtensionAPI) {
15669
16022
  return;
15670
16023
  }
15671
16024
 
15672
- const result = cancelActiveRequest(msg.requestId);
16025
+ const result = cancelActiveRequest(msg.requestId, msg.runId);
15673
16026
  if (result.ok === false) {
15674
- sendToClient(client, { type: "error", requestId: msg.requestId, message: result.message });
16027
+ sendToClient(client, { type: msg.runId ? "cancel_rejected" : "error", requestId: msg.requestId, runId: msg.runId, message: result.message });
15675
16028
  }
15676
16029
  return;
15677
16030
  }
@@ -16395,7 +16748,10 @@ export default function (pi: ExtensionAPI) {
16395
16748
  return;
16396
16749
  }
16397
16750
 
16398
- const result = writeStudioFile(msg.path, studioCwd, msg.content, msg.overwrite === true, msg.expectedRevision);
16751
+ const writer = (authorizeCommit?: (path: string) => { ok: boolean; message?: string }) => writeStudioFile(msg.path, studioCwd, msg.content, msg.overwrite === true, msg.expectedRevision, authorizeCommit);
16752
+ const result = (hosted ? studioBufferStateStore.hostingSave(hosted.capability, { ...hostingProof,
16753
+ kind: "save-as", path: msg.path, content: msg.content, overwrite: msg.overwrite === true, expectedDiskRevision: msg.expectedRevision }, writer)
16754
+ : writer()) as ReturnType<typeof writeStudioFile> & { documents?: Record<string, number>; operationId?: string; claimStatus?: string };
16399
16755
  if (result.ok === false) {
16400
16756
  if (result.conflict) {
16401
16757
  sendToClient(client, {
@@ -16430,6 +16786,7 @@ export default function (pi: ExtensionAPI) {
16430
16786
  resourceDir: dirname(result.resolvedPath),
16431
16787
  diskRevision: result.diskRevision,
16432
16788
  message: `Saved editor text to ${result.label}`,
16789
+ ...(hosted ? { hosting: { documents: result.documents, operationId: result.operationId, claimStatus: result.claimStatus } } : {}),
16433
16790
  });
16434
16791
  return;
16435
16792
  }
@@ -16448,12 +16805,12 @@ export default function (pi: ExtensionAPI) {
16448
16805
  return;
16449
16806
  }
16450
16807
 
16451
- const result = saveStudioDiskFileIfRevision({
16452
- path: msg.path,
16453
- content: msg.content,
16454
- expectedRevision: msg.expectedRevision,
16455
- force: msg.force === true,
16456
- }) as StudioDiskWriteResult;
16808
+ const writer = (authorizeCommit?: (path: string) => { ok: boolean; message?: string }) => saveStudioDiskFileIfRevision({
16809
+ path: msg.path, content: msg.content, expectedRevision: msg.expectedRevision, force: msg.force === true, authorizeCommit,
16810
+ });
16811
+ const result = (hosted ? studioBufferStateStore.hostingSave(hosted.capability, { ...hostingProof,
16812
+ kind: "save-over", path: msg.path, content: msg.content, force: msg.force === true, expectedDiskRevision: msg.expectedRevision }, writer)
16813
+ : writer()) as StudioDiskWriteResult & { documents?: Record<string, number>; operationId?: string; claimStatus?: string };
16457
16814
  if (result.ok === false) {
16458
16815
  if (result.conflict) {
16459
16816
  sendToClient(client, {
@@ -16492,6 +16849,7 @@ export default function (pi: ExtensionAPI) {
16492
16849
  resourceDir: dirname(result.path),
16493
16850
  diskRevision: result.revision,
16494
16851
  message: `Saved over ${existingLabel}`,
16852
+ ...(hosted ? { hosting: { documents: result.documents, operationId: result.operationId, claimStatus: result.claimStatus } } : {}),
16495
16853
  });
16496
16854
  return;
16497
16855
  }
@@ -16516,7 +16874,16 @@ export default function (pi: ExtensionAPI) {
16516
16874
  return;
16517
16875
  }
16518
16876
 
16519
- const refreshed = readStudioFile(refreshPath, studioCwd, { requireCanonicalPath: true });
16877
+ const refreshed = hosted
16878
+ ? studioBufferStateStore.hostingRefresh(hosted.capability, { ...hostingProof, path: requestedPath },
16879
+ (canonical: string, buffer: { sourceState: { path: string | null }; resourceDir: string }) => {
16880
+ // Use the verified checkpoint's context, never request-supplied hints;
16881
+ // ownership does not bypass the existing resource permission check.
16882
+ const resource = resolveStudioLocalPreviewResourcePath(canonical, buffer.sourceState.path ?? undefined, buffer.resourceDir, studioCwd, studioResourceGrantRegistry);
16883
+ if (resource.kind !== "text" || resource.filePath !== canonical) return { ok: false, message: "Refresh requires a permitted text file at the same canonical location." };
16884
+ return readStudioFile(canonical, studioCwd, { requireCanonicalPath: true });
16885
+ })
16886
+ : readStudioFile(refreshPath, studioCwd, { requireCanonicalPath: true });
16520
16887
  if (refreshed.ok === false) {
16521
16888
  sendToClient(client, {
16522
16889
  type: "error",
@@ -16534,7 +16901,7 @@ export default function (pi: ExtensionAPI) {
16534
16901
  resourceDir: dirname(refreshed.resolvedPath),
16535
16902
  diskRevision: refreshed.diskRevision,
16536
16903
  };
16537
- if (!requestedPath || initialStudioDocument?.path === refreshed.resolvedPath) {
16904
+ if (!hosted && (!requestedPath || initialStudioDocument?.path === refreshed.resolvedPath)) {
16538
16905
  initialStudioDocument = refreshedDocument;
16539
16906
  recordStudioDocumentResourceGrants(refreshedDocument);
16540
16907
  }
@@ -16754,6 +17121,7 @@ export default function (pi: ExtensionAPI) {
16754
17121
  "X-Content-Type-Options": "nosniff",
16755
17122
  "Content-Disposition": `inline; filename="${safeAsciiName}"; filename*=UTF-8''${encodeURIComponent(prepared.filename)}`,
16756
17123
  "Content-Length": String(prepared.pdf.length),
17124
+ "Referrer-Policy": "no-referrer",
16757
17125
  };
16758
17126
  if (prepared.warning) headers["X-Pi-Studio-Export-Warning"] = prepared.warning;
16759
17127
 
@@ -16853,12 +17221,20 @@ export default function (pi: ExtensionAPI) {
16853
17221
  "Content-Length": String(prepared.html.length),
16854
17222
  };
16855
17223
  if (prepared.warning) headers["X-Pi-Studio-Export-Warning"] = prepared.warning;
17224
+ if (prepared.documentExport) {
17225
+ // Generated HTML is editable only inside a registered/sandboxed Document.
17226
+ // Its retained download must never execute at the privileged Studio origin.
17227
+ headers["Content-Disposition"] = headers["Content-Disposition"].replace(/^inline;/, "attachment;");
17228
+ headers["Content-Security-Policy"] = "sandbox; default-src 'none'";
17229
+ headers["Referrer-Policy"] = "no-referrer";
17230
+ }
16856
17231
 
16857
17232
  res.writeHead(200, headers);
16858
17233
  res.end(prepared.html);
16859
17234
  };
16860
17235
 
16861
17236
  const handleScratchpadStateRequest = async (req: IncomingMessage, res: ServerResponse, requestUrl: URL) => {
17237
+ if (STUDIO_DOCUMENT_HOSTING_ENABLED) await studioPersistentStateQueue.catch(() => undefined);
16862
17238
  const method = (req.method ?? "GET").toUpperCase();
16863
17239
  if (method === "GET") {
16864
17240
  const action = (requestUrl.searchParams.get("action") ?? "").trim().toLowerCase();
@@ -16921,7 +17297,20 @@ export default function (pi: ExtensionAPI) {
16921
17297
  ? (parsedBody as { label: string }).label
16922
17298
  : undefined;
16923
17299
 
16924
- await writePersistedStudioScratchpadText(documentKey, text, label);
17300
+ const writeOwnership = parseStudioMetadataWriteOwnership(parsedBody);
17301
+ if (!writeOwnership.ok) {
17302
+ respondJson(res, 400, { ok: false, error: "Invalid metadata write ownership." });
17303
+ return;
17304
+ }
17305
+ const proof = (parsedBody as { hosting?: Record<string, unknown> }).hosting;
17306
+ if (STUDIO_DOCUMENT_HOSTING_ENABLED && proof) {
17307
+ const result = await studioBufferStateStore.hostingMetadata(proof.capability, proof, "scratchpad", documentKey,
17308
+ (authorize: () => void) => writePersistedStudioScratchpadText(documentKey, text, label, writeOwnership.ownership, authorize));
17309
+ respondJson(res, result.ok ? 200 : 409, { ...result, ...(!result.ok ? { error: result.message } : {}) }); return;
17310
+ }
17311
+ await writePersistedStudioScratchpadText(documentKey, text, label, writeOwnership.ownership, STUDIO_DOCUMENT_HOSTING_ENABLED ? () => {
17312
+ if (studioBufferStateStore.ownsMetadataKey("scratchpad", documentKey)) throw new Error("Edit this scratchpad in its owning Document.");
17313
+ } : undefined);
16925
17314
  respondJson(res, 200, { ok: true });
16926
17315
  };
16927
17316
 
@@ -16974,6 +17363,7 @@ export default function (pi: ExtensionAPI) {
16974
17363
  };
16975
17364
 
16976
17365
  const handleReviewNotesRequest = async (req: IncomingMessage, res: ServerResponse, requestUrl: URL) => {
17366
+ if (STUDIO_DOCUMENT_HOSTING_ENABLED) await studioPersistentStateQueue.catch(() => undefined);
16977
17367
  const method = (req.method ?? "GET").toUpperCase();
16978
17368
  if (method === "GET") {
16979
17369
  const documentKey = (requestUrl.searchParams.get("documentKey") ?? "").trim();
@@ -17026,7 +17416,20 @@ export default function (pi: ExtensionAPI) {
17026
17416
  return;
17027
17417
  }
17028
17418
 
17029
- await writePersistedStudioReviewNotes(documentKey, notes);
17419
+ const writeOwnership = parseStudioMetadataWriteOwnership(parsedBody);
17420
+ if (!writeOwnership.ok) {
17421
+ respondJson(res, 400, { ok: false, error: "Invalid metadata write ownership." });
17422
+ return;
17423
+ }
17424
+ const proof = (parsedBody as { hosting?: Record<string, unknown> }).hosting;
17425
+ if (STUDIO_DOCUMENT_HOSTING_ENABLED && proof) {
17426
+ const result = await studioBufferStateStore.hostingMetadata(proof.capability, proof, "review-notes", documentKey,
17427
+ (authorize: () => void) => writePersistedStudioReviewNotes(documentKey, notes, writeOwnership.ownership, authorize));
17428
+ respondJson(res, result.ok ? 200 : 409, { ...result, ...(!result.ok ? { error: result.message } : {}) }); return;
17429
+ }
17430
+ await writePersistedStudioReviewNotes(documentKey, notes, writeOwnership.ownership, STUDIO_DOCUMENT_HOSTING_ENABLED ? () => {
17431
+ if (studioBufferStateStore.ownsMetadataKey("review-notes", documentKey)) throw new Error("Edit these comments in their owning Document.");
17432
+ } : undefined);
17030
17433
  respondJson(res, 200, { ok: true });
17031
17434
  };
17032
17435
 
@@ -17259,6 +17662,14 @@ export default function (pi: ExtensionAPI) {
17259
17662
  const writeResult = writeStudioPreviewExportFile(buildStudioPreviewExportPath(sourcePath || undefined, userResourceDir || undefined, studioCwd, filename), pdf);
17260
17663
  const exportId = storePreparedPdfExport(pdf, filename, warning, writeResult.filePath ?? undefined);
17261
17664
  const token = serverState?.token ?? "";
17665
+ if (openTarget === "studio" && STUDIO_DOCUMENT_HOSTING_ENABLED) {
17666
+ // The resident export bytes remain viewable even when project writing
17667
+ // fails. No editable wrapper, file grant or Document is created.
17668
+ respondJson(res, 200, { ok: true, filename, path: writeResult.filePath, writeError: writeResult.error, warning: warning ?? null,
17669
+ openedStudio: false, readOnly: true, relativeUrl: `/export-pdf?token=${encodeURIComponent(token)}&id=${encodeURIComponent(exportId)}`,
17670
+ downloadUrl: `/export-pdf?token=${encodeURIComponent(token)}&id=${encodeURIComponent(exportId)}` });
17671
+ return;
17672
+ }
17262
17673
  if (openTarget === "studio" && serverState && writeResult.filePath) {
17263
17674
  const exportedPath = writeResult.filePath;
17264
17675
  const title = sanitizeStudioPreviewBlockLine(filename || basename(exportedPath) || "PDF preview");
@@ -17381,6 +17792,15 @@ export default function (pi: ExtensionAPI) {
17381
17792
  ? (parsedBody as { openTarget: string }).openTarget.trim().toLowerCase()
17382
17793
  : "browser";
17383
17794
  const openTarget = requestedOpenTarget === "studio" ? "studio" : "browser";
17795
+ const hostedDocumentExport = openTarget === "studio" && STUDIO_DOCUMENT_HOSTING_ENABLED;
17796
+ const exportCapability = req.headers["x-studio-buffer-capability"];
17797
+ const exportProof = parsedBody && typeof parsedBody === "object" ? (parsedBody as { hosting?: Record<string, unknown> }).hosting : undefined;
17798
+ let exportAuthority: ReturnType<typeof studioBufferStateStore.hostingExportAuthority> | null = null;
17799
+ if (hostedDocumentExport) {
17800
+ await studioPersistentStateQueue.catch(() => undefined);
17801
+ exportAuthority = typeof exportCapability === "string" ? studioBufferStateStore.hostingExportAuthority(exportCapability, exportProof) : null;
17802
+ if (!exportAuthority?.ok) { respondJson(res, 409, { ok: false, error: exportAuthority?.message || "A current owning export checkpoint is required; current work was kept." }); return; }
17803
+ }
17384
17804
  const editorHtmlLanguage = inferStudioPdfLanguage(markdown, requestedEditorHtmlLanguage);
17385
17805
  const isLatex = editorHtmlLanguage === "latex"
17386
17806
  || (
@@ -17403,9 +17823,33 @@ export default function (pi: ExtensionAPI) {
17403
17823
  themeVars,
17404
17824
  },
17405
17825
  );
17406
- const writeResult = writeStudioPreviewExportFile(buildStudioPreviewExportPath(sourcePath || undefined, userResourceDir || undefined, studioCwd, filename), html);
17407
- const exportId = storePreparedHtmlExport(html, filename, warning, writeResult.filePath ?? undefined);
17826
+ const targetPath = buildStudioPreviewExportPath(sourcePath || undefined, userResourceDir || undefined, studioCwd, filename);
17408
17827
  const token = serverState?.token ?? "";
17828
+ if (hostedDocumentExport && exportAuthority?.ok && typeof exportCapability === "string") {
17829
+ // Retain generated bytes BEFORE any filesystem attempt; no Document is
17830
+ // registered until a separate original-identity launch transaction.
17831
+ const exportId = storePreparedHtmlExport(html, filename, warning);
17832
+ const prepared = preparedHtmlExports.get(exportId)!;
17833
+ prepared.documentExport = { workspaceId: exportAuthority.workspaceId, bufferId: exportAuthority.bufferId,
17834
+ documentEpoch: exportProof!.documentEpoch as number, expectedRevision: exportProof!.expectedRevision as string,
17835
+ resourceDir: resourcePath || studioCwd, diskRevision: null };
17836
+ const writeResult = writeStudioHostedHtmlDocumentExport(targetPath, html, studioCwd, (canonical) => {
17837
+ const current = studioBufferStateStore.hostingExportAuthority(exportCapability, exportProof);
17838
+ if (!current.ok) return { ok: false, message: current.message };
17839
+ const found = studioBufferStateStore.hostingOwner(exportCapability, canonical);
17840
+ return found.ok && "owner" in found && !found.owner ? { ok: true } : { ok: false, message: "The export target is owned or claimed by a Document; its file and work were kept." };
17841
+ });
17842
+ if (writeResult.filePath) { prepared.filePath = writeResult.filePath; prepared.persistent = true; prepared.documentExport.diskRevision = writeResult.diskRevision; }
17843
+ respondJson(res, 200, { ok: true, filename, path: writeResult.filePath, writeError: writeResult.error, warning: warning ?? null,
17844
+ openedStudio: false, preparedDocument: { exportId, kind: writeResult.filePath ? "file" : "blank", path: writeResult.filePath || "" },
17845
+ downloadUrl: `/export-html?token=${encodeURIComponent(token)}&id=${encodeURIComponent(exportId)}` });
17846
+ return;
17847
+ }
17848
+ if (STUDIO_DOCUMENT_HOSTING_ENABLED) await studioPersistentStateQueue.catch(() => undefined);
17849
+ const writeResult = STUDIO_DOCUMENT_HOSTING_ENABLED
17850
+ ? writeStudioClassicHtmlExportFile(targetPath, html, studioCwd, canonical => studioBufferStateStore.checkUnownedHostedFile(canonical), true)
17851
+ : writeStudioPreviewExportFile(targetPath, html);
17852
+ const exportId = storePreparedHtmlExport(html, filename, warning, writeResult.filePath ?? undefined);
17409
17853
  if (openTarget === "studio" && serverState) {
17410
17854
  const exportedPath = writeResult.filePath ?? "";
17411
17855
  const document: InitialStudioDocument = {
@@ -17458,7 +17902,7 @@ export default function (pi: ExtensionAPI) {
17458
17902
  };
17459
17903
 
17460
17904
  const handleHttpRequest = (req: IncomingMessage, res: ServerResponse) => {
17461
- if (!serverState) {
17905
+ if (!serverState || studioRestarting) {
17462
17906
  respondText(res, 503, "Studio server not ready");
17463
17907
  return;
17464
17908
  }
@@ -17611,6 +18055,174 @@ export default function (pi: ExtensionAPI) {
17611
18055
  return;
17612
18056
  }
17613
18057
 
18058
+ if (requestUrl.pathname.startsWith("/buffer-modules/")) {
18059
+ if (!STUDIO_BUFFER_RECOVERY_ENABLED || requestUrl.searchParams.get("token") !== serverState.token) {
18060
+ respondText(res, 403, "Buffer module authorization unavailable.");
18061
+ return;
18062
+ }
18063
+ const name = requestUrl.pathname.slice("/buffer-modules/".length);
18064
+ const sourceUrl = STUDIO_BUFFER_MODULE_URLS.get(name);
18065
+ if (!sourceUrl) { respondText(res, 404, "Not found"); return; }
18066
+ try {
18067
+ let source = readFileSync(sourceUrl, "utf8");
18068
+ // Module imports do not inherit query parameters: authorize each fixed graph edge.
18069
+ for (const moduleName of STUDIO_BUFFER_MODULE_URLS.keys()) {
18070
+ source = source.replaceAll(JSON.stringify("./" + moduleName), JSON.stringify("/buffer-modules/" + moduleName + "?token=" + encodeURIComponent(serverState.token)));
18071
+ }
18072
+ res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", "Cross-Origin-Resource-Policy": "same-origin" });
18073
+ res.end(source);
18074
+ } catch { respondText(res, 500, "Could not load buffer module."); }
18075
+ return;
18076
+ }
18077
+
18078
+ if (requestUrl.pathname === "/document-hosting") {
18079
+ const capability = req.headers["x-studio-buffer-capability"];
18080
+ if (!STUDIO_DOCUMENT_HOSTING_ENABLED || requestUrl.searchParams.get("token") !== serverState.token || typeof capability !== "string") {
18081
+ respondJson(res, 403, { ok: false, message: "Document hosting authorization unavailable." }); return;
18082
+ }
18083
+ if (req.method !== "POST") { respondJson(res, 405, { ok: false, message: "Use POST." }); return; }
18084
+ void (async () => {
18085
+ const body = JSON.parse(await readRequestBody(req, 64_000)) as Record<string, unknown>;
18086
+ if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid request");
18087
+ const current = studioBufferStateStore.read(capability);
18088
+ if (!current.ok) { respondJson(res, 403, current); return; }
18089
+ const url = (id: string, mode: StudioUiMode) => buildStudioRelativeUrl(serverState!.token, mode) + "&studioTabState=" + encodeURIComponent(id) + "&hostedWorkspace=" + encodeURIComponent(id);
18090
+ if (body.operation === "hosts") {
18091
+ respondJson(res, 200, { ok: true, workspaces: studioBufferStateStore.activeHostingWorkspaces("full") }); return;
18092
+ }
18093
+ if (body.operation === "owner") {
18094
+ // Retained buffer identity covers draft/moved Documents without a path.
18095
+ // Neither lookup reads file contents, creates an owner or grants access.
18096
+ let found: ReturnType<typeof studioBufferStateStore.hostingOwner>;
18097
+ if (body.bufferId !== undefined) {
18098
+ if (typeof body.bufferId !== "string" || !/^[a-zA-Z0-9_-]{1,128}$/.test(body.bufferId) || body.path) {
18099
+ respondJson(res, 400, { ok: false, reason: "invalid-request", message: "A bounded retained Document identity is required." }); return;
18100
+ }
18101
+ found = { ok: true, owner: studioBufferStateStore.hostedDocumentOwner(body.bufferId) };
18102
+ } else {
18103
+ const rawPath = typeof body.path === "string" ? body.path.trim() : "";
18104
+ if (!rawPath || rawPath.includes("\0") || rawPath.startsWith("//") || (/^[a-z][a-z0-9+.-]*:/i.test(rawPath) && !/^file:/i.test(rawPath) && !/^[a-z]:[\\/]/i.test(rawPath))) {
18105
+ respondJson(res, 400, { ok: false, reason: "invalid-request", message: "A local Document path is required." }); return;
18106
+ }
18107
+ const context = resolveStudioPreviewResourceContext(typeof body.sourcePath === "string" ? body.sourcePath : undefined,
18108
+ typeof body.resourceDir === "string" ? body.resourceDir : undefined, studioCwd);
18109
+ const reference = recoverLikelyDroppedLeadingSlashPath(expandHome(decodeStudioLocalPreviewResourceReference(rawPath)));
18110
+ found = studioBufferStateStore.hostingOwner(capability, isAbsolute(reference) ? reference : resolve(context.baseDir, reference));
18111
+ }
18112
+ if (!("owner" in found) || !found.owner) { respondJson(res, 200, found); return; }
18113
+ const owner = found.owner;
18114
+ const expectedOwner = body.expectedOwner as Record<string, unknown> | undefined;
18115
+ if (expectedOwner && (expectedOwner.workspaceId !== owner.workspaceId || expectedOwner.bufferId !== owner.bufferId)) {
18116
+ respondJson(res, 409, { ok: false, reason: "owner-changed", message: "The Document owner changed. Recheck it without opening an independent copy." }); return;
18117
+ }
18118
+ if (body.focus === true) {
18119
+ const authority = studioBufferStateStore.hostingViewAuthority(capability, body.generation);
18120
+ if (!authority.ok) { respondJson(res, 409, authority); return; }
18121
+ }
18122
+ const live = [...hostedConnections].filter(([socket, value]) => value.workspaceId === owner.workspaceId && socket.readyState === WebSocket.OPEN);
18123
+ if (body.focus === true) for (const [socket] of live) sendToClient(socket, { type: "document_hosting_focus", bufferId: owner.bufferId });
18124
+ respondJson(res, 200, { ...found, seen: studioBufferStateStore.seenHostingView(owner.workspaceId), live: live.length > 0, url: url(owner.workspaceId, owner.mode as StudioUiMode) }); return;
18125
+ }
18126
+ await studioPersistentStateQueue.catch(() => undefined);
18127
+ let launchRequest = body;
18128
+ const opening = ["open", "open-cancel", "open-ack"].includes(String(body.operation));
18129
+ if (opening || (body.operation === "launch" && body.kind === "file")) {
18130
+ const raw = typeof body.path === "string" ? body.path.trim() : "";
18131
+ if (!raw || raw.includes("\0") || raw.startsWith("//") || (/^[a-z][a-z0-9+.-]*:/i.test(raw) && !/^file:/i.test(raw) && !/^[a-z]:[\\/]/i.test(raw))) {
18132
+ respondJson(res, 400, { ok: false, reason: "invalid-request", message: "A local file path is required." }); return;
18133
+ }
18134
+ const context = resolveStudioPreviewResourceContext(typeof body.sourcePath === "string" ? body.sourcePath : undefined, typeof body.resourceDir === "string" ? body.resourceDir : undefined, studioCwd);
18135
+ const reference = recoverLikelyDroppedLeadingSlashPath(expandHome(decodeStudioLocalPreviewResourceReference(raw)));
18136
+ launchRequest = { ...body, path: isAbsolute(reference) ? reference : resolve(context.baseDir, reference) };
18137
+ }
18138
+ const loadHostedFile = (canonical: string, prepared?: { path: string | null }) => {
18139
+ if (!prepared) {
18140
+ const resource = resolveStudioLocalPreviewResourcePath(String(body.path), typeof body.sourcePath === "string" ? body.sourcePath : undefined, typeof body.resourceDir === "string" ? body.resourceDir : undefined, studioCwd, studioResourceGrantRegistry);
18141
+ if (resource.kind !== "text" || resource.filePath !== canonical) return { ok: false, reason: "source-changed", message: "Choose a readable text file whose canonical location has not changed." };
18142
+ } else if (prepared.path !== canonical) return { ok: false, reason: "source-changed", message: "The generated HTML file identity changed." };
18143
+ const file = readStudioFile(canonical, studioCwd, { requireCanonicalPath: true });
18144
+ return file.ok === false ? file : { ...file, path: file.resolvedPath, resourceDir: dirname(file.resolvedPath), editorLanguage: inferStudioPdfLanguageFromPath(file.resolvedPath) || "markdown" };
18145
+ };
18146
+ const loadHostedExport = (request: Record<string, unknown>, scope: { workspaceId: string }) => {
18147
+ const prepared = typeof request.exportId === "string" ? getPreparedHtmlExport(request.exportId) : null;
18148
+ const origin = prepared?.documentExport;
18149
+ if (!prepared || !origin || origin.workspaceId !== scope.workspaceId || origin.bufferId !== request.bufferId || origin.documentEpoch !== request.documentEpoch || origin.expectedRevision !== request.expectedRevision) return { ok: false, reason: "export-unavailable", message: "The prepared HTML expired or belongs to another source. Keep existing Documents and re-export explicitly." };
18150
+ const path = prepared.persistent ? prepared.filePath || null : null;
18151
+ if ((path ? request.kind !== "file" || request.path !== path : request.kind !== "blank")) return { ok: false, reason: "source-changed", message: "The prepared HTML backing does not match its original request." };
18152
+ return { ok: true, kind: path ? "file" : "blank", path, text: prepared.html.toString("utf-8"), label: prepared.filename,
18153
+ resourceDir: path ? dirname(path) : origin.resourceDir, diskRevision: path ? origin.diskRevision : null, editorLanguage: "html" };
18154
+ };
18155
+ const result = (opening ? studioBufferStateStore.hostingOpen(capability, launchRequest, loadHostedFile)
18156
+ : body.operation === "launch" ? studioBufferStateStore.hostingLaunch(capability, launchRequest, loadHostedFile, loadHostedExport)
18157
+ : body.operation === "copy" ? studioBufferStateStore.hostingCopy(capability, body)
18158
+ : body.operation === "save-metadata" ? await studioBufferStateStore.hostingSaveMetadata(capability, body, runStudioSavedMetadataTransaction)
18159
+ : body.operation === "discard-save" ? studioBufferStateStore.discardHostingSave(capability, body)
18160
+ : studioBufferStateStore.hostingMove(capability, body)) as Record<string, unknown>;
18161
+ if (opening) { respondJson(res, result.ok ? 200 : 409, result); return; }
18162
+ if (result.ok) {
18163
+ if (body.operation === "launch" && typeof body.exportId === "string" && body.kind === "file") {
18164
+ const prepared = getPreparedHtmlExport(body.exportId);
18165
+ if (prepared?.documentExport && prepared.persistent && prepared.filePath) {
18166
+ try { studioResourceGrantRegistry.grantFile(prepared.filePath, { cwd: studioCwd, source: "generated-html", expectedCanonicalPath: prepared.filePath }); } catch { /* Never broaden a failed exact-file grant. */ }
18167
+ }
18168
+ }
18169
+ const owner = typeof result.bufferId === "string" ? studioBufferStateStore.hostedDocumentOwner(result.bufferId) : null;
18170
+ if (owner && (result.status === "committed" || body.operation === "copy" || body.operation === "launch")) {
18171
+ result.seen = studioBufferStateStore.seenHostingView(owner.workspaceId);
18172
+ result.url = url(owner.workspaceId, owner.mode as StudioUiMode);
18173
+ const live = [...hostedConnections].filter(([socket, value]) => value.workspaceId === owner.workspaceId && socket.readyState === WebSocket.OPEN);
18174
+ result.live = live.length > 0;
18175
+ if (body.focus === true) for (const [socket] of live) sendToClient(socket, { type: "document_hosting_focus", bufferId: result.bufferId });
18176
+ }
18177
+ if (body.operation !== "status" && typeof result.moveId === "string") for (const [socket, value] of hostedConnections) {
18178
+ if ([result.sourceWorkspaceId, result.targetWorkspaceId].includes(value.workspaceId)) sendToClient(socket, { type: "document_hosting_changed", moveId: result.moveId });
18179
+ }
18180
+ }
18181
+ respondJson(res, result.ok ? 200 : 409, result);
18182
+ })().catch(error => {
18183
+ if (error instanceof StudioResourceGrantRequiredError) { respondStudioResourceGrantRequiredJson(res, error); return; }
18184
+ respondJson(res, 500, { ok: false, reason: "unavailable", message: "The outcome could not be checked. Keep all copies and recheck; do not resume a frozen Document." });
18185
+ });
18186
+ return;
18187
+ }
18188
+
18189
+ if (requestUrl.pathname === "/tab-buffer-state") {
18190
+ if (!STUDIO_BUFFER_RECOVERY_ENABLED || requestUrl.searchParams.get("token") !== serverState.token) {
18191
+ respondJson(res, 403, { ok: false, reason: "forbidden", message: "Buffer recovery authorization unavailable." });
18192
+ return;
18193
+ }
18194
+ const capability = req.headers["x-studio-buffer-capability"];
18195
+ if (typeof capability !== "string" || capability.length !== 48) {
18196
+ respondJson(res, 403, { ok: false, reason: "forbidden", message: "An editable page recovery capability is required." });
18197
+ return;
18198
+ }
18199
+ void (async () => {
18200
+ const method = (req.method ?? "GET").toUpperCase();
18201
+ // Releasing a page capability never deletes its workspace recovery.
18202
+ if (method === "DELETE") { respondJson(res, 200, studioBufferStateStore.release(capability)); return; }
18203
+ if (method === "GET" && requestUrl.searchParams.get("inspect") === "1") {
18204
+ const result = studioBufferStateStore.inspect(capability);
18205
+ respondJson(res, result.ok ? 200 : ("reason" in result && result.reason === "forbidden" ? 403 : 409), result); return;
18206
+ }
18207
+ const current = studioBufferStateStore.read(capability);
18208
+ if (!current.ok) { respondJson(res, "reason" in current && current.reason === "forbidden" ? 403 : 409, current); return; }
18209
+ if (method === "GET") { respondJson(res, 200, current); return; }
18210
+ if (method !== "POST") { res.setHeader("Allow", "GET, POST, DELETE"); respondJson(res, 405, { ok: false, message: "Use GET, POST, or DELETE (release page authorization only)." }); return; }
18211
+ let raw: string;
18212
+ try { raw = await readRequestBody(req, STUDIO_BUFFER_REQUEST_MAX_BYTES); }
18213
+ catch { respondJson(res, 413, { ok: false, reason: "limit-exceeded", message: "Buffer request exceeds its limit; recovery was retained." }); return; }
18214
+ let body: Record<string, unknown>;
18215
+ try {
18216
+ body = JSON.parse(raw);
18217
+ if (!body || typeof body !== "object" || Array.isArray(body) || Object.keys(body).some((key) => !["state", "expectedRevision", "generation", "documents"].includes(key))) throw new Error();
18218
+ } catch { respondJson(res, 400, { ok: false, reason: "invalid-request", message: "Invalid buffer request." }); return; }
18219
+ if (STUDIO_DOCUMENT_HOSTING_ENABLED) await studioPersistentStateQueue.catch(() => undefined);
18220
+ const result = studioBufferStateStore.write(capability, body.expectedRevision, body.state, body.generation, body.documents);
18221
+ respondJson(res, result.ok ? 200 : 409, result);
18222
+ })().catch(() => respondJson(res, 500, { ok: false, reason: "unavailable", message: "Buffer recovery failed; keep the current editor text." }));
18223
+ return;
18224
+ }
18225
+
17614
18226
  if (requestUrl.pathname === "/tab-workspace-state") {
17615
18227
  const token = requestUrl.searchParams.get("token") ?? "";
17616
18228
  if (token !== serverState.token) {
@@ -17660,6 +18272,9 @@ export default function (pi: ExtensionAPI) {
17660
18272
  respondJson(res, 400, { ok: false, error: "Invalid or oversized Studio workspace state." });
17661
18273
  return;
17662
18274
  }
18275
+ if (STUDIO_DOCUMENT_HOSTING_ENABLED && studioBufferStateStore.hostedWorkspace(tabStateId)) {
18276
+ respondJson(res, 409, { ok: false, error: "Use the owning Document's buffer recovery; legacy publication is disabled for this workspace." }); return;
18277
+ }
17663
18278
  const stored = studioWorkspaceStateStore.set(tabStateId, state);
17664
18279
  respondJson(res, 200, { ok: true, stored });
17665
18280
  })().catch((error) => {
@@ -17912,6 +18527,29 @@ export default function (pi: ExtensionAPI) {
17912
18527
  return;
17913
18528
  }
17914
18529
 
18530
+ if (requestUrl.pathname === "/studio-handover") {
18531
+ const token = requestUrl.searchParams.get("token") ?? "";
18532
+ if (token !== serverState.token) { respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." }); return; }
18533
+ if ((req.method ?? "GET").toUpperCase() !== "POST") { res.setHeader("Allow", "POST"); respondJson(res, 405, { ok: false, error: "Method not allowed. Use POST." }); return; }
18534
+ if (!STUDIO_DOCUMENT_HOSTING_ENABLED) { respondJson(res, 409, { ok: false, error: "Use /studio-replace in Pi." }); return; }
18535
+ const admitted = serverState;
18536
+ void (async () => {
18537
+ let body: Record<string, unknown>;
18538
+ try { const raw = await readRequestBody(req, 4096); body = raw ? JSON.parse(raw) as Record<string, unknown> : {}; }
18539
+ catch { respondJson(res, 400, { ok: false, error: "Invalid request." }); return; }
18540
+ // Never wait behind a restart: it waits for this response to finish (Sol, candidate70).
18541
+ if (studioRestarting || serverState !== admitted || studioLifecycle.pending() > 0 || !STUDIO_DOCUMENT_HOSTING_ENABLED) {
18542
+ respondJson(res, 503, { ok: false, error: "Studio is busy. Try again in a moment." }); return;
18543
+ }
18544
+ const capability = typeof body.capability === "string" ? body.capability : "";
18545
+ const issued = studioBufferStateStore.capabilityWorkspace(capability);
18546
+ if (!issued || issued.mode !== "full" || studioMovedAwayCapabilities.has(capability)) { respondJson(res, 409, { ok: false, error: "This tab can't take over Studio. Run /studio-replace in Pi instead." }); return; }
18547
+ const moved = await handOverFullStudioView({ state: admitted, capability, workspaceId: issued.workspaceId });
18548
+ respondJson(res, moved.ok ? 200 : 409, moved.ok ? { ok: true, clean: moved.clean } : { ok: false, error: moved.message });
18549
+ })().catch(error => respondJson(res, 500, { ok: false, error: `Studio couldn't move: ${error instanceof Error ? error.message : String(error)}` }));
18550
+ return;
18551
+ }
18552
+
17915
18553
  if (requestUrl.pathname === "/side-question-context-root") {
17916
18554
  const token = requestUrl.searchParams.get("token") ?? "";
17917
18555
  if (token !== serverState.token) {
@@ -18038,6 +18676,21 @@ export default function (pi: ExtensionAPI) {
18038
18676
  return;
18039
18677
  }
18040
18678
 
18679
+ if (requestUrl.pathname === "/image-viewer") {
18680
+ const token = requestUrl.searchParams.get("token") ?? "";
18681
+ if (token !== serverState.token) { respondText(res, 403, "Invalid or expired studio token. Re-run /studio."); return; }
18682
+ try {
18683
+ const resource = resolveStudioHtmlPreviewResourcePath(requestUrl.searchParams.get("path") ?? "", requestUrl.searchParams.get("sourcePath") ?? undefined,
18684
+ requestUrl.searchParams.get("resourceDir") ?? undefined, studioCwd, studioResourceGrantRegistry);
18685
+ if (!resource.mimeType.startsWith("image/")) { respondText(res, 400, "Only images can open in this read-only viewer."); return; }
18686
+ respondStudioReadOnlyImage(req, res, resource.filePath, resource.mimeType);
18687
+ } catch (error) {
18688
+ if (error instanceof StudioResourceGrantRequiredError) { respondStudioResourceGrantRequiredJson(res, error); return; }
18689
+ respondText(res, 404, "Image unavailable; its identity or permission changed.");
18690
+ }
18691
+ return;
18692
+ }
18693
+
18041
18694
  if (requestUrl.pathname !== "/") {
18042
18695
  respondText(res, 404, "Not found");
18043
18696
  return;
@@ -18048,7 +18701,37 @@ export default function (pi: ExtensionAPI) {
18048
18701
  respondText(res, 403, "Invalid or expired studio token. Re-run /studio.");
18049
18702
  return;
18050
18703
  }
18704
+ if (requestUrl.searchParams.has("modeSwitch")) {
18705
+ const landing = pendingEditorModeLanding;
18706
+ if (!landing || landing.nonce !== requestUrl.searchParams.get("modeSwitch") || landing.expiresAt < Date.now()) {
18707
+ respondText(res, 410, "This link has expired. Run /studio in Pi to open Studio.");
18708
+ return;
18709
+ }
18710
+ res.writeHead(302, { Location: landing.path, "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" });
18711
+ res.end();
18712
+ return;
18713
+ }
18051
18714
 
18715
+ refreshContextUsage();
18716
+ const studioMode = normalizeStudioUiMode(requestUrl.searchParams.get("mode"));
18717
+ const hostingPage = resolveStudioHostingPage({ enabled: STUDIO_DOCUMENT_HOSTING_ENABLED, requestUrl, mode: studioMode,
18718
+ workspace: (id: string) => studioBufferStateStore.hostedWorkspace(id), transient: readTransientStudioDocument });
18719
+ if (!hostingPage.ok) { respondText(res, 409, "message" in hostingPage ? hostingPage.message : "This editing page is unavailable."); return; }
18720
+ const hostedId = "workspaceId" in hostingPage ? hostingPage.workspaceId : undefined;
18721
+ // Neither path hints nor watch flags authorize reads. Owned recovery uses
18722
+ // a neutral surface; watched pages require a retained read-only capability.
18723
+ const requestInitialDocument: InitialStudioDocument | null = "legacy" in hostingPage && hostingPage.legacy
18724
+ ? resolveRequestedStudioDocumentFromUrl(requestUrl, initialStudioDocument, studioCwd, lastStudioResponse)
18725
+ : "document" in hostingPage ? hostingPage.document as InitialStudioDocument : null;
18726
+ let bufferRecovery: { workspaceId: string; capability: string; hosting?: boolean; resuming?: boolean } | null = null;
18727
+ if (STUDIO_BUFFER_RECOVERY_ENABLED && !requestInitialDocument?.watchFile && requestUrl.searchParams.get("watchedFile") !== "1") {
18728
+ const requestedId = hostedId || requestUrl.searchParams.get("studioTabState") || "";
18729
+ const workspaceId = hostedId || (requestUrl.searchParams.get("skipWorkspaceRestore") !== "1" && isValidStudioTabStateId(requestedId) ? requestedId : "tab_" + createSessionToken().slice(0, 40));
18730
+ const issued = studioBufferStateStore.issue({ workspaceId, mode: studioMode });
18731
+ if (!("capability" in issued)) { respondText(res, 503, "Buffer recovery page capacity reached. Existing recovery was retained; close unused test pages before retrying."); return; }
18732
+ bufferRecovery = { workspaceId, capability: issued.capability, hosting: STUDIO_DOCUMENT_HOSTING_ENABLED,
18733
+ resuming: STUDIO_DOCUMENT_HOSTING_ENABLED && studioBufferStateStore.seenHostingView(workspaceId) && requestUrl.searchParams.get("resumeAcknowledged") !== "1" };
18734
+ }
18052
18735
  res.writeHead(200, {
18053
18736
  "Content-Type": "text/html; charset=utf-8",
18054
18737
  "Cache-Control": "no-store",
@@ -18057,13 +18740,14 @@ export default function (pi: ExtensionAPI) {
18057
18740
  "Cross-Origin-Opener-Policy": "same-origin",
18058
18741
  "Cross-Origin-Resource-Policy": "same-origin",
18059
18742
  });
18060
- refreshContextUsage();
18061
- const studioMode = normalizeStudioUiMode(requestUrl.searchParams.get("mode"));
18062
- const requestInitialDocument = resolveRequestedStudioDocumentFromUrl(requestUrl, initialStudioDocument, studioCwd, lastStudioResponse);
18063
- res.end(buildStudioHtml(requestInitialDocument, serverState.token, lastCommandCtx?.ui.theme, currentModelLabel, terminalSessionLabel, terminalSessionDetail, contextUsageSnapshot, studioMode));
18743
+ res.end(buildStudioHtml(requestInitialDocument, serverState.token, lastCommandCtx?.ui.theme, currentModelLabel, terminalSessionLabel, terminalSessionDetail, contextUsageSnapshot, studioMode, bufferRecovery));
18064
18744
  };
18065
18745
 
18066
- const ensureServer = async (requestedPort?: number, listenAll = false): Promise<StudioServerState> => {
18746
+ // Starting, stopping and switching editor mode take turns (Sol, candidate69). The *Now
18747
+ // forms run inside a turn; ensureServer and stopServer queue for one.
18748
+ const ensureServer = (requestedPort?: number, listenAll = false): Promise<StudioServerState> =>
18749
+ serverState && studioLifecycle.pending() === 0 ? Promise.resolve(serverState) : withStudioLifecycle(() => startServerNow(requestedPort, listenAll));
18750
+ const startServerNow = async (requestedPort?: number, listenAll = false, token = createSessionToken()): Promise<StudioServerState> => {
18067
18751
  if (serverState) return serverState;
18068
18752
 
18069
18753
  const binding = resolveStudioNetworkBinding(listenAll);
@@ -18080,10 +18764,15 @@ export default function (pi: ExtensionAPI) {
18080
18764
  port: 0,
18081
18765
  bindHost: binding.bindHost,
18082
18766
  listenAll: binding.listenAll,
18083
- token: createSessionToken(),
18767
+ token,
18084
18768
  };
18085
18769
 
18086
18770
  server.on("upgrade", (req, socket, head) => {
18771
+ if (studioRestarting) {
18772
+ socket.write("HTTP/1.1 503 Service Unavailable\r\n\r\n");
18773
+ socket.destroy();
18774
+ return;
18775
+ }
18087
18776
  const requestUrl = parseStudioRequestTarget(req.url);
18088
18777
  if (!requestUrl) {
18089
18778
  socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
@@ -18154,6 +18843,10 @@ export default function (pi: ExtensionAPI) {
18154
18843
  }
18155
18844
  return;
18156
18845
  }
18846
+ if (studioMovedAwayCapabilities.has(requestUrl.searchParams.get("bufferCapability") || "")) {
18847
+ try { ws.close(4006, "Studio moved to another tab"); } catch {}
18848
+ return;
18849
+ }
18157
18850
  if (clientMode === "full") {
18158
18851
  for (const client of clients) {
18159
18852
  if (client.readyState !== WebSocket.OPEN) continue;
@@ -18167,6 +18860,12 @@ export default function (pi: ExtensionAPI) {
18167
18860
  return;
18168
18861
  }
18169
18862
  }
18863
+ if (STUDIO_DOCUMENT_HOSTING_ENABLED && !canonicalWatchedPath) {
18864
+ const capability = requestUrl.searchParams.get("bufferCapability") || "";
18865
+ const binding = studioBufferStateStore.bindHostingView(capability, clientMode, ws);
18866
+ if (!("generation" in binding)) { ws.close(4005, "Document editing view unavailable or already open"); return; }
18867
+ hostedConnections.set(ws, { capability, workspaceId: binding.workspaceId, generation: binding.generation, ...("continuation" in binding && binding.continuation ? { continuation: binding.continuation } : {}) });
18868
+ }
18170
18869
  clients.add(ws);
18171
18870
  clientModes.set(ws, clientMode);
18172
18871
  if (canonicalWatchedPath) studioWatchedClientPaths.set(ws, canonicalWatchedPath);
@@ -18174,15 +18873,21 @@ export default function (pi: ExtensionAPI) {
18174
18873
  broadcastState();
18175
18874
 
18176
18875
  ws.on("message", (data) => {
18177
- const parsed = parseIncomingMessage(data);
18876
+ if (studioRestarting) return;
18877
+ let proof: Record<string, unknown> | undefined;
18878
+ const parsed = parseIncomingMessage(data, envelope => {
18879
+ if (envelope.hosting && typeof envelope.hosting === "object" && !Array.isArray(envelope.hosting)) proof = envelope.hosting as Record<string, unknown>;
18880
+ });
18178
18881
  if (!parsed) {
18179
18882
  sendToClient(ws, { type: "error", message: "Invalid message payload." });
18180
18883
  return;
18181
18884
  }
18182
- handleStudioMessage(ws, parsed);
18885
+ handleStudioMessage(ws, parsed, proof);
18183
18886
  });
18184
18887
 
18185
18888
  const cleanupClient = () => {
18889
+ const hosted = hostedConnections.get(ws);
18890
+ if (hosted) { studioBufferStateStore.releaseHostingView(hosted.capability, ws); hostedConnections.delete(ws); }
18186
18891
  clients.delete(ws);
18187
18892
  clientModes.delete(ws);
18188
18893
  studioWatchedClientPaths.delete(ws);
@@ -18273,7 +18978,145 @@ export default function (pi: ExtensionAPI) {
18273
18978
  return state;
18274
18979
  };
18275
18980
 
18276
- const stopServer = async () => {
18981
+ // Oliver, 13 Oct: switching editor mode restarts Studio's server (Pi keeps running).
18982
+ // The page that asked lands on a fresh workspace holding the Prompt it carried.
18983
+ // The old server takes no new connections, requests or messages while it stops (Sol, candidate69).
18984
+ const studioEditorModeLabel = (mode: "single" | "dual") => mode === "single" ? "single editor mode" : "dual editor mode";
18985
+ let pendingEditorModeLanding: { nonce: string; path: string; expiresAt: number } | null = null;
18986
+ let studioRestarting = false;
18987
+ const studioLifecycle = createStudioTurns();
18988
+ const withStudioLifecycle = <T>(operation: () => Promise<T>): Promise<T> => studioLifecycle.run(operation);
18989
+ const isStudioSideQuestionBusy = () => Boolean(studioSideQuestionStartRequestId || studioSideQuestionRuntime?.publicState.status === "running");
18990
+ const studioEditorModeCarryFits = (mode: "single" | "dual", text: string) => mode === "dual"
18991
+ ? buildStudioHostingBootstrapState({ text, label: "blank", source: "blank" }).ok : text.length <= STUDIO_EDITOR_MODE_CARRY_MAX_CHARS;
18992
+ const studioEditorModeSwitchRefusal = (client: WebSocket, msg: EditorModeSwitchRequestMessage): string | null => {
18993
+ const state = serverState;
18994
+ if (!state || !state.clients.has(client) || client.readyState !== WebSocket.OPEN) return "Studio isn't running.";
18995
+ if (studioLifecycle.pending() > 0 || studioRestarting) return "Studio is restarting.";
18996
+ if (STUDIO_EDITOR_FLAGS.forced) return "Studio's editor mode is set by environment variables here.";
18997
+ const hosted = hostedConnections.get(client);
18998
+ if (state.clientModes.get(client) !== "full" || studioWatchedClientPaths.has(client) || (STUDIO_DOCUMENT_HOSTING_ENABLED && !hosted)) return "Switch from Studio's main tab.";
18999
+ if (msg.mode === STUDIO_EDITOR_FLAGS.mode) return `Studio is already in ${studioEditorModeLabel(msg.mode)}.`;
19000
+ if (isStudioBusy()) return "Wait for Pi to finish, then switch.";
19001
+ if (isStudioSideQuestionBusy()) return "Wait for the side question to finish, then switch.";
19002
+ // The page named side questions in its question only if it knew of them.
19003
+ if (studioSideQuestionRuntime?.publicState.threadId && !msg.sideQuestions) return "Side questions changed while you were asked. Try again.";
19004
+ if ([...state.clients].some(other => other !== client && other.readyState === WebSocket.OPEN)) return "Close Studio's other tabs first.";
19005
+ if (!studioEditorModeCarryFits(msg.mode, msg.text)) return "The text is too long to carry over. Save it to a file first.";
19006
+ if (STUDIO_DOCUMENT_HOSTING_ENABLED && hosted) {
19007
+ const work = studioBufferStateStore.retainedWork(hosted.workspaceId, msg.text);
19008
+ if (work.unsaved.includes(hosted.workspaceId)) return "The Document has unsaved text. Save it, or add it to the Prompt, before switching.";
19009
+ if (work.pending || work.unsaved.length) return "Another Studio window still has unsaved text, or a move hasn't finished. Save it first.";
19010
+ }
19011
+ return null;
19012
+ };
19013
+ // Runs inside a lifecycle turn. The footer sets studioRestarting when it accepts.
19014
+ const restartStudioInEditorModeNow = async (mode: "single" | "dual", port: number | undefined, listenAll: boolean, token: string, landing: { nonce: string; text: string } | null): Promise<StudioServerState | null> => {
19015
+ const label = studioEditorModeLabel(mode);
19016
+ studioRestarting = true;
19017
+ try {
19018
+ await studioPersistentStateQueue.catch(() => undefined);
19019
+ await stopServerNow();
19020
+ applyStudioEditorMode(mode);
19021
+ pendingEditorModeLanding = null;
19022
+ if (landing) {
19023
+ try {
19024
+ const document: InitialStudioDocument = { text: landing.text, label: "blank", source: "blank", draftId: createStudioDraftId(), resourceDir: studioCwd };
19025
+ let path: string;
19026
+ if (STUDIO_DOCUMENT_HOSTING_ENABLED) {
19027
+ const result = studioBufferStateStore.hostingBootstrap({}, () => ({ ok: true, document, editorLanguage: "markdown" }));
19028
+ if (!result.ok || !("owner" in result) || !result.owner) throw new Error("message" in result ? String(result.message) : "The new workspace could not be registered.");
19029
+ lastHostedFullWorkspaceId = result.owner.workspaceId;
19030
+ path = buildStudioRelativeUrl(token, "full", undefined, undefined, { hostedWorkspaceId: result.owner.workspaceId });
19031
+ } else {
19032
+ path = buildStudioRelativeUrl(token, "full", document, storeTransientStudioDocument(document));
19033
+ }
19034
+ pendingEditorModeLanding = { nonce: landing.nonce, path, expiresAt: Date.now() + 10 * 60 * 1000 };
19035
+ } catch (error) {
19036
+ lastCommandCtx?.ui.notify(`Studio couldn't reopen the tab: ${error instanceof Error ? error.message : String(error)}. The tab keeps its text.`, "warning");
19037
+ }
19038
+ }
19039
+ } finally {
19040
+ studioRestarting = false;
19041
+ }
19042
+ try {
19043
+ const state = await startServerNow(port, listenAll, token);
19044
+ if (state.token !== token) throw new Error("another Studio server started first");
19045
+ lastCommandCtx?.ui.notify(`Studio switched to ${label}.`, "info");
19046
+ if (pendingEditorModeLanding) lastCommandCtx?.ui.notify(`Studio URL: http://${STUDIO_ADVERTISED_HOST}:${state.port}${pendingEditorModeLanding.path}`, "info");
19047
+ return state;
19048
+ } catch (error) {
19049
+ lastCommandCtx?.ui.notify(`Studio is now in ${label}, but it couldn't restart${port ? ` on port ${port}` : ""}: ${error instanceof Error ? error.message : String(error)}. Run /studio to open it.`, "error");
19050
+ return null;
19051
+ }
19052
+ };
19053
+
19054
+ // Oliver, 14 Oct: Studio opened in another browser, or in a tab you can't find, holds the
19055
+ // workspace. Ask that tab to hand over: it sends its text and leaves cleanly, so the new tab
19056
+ // opens without asking. A tab that doesn't answer is disconnected, and the new tab asks the
19057
+ // usual resume question. Single editor mode replaces the tab, as /studio-replace always did.
19058
+ const STUDIO_HANDOVER_WAIT_MS = 5000;
19059
+ // Tabs disconnected without answering: if one wakes and reconnects, it's told it moved,
19060
+ // rather than being offered to move Studio back.
19061
+ const studioMovedAwayCapabilities = new Set<string>();
19062
+ const openFullStudioClients = () => {
19063
+ const state = serverState;
19064
+ return state ? [...state.clients].filter(client => client.readyState === WebSocket.OPEN && (state.clientModes.get(client) ?? "full") === "full" && !studioWatchedClientPaths.has(client)) : [];
19065
+ };
19066
+ // asking: the Move it here page, revalidated here, inside the turn (Sol, candidate70).
19067
+ const handOverFullStudioView = (asking: { state: StudioServerState; capability: string; workspaceId: string } | null = null): Promise<{ ok: true; workspaceId: string | null; clean: boolean } | { ok: false; message: string }> => withStudioLifecycle(async () => {
19068
+ if (asking && (serverState !== asking.state || studioMovedAwayCapabilities.has(asking.capability)
19069
+ || studioBufferStateStore.capabilityWorkspace(asking.capability)?.workspaceId !== asking.workspaceId)) return { ok: false, message: "This tab can't take over Studio. Run /studio-replace in Pi instead." };
19070
+ if (!serverState) return { ok: true, workspaceId: null, clean: false };
19071
+ const owners = openFullStudioClients();
19072
+ const workspaceId = owners.map(client => hostedConnections.get(client)?.workspaceId).find(Boolean) ?? lastHostedFullWorkspaceId;
19073
+ if (asking && owners.length && workspaceId !== asking.workspaceId) return { ok: false, message: "This tab is for a different workspace. Run /studio-replace in Pi instead." };
19074
+ if (owners.length && (isStudioBusy() || isStudioSideQuestionBusy())) return { ok: false, message: "Wait for Pi to finish, then try again." };
19075
+ await Promise.all(owners.map(client => new Promise<void>(resolve => {
19076
+ let forced: NodeJS.Timeout | null = null;
19077
+ const timer = setTimeout(() => {
19078
+ const hosted = hostedConnections.get(client);
19079
+ if (hosted) { studioMovedAwayCapabilities.add(hosted.capability); studioBufferStateStore.forgetCleanLeave(hosted.workspaceId); }
19080
+ try { client.close(4006, "Studio moved to another tab"); } catch {}
19081
+ forced = setTimeout(() => { try { client.terminate(); } catch {} }, 1500);
19082
+ }, STUDIO_HANDOVER_WAIT_MS);
19083
+ client.once("close", () => { clearTimeout(timer); if (forced) clearTimeout(forced); resolve(); });
19084
+ if (STUDIO_DOCUMENT_HOSTING_ENABLED && hostedConnections.has(client)) sendToClient(client, { type: "workspace_handover_request" });
19085
+ else { clearTimeout(timer); try { client.close(4001, "Full Studio replaced"); } catch { resolve(); } }
19086
+ })));
19087
+ return { ok: true, workspaceId: workspaceId ?? null, clean: Boolean(workspaceId && studioBufferStateStore.hasCleanLeave(workspaceId)) };
19088
+ });
19089
+ // /studio-replace in dual editor mode: move the open Studio (or the last one) here.
19090
+ const moveHostedStudioHere = async (trimmed: string, ctx: ExtensionCommandContext) => {
19091
+ const flags = parseStudioLaunchOpenFlags(trimmed);
19092
+ if (flags.error) { ctx.ui.notify(`${flags.error} Use /studio-replace --help`, "error"); return; }
19093
+ if (flags.args || flags.editorMode) { ctx.ui.notify("In dual editor mode, /studio-replace moves the open Studio here. Open files from Studio's Document controls.", "warning"); return; }
19094
+ lastCommandCtx = ctx;
19095
+ const moved = await handOverFullStudioView();
19096
+ if (!moved.ok) { ctx.ui.notify(moved.message, "warning"); return; }
19097
+ const state = serverState;
19098
+ if (!state || !moved.workspaceId || !studioBufferStateStore.hostedWorkspace(moved.workspaceId)) {
19099
+ await openStudioView(trimmed, ctx, "full", { defaultSource: "last-response", commandLabel: "/studio-replace" });
19100
+ return;
19101
+ }
19102
+ lastHostedFullWorkspaceId = moved.workspaceId;
19103
+ const url = buildStudioUrl(state.port, state.token, "full", undefined, undefined, { hostedWorkspaceId: moved.workspaceId });
19104
+ try {
19105
+ if (shouldAutoOpenStudioBrowser(flags)) await openStudioUrlInBrowser(url);
19106
+ ctx.ui.notify(moved.clean ? "Studio moved here." : "Opening Studio here. The other tab didn't hand over cleanly, so Studio asks before resuming.", "info");
19107
+ } catch (error) {
19108
+ ctx.ui.notify(`Browser opening failed; the workspace is kept: ${error instanceof Error ? error.message : String(error)}`, "warning");
19109
+ } finally {
19110
+ ctx.ui.notify(`Studio URL: ${url}`, "info");
19111
+ const hint = state.listenAll ? null : buildStudioSshTunnelHint(state.port, url)
19112
+ ?? (flags.noBrowser ? buildStudioForwardingHint(state.port, url, { prefix: "Browser auto-open was skipped because --no-browser was used." }) : null);
19113
+ if (hint) ctx.ui.notify(hint, "info");
19114
+ if (state.listenAll) ctx.ui.notify(buildStudioListenAllWarning(state.port, url), "warning");
19115
+ }
19116
+ };
19117
+
19118
+ const stopServer = () => withStudioLifecycle(stopServerNow);
19119
+ const stopServerNow = async () => {
18277
19120
  studioSideQuestionStartRequestId = null;
18278
19121
  await disposeStudioSideQuestionRuntime();
18279
19122
  await closeAllStudioFileWatchers();
@@ -18310,7 +19153,10 @@ export default function (pi: ExtensionAPI) {
18310
19153
  studioWatchedClientPaths.clear();
18311
19154
  studioFileWatchSubscriptionGenerations.clear();
18312
19155
  studioWorkspaceStateStore.clear();
19156
+ studioBufferStateStore.clear();
19157
+ lastHostedFullWorkspaceId = null;
18313
19158
  studioResourceGrantRegistry.clear();
19159
+ studioMovedAwayCapabilities.clear();
18314
19160
  };
18315
19161
 
18316
19162
  const hydrateLatestAssistant = (entries: SessionEntry[]) => {
@@ -18401,9 +19247,49 @@ export default function (pi: ExtensionAPI) {
18401
19247
  broadcastState();
18402
19248
  });
18403
19249
 
18404
- pi.on("agent_start", async () => {
19250
+ pi.on("input", async (event) => {
19251
+ studioRunLifecycle.input(event.source);
19252
+ const text = normalizePromptText(event.text);
19253
+ if (!text) return;
19254
+ if (text.length > 900_000) { studioPromptInputProvenanceLost = true; return; }
19255
+ while (studioPromptInputs.length && (studioPromptInputs.length >= 16 || studioPromptInputs.reduce((sum, input) => sum + input.text.length, 0) + text.length > 2_700_000)) {
19256
+ studioPromptInputProvenanceLost = true;
19257
+ studioPromptInputs.shift();
19258
+ }
19259
+ const candidates = [...queuedStudioDirectRequests.map(request => ({ ...request, id: request.requestId })),
19260
+ ...(activeRequest?.kind === "direct" ? [activeRequest] : [])];
19261
+ const candidate = event.source === "extension" ? candidates.find(request =>
19262
+ normalizePromptText(request.promptTriggerText ?? request.prompt) === text && !studioPromptInputs.some(input => input.requestId === request.id)) : null;
19263
+ studioPromptInputs.push({ text, requestId: candidate?.id ?? null });
19264
+ });
19265
+ pi.on("session_before_compact", async (event) => {
19266
+ // Automatic pre-prompt compaction is already inside the SDK run, even
19267
+ // before agent_start. Manual compaction alone grants no main-run target.
19268
+ if (event.reason !== "manual" && !studioRunLifecycle.snapshot().run) {
19269
+ studioRunLifecycle.start(activeRequest);
19270
+ agentBusy = true;
19271
+ resetStudioTraceForRun();
19272
+ }
19273
+ if (studioRunLifecycle.snapshot().run?.stopping) return { cancel: true };
19274
+ studioRunLifecycle.compactStart(event);
19275
+ broadcastState();
19276
+ });
19277
+ pi.on("session_compact", async () => { studioRunLifecycle.compactEnd(); refreshContextUsage(); broadcastState(); });
19278
+ pi.on("session_compact_failed", async () => { studioRunLifecycle.compactEnd(); broadcastState(); });
19279
+ pi.on("before_provider_request", async (_event, ctx) => {
19280
+ // Also fence delayed observer dispatch before a continued agent's
19281
+ // provider request. This does not pretend to abort the SDK compactor.
19282
+ if (studioRunLifecycle.snapshot().run?.stopping) ctx.abort();
19283
+ });
19284
+
19285
+ pi.on("agent_start", async (_event, ctx) => {
19286
+ // ctx.abort does not cancel an already-running SDK compactor. Fence an
19287
+ // automatic continuation of a stopped run at its fresh agent signal.
19288
+ if (studioRunLifecycle.snapshot().run?.stopping) ctx.abort();
19289
+ const continuingRun = studioRunLifecycle.snapshot().run !== null;
19290
+ studioRunLifecycle.start(activeRequest);
18405
19291
  agentBusy = true;
18406
- resetStudioTraceForRun();
19292
+ if (!continuingRun) resetStudioTraceForRun();
18407
19293
  emitDebugEvent("agent_start", { activeRequestId: activeRequest?.id ?? null, activeRequestKind: activeRequest?.kind ?? null });
18408
19294
  setTerminalActivity("running");
18409
19295
  });
@@ -18487,6 +19373,7 @@ export default function (pi: ExtensionAPI) {
18487
19373
  const message = event.message as { stopReason?: string; role?: string };
18488
19374
  const stopReason = typeof message.stopReason === "string" ? message.stopReason : "";
18489
19375
  const role = typeof message.role === "string" ? message.role : "";
19376
+ if (role === "assistant" && studioRunLifecycle.snapshot().run) studioRunLastStopReason = stopReason || null;
18490
19377
  const markdown = extractAssistantText(event.message);
18491
19378
  const thinking = extractAssistantThinking(event.message);
18492
19379
  emitDebugEvent("message_end", {
@@ -18503,6 +19390,8 @@ export default function (pi: ExtensionAPI) {
18503
19390
  if (role === "user") {
18504
19391
  const userPrompt = extractUserText(event.message);
18505
19392
  pendingTurnPrompt = userPrompt;
19393
+ const inputIndex = studioPromptInputs.findIndex(input => input.text === normalizePromptText(userPrompt));
19394
+ const registeredInput = inputIndex >= 0 ? studioPromptInputs.splice(inputIndex, 1)[0] : null;
18506
19395
  const activatedQueuedRequest = activateQueuedStudioDirectRequestForPrompt(userPrompt);
18507
19396
  if (activatedQueuedRequest) {
18508
19397
  emitDebugEvent("activate_queued_request", {
@@ -18516,6 +19405,16 @@ export default function (pi: ExtensionAPI) {
18516
19405
  } else {
18517
19406
  pendingStudioPromptMetadata = null;
18518
19407
  }
19408
+ // Informational input receipt is independent of response/consent ownership:
19409
+ // steering can register while the original response request stays active.
19410
+ const receiptRequestId = registeredInput?.requestId ?? (activeRequest?.kind === "direct" ? activeRequest.id : null);
19411
+ if (receiptRequestId) {
19412
+ const timestamp = (event.message as { timestamp?: unknown }).timestamp;
19413
+ const studioOwned = !studioPromptInputProvenanceLost && registeredInput?.requestId === receiptRequestId;
19414
+ if (studioRunLifecycle.snapshot().run) studioRunInputRequestIds.add(receiptRequestId);
19415
+ broadcast({ type: "prompt_submitted", requestId: receiptRequestId, studioOwned,
19416
+ sentAt: typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0 ? timestamp : Date.now() });
19417
+ }
18519
19418
  return;
18520
19419
  }
18521
19420
 
@@ -18571,7 +19470,8 @@ export default function (pi: ExtensionAPI) {
18571
19470
  const responseTimestamp = latestItem?.timestamp ?? Date.now();
18572
19471
  const responseThinking = latestItem?.thinking ?? thinking ?? null;
18573
19472
  pendingTurnPrompt = null;
18574
- setStudioTraceRunStatus("complete");
19473
+ // Only settlement ends a tracked run; a message end may precede retry or compaction.
19474
+ if (!studioRunLifecycle.snapshot().run) setStudioTraceRunStatus("complete");
18575
19475
  if (latestItem) {
18576
19476
  storeStudioTraceSnapshotForResponse(latestItem.id);
18577
19477
  }
@@ -18600,6 +19500,8 @@ export default function (pi: ExtensionAPI) {
18600
19500
  thinking: lastStudioResponse.thinking,
18601
19501
  timestamp: lastStudioResponse.timestamp,
18602
19502
  responseHistory: studioResponseHistory,
19503
+ runtimeActivity: studioRunLifecycle.snapshot(),
19504
+ busy: isStudioBusy(),
18603
19505
  });
18604
19506
  broadcastResponseHistory();
18605
19507
  pendingStudioCompletionKind = kind;
@@ -18627,11 +19529,26 @@ export default function (pi: ExtensionAPI) {
18627
19529
  thinking: lastStudioResponse.thinking,
18628
19530
  timestamp: lastStudioResponse.timestamp,
18629
19531
  responseHistory: studioResponseHistory,
19532
+ runtimeActivity: studioRunLifecycle.snapshot(),
19533
+ busy: isStudioBusy(),
18630
19534
  });
18631
19535
  broadcastResponseHistory();
18632
19536
  });
18633
19537
 
18634
- pi.on("agent_end", async () => {
19538
+ // agent_end precedes SDK retry/compaction/queued continuation. It is not
19539
+ // final settlement and must not retire a cancellable Studio run.
19540
+ pi.on("agent_settled", async () => {
19541
+ const settlingRun = studioRunLifecycle.snapshot().run;
19542
+ const stopping = settlingRun?.stopping === true;
19543
+ // Settlement, not Stop delivery or a model toolUse pause, decides the outcome.
19544
+ const lastStop = studioRunLastStopReason, owned = settlingRun?.origin === "studio";
19545
+ const ended = lastStop === "stop" || lastStop === "length";
19546
+ const outcome: StudioRunOutcome = stopping || lastStop === "aborted" ? "stopped"
19547
+ : activeRequest || lastStop === "error" || (owned && !ended) ? "failed" : "completed";
19548
+ const outcomeRequestId = settlingRun?.requestId ?? activeRequest?.id ?? null;
19549
+ const outcomeRequestIds = [...new Set([outcomeRequestId, ...studioRunInputRequestIds].filter((id): id is string => Boolean(id)))];
19550
+ studioRunLastStopReason = null; studioRunInputRequestIds.clear();
19551
+ studioRunLifecycle.settled();
18635
19552
  agentBusy = false;
18636
19553
  pendingTurnPrompt = null;
18637
19554
  pendingStudioPromptMetadata = null;
@@ -18649,10 +19566,13 @@ export default function (pi: ExtensionAPI) {
18649
19566
  });
18650
19567
  clearStudioDirectRunState();
18651
19568
  setTerminalActivity("idle");
18652
- setStudioTraceRunStatus("complete");
19569
+ setStudioTraceRunStatus(outcome === "stopped" ? "stopped" : outcome === "failed" ? "error" : "complete");
19570
+ if (settlingRun && outcomeRequestId) {
19571
+ broadcast({ type: "run_settled", runId: settlingRun.id, requestId: outcomeRequestId, requestIds: outcomeRequestIds, outcome, settledAt: Date.now() });
19572
+ }
18653
19573
  if (activeRequest) {
18654
19574
  const requestId = activeRequest.id;
18655
- broadcast({
19575
+ if (!stopping) broadcast({
18656
19576
  type: "error",
18657
19577
  requestId,
18658
19578
  message: "Request ended without a complete assistant response.",
@@ -18667,8 +19587,10 @@ export default function (pi: ExtensionAPI) {
18667
19587
  });
18668
19588
 
18669
19589
  pi.on("session_shutdown", async () => {
19590
+ studioRunLastStopReason = null; studioRunInputRequestIds.clear();
18670
19591
  lastCommandCtx = null;
18671
19592
  latestModelRequestCtx = null;
19593
+ studioRunLifecycle.settled();
18672
19594
  agentBusy = false;
18673
19595
  clearStudioDirectRunState();
18674
19596
  clearPendingStudioCompletion();
@@ -19075,7 +19997,7 @@ export default function (pi: ExtensionAPI) {
19075
19997
  themeVars,
19076
19998
  },
19077
19999
  );
19078
- await writeFile(source.outputPath, html);
20000
+ await writeClassicStudioHtml(source.outputPath, html, ctx.cwd);
19079
20001
  const openError = await maybeOpenStudioExportPath(source.outputPath, params.open);
19080
20002
  return { ok: true, format: "html", path: source.outputPath, source: source.sourceLabel, bytes: html.length, warning, openError: openError ?? undefined };
19081
20003
  } catch (error) {
@@ -19083,6 +20005,147 @@ export default function (pi: ExtensionAPI) {
19083
20005
  }
19084
20006
  };
19085
20007
 
20008
+ const openHostedStudioView = async (
20009
+ launchArgs: string, ctx: ExtensionCommandContext, flags: StudioLaunchFlags,
20010
+ options?: { defaultSource?: "blank" | "last-response"; commandLabel?: string },
20011
+ ) => {
20012
+ if (hasConnectedFullStudioView() && serverState) {
20013
+ ctx.ui.notify("The existing full Studio was kept. Use its opening controls, or /studio-replace to move it here if you can't find it.", "warning");
20014
+ ctx.ui.notify(`Studio URL: ${currentFullStudioUrl(serverState)}`, "info");
20015
+ return;
20016
+ }
20017
+ // Oliver, 14 Oct: plain /studio reopens the last workspace when its tab is gone, asking to
20018
+ // resume only if that tab didn't leave cleanly. --blank and --last start afresh; a file finds its owner.
20019
+ const lastWorkspace = lastHostedFullWorkspaceId;
20020
+ if (!launchArgs && serverState && lastWorkspace && studioBufferStateStore.hostedWorkspace(lastWorkspace)) {
20021
+ const state = serverState;
20022
+ const url = buildStudioUrl(state.port, state.token, "full", undefined, undefined, { hostedWorkspaceId: lastWorkspace });
20023
+ const clean = studioBufferStateStore.hasCleanLeave(lastWorkspace);
20024
+ try {
20025
+ if (shouldAutoOpenStudioBrowser(flags)) await openStudioUrlInBrowser(url);
20026
+ ctx.ui.notify(clean ? "Opening your last Studio workspace." : "Opening your last Studio workspace. Studio asks before resuming, in case its tab had newer edits.", "info");
20027
+ } catch (error) {
20028
+ ctx.ui.notify(`Browser opening failed; the workspace is kept: ${error instanceof Error ? error.message : String(error)}`, "warning");
20029
+ } finally {
20030
+ ctx.ui.notify(`Studio URL: ${url}`, "info");
20031
+ const hint = state.listenAll ? null : buildStudioSshTunnelHint(state.port, url)
20032
+ ?? (flags.noBrowser ? buildStudioForwardingHint(state.port, url, { prefix: "Browser auto-open was skipped because --no-browser was used." }) : null);
20033
+ if (hint) ctx.ui.notify(hint, "info");
20034
+ if (state.listenAll) ctx.ui.notify(buildStudioListenAllWarning(state.port, url), "warning");
20035
+ }
20036
+ return;
20037
+ }
20038
+ let path: string | undefined;
20039
+ if (launchArgs && !["--blank", "blank", "--last", "last"].includes(launchArgs) && !launchArgs.startsWith("-")) {
20040
+ const argument = parsePathArgument(launchArgs);
20041
+ if (!argument) { ctx.ui.notify("Invalid file path argument.", "error"); return; }
20042
+ const resolved = resolveStudioPath(argument, ctx.cwd);
20043
+ if (!resolved.ok) { ctx.ui.notify(resolved.message, "error"); return; }
20044
+ path = resolved.resolved;
20045
+ }
20046
+ try {
20047
+ const state = await ensureServer(flags.port, flags.listenAll);
20048
+ const result = studioBufferStateStore.hostingBootstrap({ path }, () => {
20049
+ const selection = resolveStudioLaunchDocument(launchArgs, ctx, options);
20050
+ if (!selection) return { ok: false, message: "The initial document was not loaded." };
20051
+ return { ok: true, document: selection.document,
20052
+ editorLanguage: selection.document.path ? inferStudioPdfLanguageFromPath(selection.document.path) || "markdown" : "markdown" };
20053
+ });
20054
+ if (!result.ok || !("owner" in result) || !result.owner) {
20055
+ ctx.ui.notify("message" in result ? result.message : "The workspace could not be registered. Existing work was kept.", "warning"); return;
20056
+ }
20057
+ const owner = result.owner;
20058
+ if (result.status === "created" && "document" in result) {
20059
+ initialStudioDocument = result.document;
20060
+ recordStudioDocumentResourceGrants(result.document, ctx.cwd);
20061
+ }
20062
+ if (owner.mode === "full") lastHostedFullWorkspaceId = owner.workspaceId;
20063
+ const url = buildStudioUrl(state.port, state.token, owner.mode as StudioUiMode, undefined, undefined, { hostedWorkspaceId: owner.workspaceId });
20064
+ const live = [...hostedConnections].filter(([socket, value]) => value.workspaceId === owner.workspaceId && socket.readyState === WebSocket.OPEN);
20065
+ try {
20066
+ if (live.length) {
20067
+ for (const [socket] of live) sendToClient(socket, { type: "document_hosting_focus", bufferId: owner.bufferId });
20068
+ ctx.ui.notify("Requested focus in the existing editor. Its unsaved work was kept; no independent copy was opened.", "info");
20069
+ } else if (shouldAutoOpenStudioBrowser(flags)) {
20070
+ await openStudioUrlInBrowser(url);
20071
+ ctx.ui.notify(result.status !== "reused" ? "Opened a registered Studio workspace." : studioBufferStateStore.hasCleanLeave(owner.workspaceId) ? "Opening the retained workspace."
20072
+ : "Opening the retained owner. Confirm resuming if its previous browser may still contain newer work.", "info");
20073
+ } else ctx.ui.notify("Studio workspace is registered. Browser auto-open was skipped; use the same URL to open or resume it.", "info");
20074
+ } catch (error) {
20075
+ ctx.ui.notify(`Browser opening failed; the same workspace is retained: ${error instanceof Error ? error.message : String(error)}`, "warning");
20076
+ } finally {
20077
+ ctx.ui.notify(`Studio URL: ${url}`, "info");
20078
+ const hint = state.listenAll ? null : buildStudioSshTunnelHint(state.port, url)
20079
+ ?? (flags.noBrowser ? buildStudioForwardingHint(state.port, url, { prefix: "Browser auto-open was skipped because --no-browser was used." }) : null);
20080
+ if (hint) ctx.ui.notify(hint, "info");
20081
+ if (state.listenAll) ctx.ui.notify(buildStudioListenAllWarning(state.port, url), "warning");
20082
+ }
20083
+ } catch (error) { ctx.ui.notify(`Studio launch failed; existing work was kept: ${error instanceof Error ? error.message : String(error)}`, "error"); }
20084
+ };
20085
+
20086
+ // /studio --single and --dual: remember the choice, restarting Studio if it's running in
20087
+ // the other mode. Works when the page won't load; open tabs are disconnected, as by --stop.
20088
+ // What a restart would drop that no open tab shows needs a yes first, and the yes covers
20089
+ // only what was named: the final checks rerun inside the lifecycle turn (Sol, candidate69).
20090
+ const studioEditorModeCommandStake = (mode: "single" | "dual"): { key: string; message: string } | null => {
20091
+ if (!serverState || mode === STUDIO_EDITOR_FLAGS.mode) return null;
20092
+ const parts: string[] = [], key: Record<string, unknown> = {};
20093
+ if (STUDIO_DOCUMENT_HOSTING_ENABLED) {
20094
+ const work = studioBufferStateStore.retainedWork();
20095
+ const live = new Set([...hostedConnections].filter(([socket]) => socket.readyState === WebSocket.OPEN).map(([, value]) => value.workspaceId));
20096
+ const offline = work.unsaved.filter(id => !live.has(id)).sort();
20097
+ if (work.pending || offline.length) {
20098
+ parts.push("Studio holds unsaved text that isn't open in a tab, or a save or move hasn't finished. Restarting Studio loses it.");
20099
+ key.work = { pending: work.pendingKeys, offline: offline.map(id => [id, studioBufferStateStore.hostedWorkspace(id)?.revision ?? null]) };
20100
+ }
20101
+ }
20102
+ const side = studioSideQuestionRuntime?.publicState;
20103
+ if (side?.threadId) {
20104
+ parts.push("Side questions are cleared.");
20105
+ key.side = [side.threadId, side.messages.length, side.status];
20106
+ }
20107
+ return parts.length ? { key: JSON.stringify(key), message: parts.join(" ") + " Switch anyway?" } : null;
20108
+ };
20109
+ const chooseStudioEditorMode = async (mode: "single" | "dual", flags: StudioLaunchFlags, ctx: ExtensionCommandContext): Promise<boolean> => {
20110
+ const label = studioEditorModeLabel(mode);
20111
+ const save = () => {
20112
+ try { writeStudioEditorMode(STUDIO_EDITOR_MODE_PATH, mode); return true; }
20113
+ catch (error) { ctx.ui.notify(`Couldn't save the editor mode setting: ${error instanceof Error ? error.message : String(error)}`, "error"); return false; }
20114
+ };
20115
+ let consented: string | null = null;
20116
+ for (;;) {
20117
+ if (STUDIO_EDITOR_FLAGS.forced) { ctx.ui.notify("Studio's editor mode is set by PI_STUDIO_* environment variables here. Unset them to choose a mode.", "warning"); return false; }
20118
+ const stake = studioEditorModeCommandStake(mode);
20119
+ if (stake && stake.key !== consented) {
20120
+ if (!await ctx.ui.confirm(`Switch to ${label}?`, stake.message)) { ctx.ui.notify("Nothing changed.", "info"); return false; }
20121
+ consented = stake.key;
20122
+ continue; // Anything may have changed while asking.
20123
+ }
20124
+ const outcome = await withStudioLifecycle(async (): Promise<"done" | "failed" | "changed"> => {
20125
+ if (STUDIO_EDITOR_FLAGS.forced) return "changed";
20126
+ const now = studioEditorModeCommandStake(mode);
20127
+ if (now && now.key !== consented) return "changed";
20128
+ if (mode === STUDIO_EDITOR_FLAGS.mode) return save() ? "done" : "failed";
20129
+ if (!serverState) {
20130
+ if (!save()) return "failed";
20131
+ applyStudioEditorMode(mode);
20132
+ ctx.ui.notify(`Studio is now in ${label}.`, "info");
20133
+ return "done";
20134
+ }
20135
+ if (isStudioBusy() || isStudioSideQuestionBusy()) { ctx.ui.notify("Wait for Pi to finish, then switch.", "warning"); return "failed"; }
20136
+ if (!save()) return "failed";
20137
+ const previous = serverState, open = getStudioClientCounts();
20138
+ flags.port ??= previous.port;
20139
+ flags.listenAll ||= previous.listenAll;
20140
+ lastCommandCtx = ctx;
20141
+ if (!await restartStudioInEditorModeNow(mode, flags.port, flags.listenAll, createSessionToken(), null)) return "failed";
20142
+ if (open.full + open.editorOnly) ctx.ui.notify("Studio's open tabs were disconnected. Their text is still on screen.", "warning");
20143
+ return "done";
20144
+ });
20145
+ if (outcome !== "changed") return outcome === "done";
20146
+ }
20147
+ };
20148
+
19086
20149
  const openStudioView = async (
19087
20150
  trimmed: string,
19088
20151
  ctx: ExtensionCommandContext,
@@ -19095,6 +20158,7 @@ export default function (pi: ExtensionAPI) {
19095
20158
  return;
19096
20159
  }
19097
20160
  const launchArgs = launchOpenFlags.args;
20161
+ if (launchOpenFlags.editorMode && !await chooseStudioEditorMode(launchOpenFlags.editorMode, launchOpenFlags, ctx)) return;
19098
20162
  if (serverState && launchOpenFlags.port && serverState.port !== launchOpenFlags.port) {
19099
20163
  ctx.ui.notify(`Studio server is already running on port ${serverState.port}; requested port ${launchOpenFlags.port}. Use /studio --stop, then restart Studio with --port ${launchOpenFlags.port} to change it.`, "warning");
19100
20164
  }
@@ -19113,13 +20177,38 @@ export default function (pi: ExtensionAPI) {
19113
20177
  }
19114
20178
  const launchesWatchedTextPreview = Boolean(launchOpenFlags.watchPdf && parsedLaunchPath && !launchesPdfPreview);
19115
20179
  const requestedLaunchMode: StudioUiMode = launchesPdfPreview || launchesWatchedTextPreview ? "editor-only" : mode;
20180
+ if (STUDIO_DOCUMENT_HOSTING_ENABLED && launchesPdfPreview && !launchOpenFlags.watchPdf && !options?.replaceExistingFull) {
20181
+ await ctx.waitForIdle();
20182
+ try {
20183
+ const target = parseStudioPdfLaunchTarget(normalizePathInput(parsedLaunchPath!));
20184
+ if (!target) throw new Error("A local PDF is required.");
20185
+ // The explicit terminal argument approves exactly this file, never its
20186
+ // folder or an editable owner. Existing full-workspace buffers stay put.
20187
+ const grant = studioResourceGrantRegistry.grantFile(decodeStudioLocalPreviewResourceReference(target.path), { cwd: ctx.cwd, source: "command-media" });
20188
+ // The grant is canonical; this resolver accepts a URI reference.
20189
+ const pdf = resolveStudioPdfResourcePath(encodeURIComponent(grant.path), undefined, undefined, ctx.cwd, studioResourceGrantRegistry);
20190
+ const state = await ensureServer(launchOpenFlags.port, launchOpenFlags.listenAll);
20191
+ const url = `http://127.0.0.1:${state.port}` + buildStudioReadOnlyMediaUrl({ token: state.token, kind: "pdf", path: pdf, resourceDir: ctx.cwd, page: target.page });
20192
+ const automatic = shouldAutoOpenStudioBrowser({ openRemoteBrowser: launchOpenFlags.openRemoteBrowser, noBrowser: launchOpenFlags.noBrowser });
20193
+ if (automatic) await openStudioUrlInBrowser(url);
20194
+ ctx.ui.notify(`Read-only PDF preview${automatic ? " requested" : " ready; browser auto-open skipped"}: ${url}`, "info");
20195
+ const hint = state.listenAll ? null : buildStudioSshTunnelHint(state.port, url) ?? (launchOpenFlags.noBrowser ? buildStudioForwardingHint(state.port, url) : null);
20196
+ if (hint) ctx.ui.notify(hint, "info");
20197
+ } catch (error) { ctx.ui.notify(`PDF preview failed: ${error instanceof Error ? error.message : String(error)}`, "error"); }
20198
+ return;
20199
+ }
20200
+ const hostingError = studioHostingCommandError({ enabled: STUDIO_DOCUMENT_HOSTING_ENABLED, mode: requestedLaunchMode,
20201
+ replace: options?.replaceExistingFull, watchedText: launchesWatchedTextPreview });
20202
+ if (hostingError) { ctx.ui.notify(hostingError, "warning"); return; }
19116
20203
  if (requestedLaunchMode === "full" && hasConnectedFullStudioView()) {
19117
20204
  if (options?.replaceExistingFull) {
19118
20205
  closeStudioClientsByMode("full", 4001, "Full Studio replaced");
19119
20206
  } else {
19120
- ctx.ui.notify("A full pi Studio view is already open for this session. Close it first, use /studio-replace for a fresh full Studio view, or use /studio-editor-only for a concurrent editor-only Studio view.", "warning");
20207
+ ctx.ui.notify(STUDIO_DOCUMENT_HOSTING_ENABLED
20208
+ ? "A full Studio view is already open. Use its Open Document or New editor tab controls; existing work was kept."
20209
+ : "A full pi Studio view is already open for this session. Close it first, use /studio-replace for a fresh full Studio view, or use /studio-editor-only for a concurrent editor-only Studio view.", "warning");
19121
20210
  if (serverState) {
19122
- const url = buildStudioUrl(serverState.port, serverState.token, "full");
20211
+ const url = currentFullStudioUrl(serverState);
19123
20212
  ctx.ui.notify(`Studio URL: ${url}`, "info");
19124
20213
  const tunnelHint = serverState.listenAll
19125
20214
  ? null
@@ -19147,6 +20236,10 @@ export default function (pi: ExtensionAPI) {
19147
20236
  // ignore theme read errors
19148
20237
  }
19149
20238
 
20239
+ if (STUDIO_DOCUMENT_HOSTING_ENABLED && requestedLaunchMode === "full") {
20240
+ await openHostedStudioView(launchArgs, ctx, launchOpenFlags, options);
20241
+ return;
20242
+ }
19150
20243
  const selection = resolveStudioLaunchDocument(launchArgs, ctx, {
19151
20244
  ...options,
19152
20245
  watchPdf: launchOpenFlags.watchPdf && launchesPdfPreview,
@@ -19220,7 +20313,7 @@ export default function (pi: ExtensionAPI) {
19220
20313
  };
19221
20314
 
19222
20315
  pi.registerCommand("studio", {
19223
- description: "Open pi Studio browser UI or a read-only watched preview (/studio, /studio <file>, /studio --watch <file>, /studio --blank, /studio --last, /studio --no-browser, /studio --listen-all)",
20316
+ description: "Open pi Studio browser UI or a read-only watched preview (/studio, /studio <file>, /studio --watch <file>, /studio --blank, /studio --last, /studio --no-browser, /studio --listen-all, /studio --single, /studio --dual)",
19224
20317
  handler: async (args: string, ctx: ExtensionCommandContext) => {
19225
20318
  const trimmed = args.trim();
19226
20319
 
@@ -19236,7 +20329,7 @@ export default function (pi: ExtensionAPI) {
19236
20329
  return;
19237
20330
  }
19238
20331
  const counts = getStudioClientCounts();
19239
- const url = buildStudioUrl(serverState.port, serverState.token, "full");
20332
+ const url = currentFullStudioUrl(serverState);
19240
20333
  ctx.ui.notify(
19241
20334
  `Studio running at ${url} (listening on ${serverState.bindHost}:${serverState.port}; busy: ${isStudioBusy() ? "yes" : "no"}; full views: ${counts.full}; editor-only views: ${counts.editorOnly})`,
19242
20335
  "info",
@@ -19250,7 +20343,7 @@ export default function (pi: ExtensionAPI) {
19250
20343
  if (trimmed === "help" || trimmed === "--help" || trimmed === "-h") {
19251
20344
  ctx.ui.notify(
19252
20345
  "Usage: /studio [path|--blank|--last]\n"
19253
- + " /studio Open studio with last model response (fallback: blank)\n"
20346
+ + " /studio Open studio with last model response (fallback: blank); in dual editor mode, reopen your last workspace if its tab is gone\n"
19254
20347
  + " /studio <path> Open a text file in Studio, or a PDF in a read-only companion preview\n"
19255
20348
  + " /studio --watch <file> Open a read-only text preview that follows disk changes, or a PDF with auto-refresh\n"
19256
20349
  + " /studio --blank Open with blank editor\n"
@@ -19261,6 +20354,8 @@ export default function (pi: ExtensionAPI) {
19261
20354
  + " /studio --open-remote Over SSH, open the remote browser anyway\n"
19262
20355
  + " /studio --status Show studio status\n"
19263
20356
  + " /studio --stop Stop studio server\n"
20357
+ + " /studio --single Use single editor mode (one editor), and remember it\n"
20358
+ + " /studio --dual Use dual editor mode (Prompt and Document), the default\n"
19264
20359
  + " Note: only one full /studio view is allowed per Pi session; PDF previews open as companions.\n"
19265
20360
  + " /studio-replace [path] Replace the current full Studio view with a new one\n"
19266
20361
  + " /studio-editor-only [path] Open another Studio tab in editor-only mode\n"
@@ -19290,12 +20385,14 @@ export default function (pi: ExtensionAPI) {
19290
20385
  + " /studio-replace --no-browser Print URL without opening a browser\n"
19291
20386
  + " /studio-replace --port <port> Bind Studio to a fixed port when starting\n"
19292
20387
  + " /studio-replace --listen-all Explicitly bind to 0.0.0.0 instead of localhost\n"
19293
- + "Editor-only Studio views stay open.",
20388
+ + "Editor-only Studio views stay open.\n"
20389
+ + "In dual editor mode, /studio-replace moves the open Studio (or the last one) to a new tab here: the old tab hands over its text.",
19294
20390
  "info",
19295
20391
  );
19296
20392
  return;
19297
20393
  }
19298
20394
 
20395
+ if (STUDIO_DOCUMENT_HOSTING_ENABLED) { await moveHostedStudioHere(trimmed, ctx); return; }
19299
20396
  await openStudioView(trimmed, ctx, "full", {
19300
20397
  defaultSource: "last-response",
19301
20398
  commandLabel: "/studio-replace",
@@ -19535,7 +20632,7 @@ export default function (pi: ExtensionAPI) {
19535
20632
  themeVars,
19536
20633
  },
19537
20634
  );
19538
- await writeFile(outputPath, html);
20635
+ await writeClassicStudioHtml(outputPath, html, ctx.cwd);
19539
20636
 
19540
20637
  let openError: string | null = null;
19541
20638
  try {
@@ -19603,7 +20700,7 @@ export default function (pi: ExtensionAPI) {
19603
20700
  themeVars,
19604
20701
  },
19605
20702
  );
19606
- await writeFile(outputPath, html);
20703
+ await writeClassicStudioHtml(outputPath, html, ctx.cwd);
19607
20704
 
19608
20705
  let openError: string | null = null;
19609
20706
  try {
@@ -19647,6 +20744,8 @@ export default function (pi: ExtensionAPI) {
19647
20744
  return;
19648
20745
  }
19649
20746
 
20747
+ const hostingError = studioHostingCommandError({ enabled: STUDIO_DOCUMENT_HOSTING_ENABLED, mode: "full", current: true });
20748
+ if (hostingError) { ctx.ui.notify(hostingError, "warning"); return; }
19650
20749
  const file = readStudioFile(pathArg, ctx.cwd);
19651
20750
  if (file.ok === false) {
19652
20751
  ctx.ui.notify(file.message, "error");