pi-studio 0.9.52 → 0.9.54

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.
package/index.ts CHANGED
@@ -23,6 +23,12 @@ import {
23
23
  transformStudioMarkdownOutsideFences,
24
24
  } from "./shared/studio-annotation-scanner.js";
25
25
  import { stripStudioMarkdownHtmlComments } from "./shared/studio-markdown-html-comments.js";
26
+ import { createStudioFileWatcher } from "./shared/studio-file-watcher.js";
27
+ import {
28
+ readStudioDiskFileSnapshot,
29
+ saveStudioDiskFileAs,
30
+ saveStudioDiskFileIfRevision,
31
+ } from "./shared/studio-disk-revisions.js";
26
32
  import { normalizeStudioMarkdownSmartFences } from "./shared/studio-markdown-fences.js";
27
33
  import {
28
34
  extractStandaloneLatexDefinitionsFromMarkdown,
@@ -32,6 +38,7 @@ import { escapeStudioPdfLatexTextFragment } from "./shared/studio-pdf-escape.js"
32
38
  import { parseStudioLocalPreviewPage, parseStudioPdfLaunchTarget } from "./shared/studio-local-preview-path.js";
33
39
  import { resolveStudioPdfResourceFile } from "./shared/studio-pdf-resource.js";
34
40
  import { createStudioPandocHtmlResourceFlagResolver } from "./shared/studio-pandoc-resource-flag.js";
41
+ import { createStudioResourceGrantRegistry } from "./shared/studio-resource-grants.js";
35
42
  import { prepareStudioLatexForPandoc } from "./shared/studio-latex-pandoc-compat.js";
36
43
  import { isStudioCmuxSession, openStudioUrlInBrowser } from "./shared/studio-browser-launcher.js";
37
44
  import { buildStudioReplTmuxStartArgs } from "./shared/studio-repl-tmux.js";
@@ -75,6 +82,7 @@ import {
75
82
  } from "./shared/studio-side-question.js";
76
83
  import {
77
84
  STUDIO_SIDE_CONTEXT_MAX_OUTPUT_CHARS,
85
+ assertStudioSideQuestionRootStable,
78
86
  formatStudioSideQuestionContextMap,
79
87
  listStudioSideQuestionContext,
80
88
  readStudioSideQuestionContextText,
@@ -312,11 +320,24 @@ interface InitialStudioDocument {
312
320
  path?: string;
313
321
  draftId?: string;
314
322
  resourceDir?: string;
323
+ diskRevision?: string;
324
+ watchFile?: boolean;
315
325
  }
316
326
 
327
+ type StudioDiskWriteResult =
328
+ | { ok: true; path: string; revision: string; size: number }
329
+ | {
330
+ ok: false;
331
+ conflict?: boolean;
332
+ reason?: string;
333
+ path?: string;
334
+ currentRevision?: string | null;
335
+ message: string;
336
+ };
337
+
317
338
  interface StudioLaunchSelection {
318
339
  document: InitialStudioDocument;
319
- kind: "document" | "pdf-preview";
340
+ kind: "document" | "pdf-preview" | "watched-preview";
320
341
  mode?: StudioUiMode;
321
342
  transient?: boolean;
322
343
  skipWorkspaceRestore?: boolean;
@@ -451,6 +472,8 @@ interface StudioSideQuestionToolDescriptor {
451
472
  gateway: boolean;
452
473
  }
453
474
 
475
+ type StudioSideQuestionContextRootInput = Pick<StudioSideQuestionContextInput, "sourcePath" | "resourceDir" | "gatherScope" | "contextPath">;
476
+
454
477
  interface StudioSideQuestionContextInput {
455
478
  focusKind: StudioSideQuestionFocusKind;
456
479
  focusLabel: string;
@@ -715,12 +738,17 @@ interface SaveAsRequestMessage {
715
738
  requestId: string;
716
739
  path: string;
717
740
  content: string;
741
+ overwrite?: boolean;
742
+ expectedRevision?: string;
718
743
  }
719
744
 
720
745
  interface SaveOverRequestMessage {
721
746
  type: "save_over_request";
722
747
  requestId: string;
748
+ path: string;
723
749
  content: string;
750
+ expectedRevision?: string;
751
+ force?: boolean;
724
752
  }
725
753
 
726
754
  interface RefreshFromDiskRequestMessage {
@@ -729,6 +757,12 @@ interface RefreshFromDiskRequestMessage {
729
757
  path?: string;
730
758
  }
731
759
 
760
+ interface WatchFileSubscribeMessage {
761
+ type: "watch_file_subscribe";
762
+ path: string;
763
+ revision?: string;
764
+ }
765
+
732
766
  interface SendToEditorRequestMessage {
733
767
  type: "send_to_editor_request";
734
768
  requestId: string;
@@ -817,6 +851,7 @@ type IncomingStudioMessage =
817
851
  | SaveAsRequestMessage
818
852
  | SaveOverRequestMessage
819
853
  | RefreshFromDiskRequestMessage
854
+ | WatchFileSubscribeMessage
820
855
  | SendToEditorRequestMessage
821
856
  | GetFromEditorRequestMessage
822
857
  | QuartoPreviewCheckRequestMessage
@@ -829,6 +864,7 @@ type IncomingStudioMessage =
829
864
 
830
865
  const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
831
866
  const PREVIEW_RENDER_MAX_CHARS = 400_000;
867
+ const STUDIO_WATCHED_FILE_MAX_CLIENTS = 16;
832
868
  const STUDIO_COMPLETION_MAX_TEXT_CHARS = 250_000;
833
869
  const STUDIO_COMPLETION_MAX_CONTEXT_CHARS = 12_000;
834
870
  const STUDIO_COMPLETION_PREFIX_CHARS = 12_000;
@@ -2459,8 +2495,8 @@ function resolveStudioPath(pathArg: string, cwd: string): { ok: true; resolved:
2459
2495
  return { ok: true, resolved, label: normalized };
2460
2496
  }
2461
2497
 
2462
- function readStudioFile(pathArg: string, cwd: string):
2463
- | { ok: true; text: string; label: string; resolvedPath: string }
2498
+ function readStudioFile(pathArg: string, cwd: string, options?: { requireCanonicalPath?: boolean }):
2499
+ | { ok: true; text: string; label: string; resolvedPath: string; diskRevision: string }
2464
2500
  | { ok: false; message: string } {
2465
2501
  const resolved = resolveStudioPath(pathArg, cwd);
2466
2502
  if (resolved.ok === false) {
@@ -2468,39 +2504,41 @@ function readStudioFile(pathArg: string, cwd: string):
2468
2504
  }
2469
2505
 
2470
2506
  try {
2471
- const stats = statSync(resolved.resolved);
2472
- if (!stats.isFile()) {
2473
- return { ok: false, message: `Path is not a file: ${resolved.label}` };
2474
- }
2475
- } catch (error) {
2476
- return {
2477
- ok: false,
2478
- message: `Could not access file: ${resolved.label} (${error instanceof Error ? error.message : String(error)})`,
2479
- };
2480
- }
2481
-
2482
- try {
2483
- // Read raw bytes first to detect binary content before UTF-8 decode
2484
- const buf = readFileSync(resolved.resolved);
2485
- // Heuristic: check the first 8KB for binary indicators
2486
- const sample = buf.subarray(0, 8192);
2507
+ const snapshot = readStudioDiskFileSnapshot(resolved.resolved);
2508
+ if (options?.requireCanonicalPath) {
2509
+ const requestedPath = resolve(resolved.resolved);
2510
+ const canonicalPath = resolve(snapshot.path);
2511
+ const pathsMatch = process.platform === "win32"
2512
+ ? requestedPath.toLowerCase() === canonicalPath.toLowerCase()
2513
+ : requestedPath === canonicalPath;
2514
+ if (!pathsMatch) {
2515
+ return { ok: false, message: `Could not read file: ${resolved.label} (the file location now resolves somewhere else)` };
2516
+ }
2517
+ }
2518
+ // Heuristic: check the first 8KB for binary indicators.
2519
+ const sample = snapshot.buffer.subarray(0, 8192);
2487
2520
  let nulCount = 0;
2488
2521
  let controlCount = 0;
2489
2522
  for (let i = 0; i < sample.length; i++) {
2490
2523
  const b = sample[i];
2491
2524
  if (b === 0x00) nulCount++;
2492
- // Control chars excluding tab (0x09), newline (0x0A), carriage return (0x0D)
2525
+ // Control chars excluding tab (0x09), newline (0x0A), carriage return (0x0D).
2493
2526
  else if (b < 0x08 || (b > 0x0D && b < 0x20 && b !== 0x1B)) controlCount++;
2494
2527
  }
2495
2528
  if (nulCount > 0 || (sample.length > 0 && controlCount / sample.length > 0.1)) {
2496
2529
  return { ok: false, message: `File appears to be binary: ${resolved.label}` };
2497
2530
  }
2498
- const text = buf.toString("utf-8");
2499
- return { ok: true, text, label: resolved.label, resolvedPath: resolved.resolved };
2531
+ return {
2532
+ ok: true,
2533
+ text: snapshot.buffer.toString("utf-8"),
2534
+ label: resolved.label,
2535
+ resolvedPath: snapshot.path,
2536
+ diskRevision: snapshot.revision,
2537
+ };
2500
2538
  } catch (error) {
2501
2539
  return {
2502
2540
  ok: false,
2503
- message: `Failed to read file: ${resolved.label} (${error instanceof Error ? error.message : String(error)})`,
2541
+ message: `Could not read file: ${resolved.label} (${error instanceof Error ? error.message : String(error)})`,
2504
2542
  };
2505
2543
  }
2506
2544
  }
@@ -2860,23 +2898,18 @@ function writeStudioPreviewExportFile(path: string | null, data: Buffer): { file
2860
2898
  }
2861
2899
  }
2862
2900
 
2863
- function writeStudioFile(pathArg: string, cwd: string, content: string):
2864
- | { ok: true; label: string; resolvedPath: string }
2865
- | { ok: false; message: string } {
2866
- const resolved = resolveStudioPath(pathArg, cwd);
2867
- if (resolved.ok === false) {
2868
- return { ok: false, message: resolved.message };
2869
- }
2870
-
2871
- try {
2872
- writeFileSync(resolved.resolved, content, "utf-8");
2873
- return { ok: true, label: resolved.label, resolvedPath: resolved.resolved };
2874
- } catch (error) {
2875
- return {
2876
- ok: false,
2877
- message: `Failed to write file: ${resolved.label} (${error instanceof Error ? error.message : String(error)})`,
2878
- };
2879
- }
2901
+ function writeStudioFile(pathArg: string, cwd: string, content: string, overwrite = false, expectedRevision?: string):
2902
+ | { ok: true; label: string; resolvedPath: string; diskRevision: string }
2903
+ | { ok: false; conflict?: boolean; reason?: string; path?: string; currentRevision?: string | null; message: string } {
2904
+ const normalizedLabel = normalizePathInput(pathArg);
2905
+ const result = saveStudioDiskFileAs({ path: pathArg, cwd, content, overwrite, expectedRevision }) as StudioDiskWriteResult;
2906
+ if (result.ok === false) return result;
2907
+ return {
2908
+ ok: true,
2909
+ label: normalizedLabel || basename(result.path),
2910
+ resolvedPath: result.path,
2911
+ diskRevision: result.revision,
2912
+ };
2880
2913
  }
2881
2914
 
2882
2915
  function writeStudioSideQuestionMarkdownFile(
@@ -3103,12 +3136,13 @@ function buildStudioCompanionLabel(_label: string | undefined): string {
3103
3136
  }
3104
3137
 
3105
3138
  const STUDIO_HTML_PREVIEW_RESOURCE_MAX_BYTES = 25 * 1024 * 1024;
3106
- const STUDIO_HTML_PREVIEW_IMAGE_MIME_BY_EXT = new Map<string, string>([
3139
+ const STUDIO_HTML_PREVIEW_MEDIA_MIME_BY_EXT = new Map<string, string>([
3107
3140
  [".png", "image/png"],
3108
3141
  [".jpg", "image/jpeg"],
3109
3142
  [".jpeg", "image/jpeg"],
3110
3143
  [".gif", "image/gif"],
3111
3144
  [".webp", "image/webp"],
3145
+ [".pdf", "application/pdf"],
3112
3146
  ]);
3113
3147
  const STUDIO_LOCAL_LINK_TEXT_EXTENSIONS = new Set([
3114
3148
  ".md", ".markdown", ".mdx", ".qmd", ".txt", ".tex", ".latex", ".rst", ".adoc",
@@ -3133,9 +3167,37 @@ const STUDIO_FILE_BROWSER_IGNORED_DIRS = new Set([
3133
3167
 
3134
3168
  type StudioLocalPreviewResourceKind = "pdf" | "text" | "image" | "office" | "other";
3135
3169
  type StudioFileBrowserSortMode = "name" | "mtime-desc" | "mtime-asc" | "size-desc" | "size-asc";
3170
+ type StudioResourceGrantRegistry = ReturnType<typeof createStudioResourceGrantRegistry>;
3171
+
3172
+ class StudioResourceGrantRequiredError extends Error {
3173
+ readonly code = "studio-resource-grant-required";
3174
+ readonly filePath: string;
3175
+ readonly directoryPath: string;
3176
+ readonly resourceKind: StudioLocalPreviewResourceKind;
3177
+
3178
+ constructor(filePath: string, resourceKind: StudioLocalPreviewResourceKind) {
3179
+ super("This local resource is outside the locations currently available to Studio.");
3180
+ this.name = "StudioResourceGrantRequiredError";
3181
+ this.filePath = filePath;
3182
+ this.directoryPath = dirname(filePath);
3183
+ this.resourceKind = resourceKind;
3184
+ }
3185
+ }
3186
+
3187
+ class StudioDirectoryGrantRequiredError extends Error {
3188
+ readonly code = "studio-resource-directory-grant-required";
3189
+ readonly directoryPath: string;
3190
+
3191
+ constructor(directoryPath: string) {
3192
+ super("This folder is not available to the current Studio session.");
3193
+ this.name = "StudioDirectoryGrantRequiredError";
3194
+ this.directoryPath = directoryPath;
3195
+ }
3196
+ }
3136
3197
 
3137
3198
  interface StudioLocalPreviewResource {
3138
3199
  filePath: string;
3200
+ referencePath: string;
3139
3201
  label: string;
3140
3202
  extension: string;
3141
3203
  kind: StudioLocalPreviewResourceKind;
@@ -3154,6 +3216,11 @@ interface StudioFileBrowserEntry {
3154
3216
  hidden: boolean;
3155
3217
  }
3156
3218
 
3219
+ interface StudioFileBrowserLocation {
3220
+ path: string;
3221
+ label: string;
3222
+ }
3223
+
3157
3224
  function normalizeStudioFileBrowserSortMode(sort: string | null | undefined): StudioFileBrowserSortMode {
3158
3225
  const value = typeof sort === "string" ? sort.trim().toLowerCase() : "";
3159
3226
  if (value === "mtime-desc" || value === "modified-desc" || value === "newest") return "mtime-desc";
@@ -3177,25 +3244,34 @@ function compareStudioFileBrowserEntries(a: StudioFileBrowserEntry, b: StudioFil
3177
3244
  return compareStudioFileBrowserEntryNames(a, b);
3178
3245
  }
3179
3246
 
3180
- function resolveStudioPdfResourcePath(pdfPath: string | undefined, sourcePath: string | undefined, resourceDir: string | undefined, fallbackCwd: string): string {
3247
+ function resolveStudioPdfResourcePath(
3248
+ pdfPath: string | undefined,
3249
+ sourcePath: string | undefined,
3250
+ resourceDir: string | undefined,
3251
+ fallbackCwd: string,
3252
+ resourceGrants?: StudioResourceGrantRegistry,
3253
+ ): string {
3181
3254
  const rawPath = typeof pdfPath === "string" ? pdfPath.trim() : "";
3182
3255
  if (!rawPath) throw new Error("Missing PDF path.");
3183
3256
  if (/\0/.test(rawPath)) throw new Error("Invalid PDF path.");
3184
- if (/^[a-z][a-z0-9+.-]*:/i.test(rawPath) && !/^[a-z]:[\\/]/i.test(rawPath)) {
3257
+ if (/^[a-z][a-z0-9+.-]*:/i.test(rawPath) && !/^file:/i.test(rawPath) && !/^[a-z]:[\\/]/i.test(rawPath)) {
3185
3258
  throw new Error("Only local PDF paths are supported.");
3186
3259
  }
3187
3260
 
3188
3261
  const context = resolveStudioPreviewResourceContext(sourcePath, resourceDir, fallbackCwd);
3189
- const cleanedPath = decodeStudioHtmlPreviewResourcePath(stripStudioHtmlPreviewResourceUrlSuffix(rawPath));
3262
+ const cleanedPath = decodeStudioLocalPreviewResourceReference(rawPath);
3190
3263
  const expandedPath = recoverLikelyDroppedLeadingSlashPath(expandHome(cleanedPath));
3191
3264
  const candidate = isAbsolute(expandedPath) ? expandedPath : resolve(context.baseDir, expandedPath);
3192
3265
  if (extname(candidate).toLowerCase() !== ".pdf") throw new Error("Only .pdf files can be embedded.");
3193
3266
 
3194
- const boundaryReal = realpathSync(context.boundaryDir);
3195
3267
  const candidateReal = realpathSync(candidate);
3196
- const rel = relative(boundaryReal, candidateReal);
3197
- if (rel.startsWith("..") || isAbsolute(rel)) {
3198
- throw new Error("PDF path must stay within the current Studio resource directory.");
3268
+ if (resourceGrants) {
3269
+ if (!resourceGrants.allows(candidateReal)) throw new StudioResourceGrantRequiredError(candidateReal, "pdf");
3270
+ } else {
3271
+ const boundaryReal = realpathSync(context.boundaryDir);
3272
+ if (!isPathInsideOrEqualDirectory(candidateReal, boundaryReal)) {
3273
+ throw new Error("PDF path must stay within the current Studio resource directory.");
3274
+ }
3199
3275
  }
3200
3276
 
3201
3277
  const stat = statSync(candidateReal);
@@ -3216,6 +3292,22 @@ function decodeStudioHtmlPreviewResourcePath(resourcePath: string): string {
3216
3292
  }
3217
3293
  }
3218
3294
 
3295
+ function decodeStudioLocalPreviewResourceReference(resourcePath: string): string {
3296
+ const raw = String(resourcePath ?? "").trim();
3297
+ if (!/^file:/i.test(raw)) {
3298
+ return decodeStudioHtmlPreviewResourcePath(stripStudioHtmlPreviewResourceUrlSuffix(raw));
3299
+ }
3300
+ try {
3301
+ const url = new URL(raw);
3302
+ if (url.protocol !== "file:") throw new Error("Not a file URL.");
3303
+ url.hash = "";
3304
+ url.search = "";
3305
+ return fileURLToPath(url);
3306
+ } catch {
3307
+ throw new Error("Invalid local file URL.");
3308
+ }
3309
+ }
3310
+
3219
3311
  function getStudioLocalPreviewResourceKind(extension: string, filePathOrName?: string): StudioLocalPreviewResourceKind {
3220
3312
  const ext = extension.toLowerCase();
3221
3313
  const name = basename(String(filePathOrName || "")).toLowerCase();
@@ -3231,60 +3323,128 @@ function resolveStudioLocalPreviewResourcePath(
3231
3323
  sourcePath: string | undefined,
3232
3324
  resourceDir: string | undefined,
3233
3325
  fallbackCwd: string,
3326
+ resourceGrants?: StudioResourceGrantRegistry,
3234
3327
  ): StudioLocalPreviewResource {
3235
3328
  const rawPath = typeof resourcePath === "string" ? resourcePath.trim() : "";
3236
3329
  if (!rawPath) throw new Error("Missing local resource path.");
3237
3330
  if (/\0/.test(rawPath)) throw new Error("Invalid local resource path.");
3238
3331
  if (/^\/\//.test(rawPath)) throw new Error("Network resources are not local Studio resources.");
3239
- if (/^[a-z][a-z0-9+.-]*:/i.test(rawPath) && !/^[a-z]:[\\/]/i.test(rawPath)) {
3240
- throw new Error("Only local relative resources are supported.");
3332
+ if (/^[a-z][a-z0-9+.-]*:/i.test(rawPath) && !/^file:/i.test(rawPath) && !/^[a-z]:[\\/]/i.test(rawPath)) {
3333
+ throw new Error("Only local file paths and file URLs are supported.");
3241
3334
  }
3242
3335
 
3243
3336
  const context = resolveStudioPreviewResourceContext(sourcePath, resourceDir, fallbackCwd);
3244
- const cleanedPath = decodeStudioHtmlPreviewResourcePath(stripStudioHtmlPreviewResourceUrlSuffix(rawPath));
3337
+ const cleanedPath = decodeStudioLocalPreviewResourceReference(rawPath);
3245
3338
  if (!cleanedPath || cleanedPath.startsWith("#")) throw new Error("Missing local resource path.");
3246
3339
  const expandedPath = recoverLikelyDroppedLeadingSlashPath(expandHome(cleanedPath));
3247
3340
  const candidate = isAbsolute(expandedPath) ? expandedPath : resolve(context.baseDir, expandedPath);
3248
3341
  const extension = extname(candidate).toLowerCase();
3249
- const boundaryReal = realpathSync(context.boundaryDir);
3250
3342
  const candidateReal = realpathSync(candidate);
3251
- const rel = relative(boundaryReal, candidateReal);
3252
- if (rel.startsWith("..") || isAbsolute(rel)) {
3253
- throw new Error("Local resource path must stay within the current Studio resource directory.");
3254
- }
3255
-
3256
3343
  const stat = statSync(candidateReal);
3257
3344
  if (!stat.isFile()) throw new Error("Local resource path does not refer to a file.");
3345
+
3346
+ const kind = getStudioLocalPreviewResourceKind(extension, candidateReal);
3347
+ const matchingGrant = resourceGrants?.findGrant(candidateReal);
3348
+ let effectiveDirectory: string;
3349
+ if (resourceGrants) {
3350
+ if (!matchingGrant) throw new StudioResourceGrantRequiredError(candidateReal, kind);
3351
+ effectiveDirectory = matchingGrant.kind === "directory" ? matchingGrant.path : dirname(candidateReal);
3352
+ } else {
3353
+ const boundaryReal = realpathSync(context.boundaryDir);
3354
+ if (!isPathInsideOrEqualDirectory(candidateReal, boundaryReal)) {
3355
+ throw new Error("Local resource path must stay within the current Studio resource directory.");
3356
+ }
3357
+ effectiveDirectory = boundaryReal;
3358
+ }
3359
+ const relativePath = relative(effectiveDirectory, candidateReal);
3360
+ const useRelativeReference = isPathInsideOrEqualDirectory(candidateReal, effectiveDirectory);
3258
3361
  return {
3259
3362
  filePath: candidateReal,
3260
- label: rel && rel !== "" ? rel : basename(candidateReal),
3363
+ referencePath: useRelativeReference ? (relativePath || basename(candidateReal)) : candidateReal,
3364
+ label: useRelativeReference ? (relativePath || basename(candidateReal)) : basename(candidateReal),
3261
3365
  extension,
3262
- kind: getStudioLocalPreviewResourceKind(extension, candidateReal),
3366
+ kind,
3263
3367
  page: parseStudioLocalPreviewPage(rawPath),
3264
- resourceDir: boundaryReal,
3368
+ resourceDir: effectiveDirectory,
3265
3369
  };
3266
3370
  }
3267
3371
 
3372
+ function getStudioFileBrowserGrantLocations(resourceGrants: StudioResourceGrantRegistry): {
3373
+ locations: StudioFileBrowserLocation[];
3374
+ exactFiles: StudioFileBrowserEntry[];
3375
+ } {
3376
+ const grants = resourceGrants.snapshot();
3377
+ const directoryPaths: string[] = [];
3378
+ for (const grant of grants) {
3379
+ if (grant.kind !== "directory") continue;
3380
+ try {
3381
+ const currentReal = realpathSync(grant.path);
3382
+ if (currentReal === grant.path && statSync(currentReal).isDirectory()) directoryPaths.push(currentReal);
3383
+ } catch {
3384
+ // Missing or replaced directory grants are omitted rather than followed.
3385
+ }
3386
+ }
3387
+ const locations = directoryPaths
3388
+ .map((path) => ({ path, label: basename(path) || path }))
3389
+ .sort((a, b) => a.path.localeCompare(b.path));
3390
+ const exactFiles: StudioFileBrowserEntry[] = [];
3391
+ for (const grant of grants) {
3392
+ if (grant.kind !== "file") continue;
3393
+ if (directoryPaths.some((directoryPath) => isPathInsideOrEqualDirectory(grant.path, directoryPath))) continue;
3394
+ try {
3395
+ const currentReal = realpathSync(grant.path);
3396
+ if (currentReal !== grant.path) continue;
3397
+ const stat = statSync(currentReal);
3398
+ if (!stat.isFile()) continue;
3399
+ const name = basename(currentReal);
3400
+ const extension = extname(currentReal).toLowerCase();
3401
+ exactFiles.push({
3402
+ name,
3403
+ path: currentReal,
3404
+ type: "file",
3405
+ extension,
3406
+ kind: getStudioLocalPreviewResourceKind(extension, currentReal),
3407
+ size: stat.size,
3408
+ mtimeMs: stat.mtimeMs,
3409
+ hidden: name.startsWith("."),
3410
+ });
3411
+ } catch {
3412
+ // Missing exact-file grants remain bounded but are omitted from the Files view.
3413
+ }
3414
+ }
3415
+ exactFiles.sort((a, b) => compareStudioFileBrowserEntries(a, b, "name"));
3416
+ return { locations, exactFiles };
3417
+ }
3418
+
3268
3419
  function resolveStudioFileBrowserDirectory(
3269
3420
  dirPath: string | undefined,
3421
+ rootPath: string | undefined,
3270
3422
  sourcePath: string | undefined,
3271
3423
  resourceDir: string | undefined,
3272
3424
  fallbackCwd: string,
3425
+ resourceGrants: StudioResourceGrantRegistry,
3273
3426
  ): { rootDir: string; currentDir: string; relativeDir: string; parentDir: string | null } {
3274
3427
  const context = resolveStudioPreviewResourceContext(sourcePath, resourceDir, fallbackCwd);
3275
- const rootReal = realpathSync(context.boundaryDir);
3428
+ const rawRoot = typeof rootPath === "string" ? rootPath.trim() : "";
3429
+ const expandedRoot = recoverLikelyDroppedLeadingSlashPath(expandHome(rawRoot || context.boundaryDir));
3430
+ const requestedRoot = isAbsolute(expandedRoot) ? expandedRoot : resolve(fallbackCwd, expandedRoot);
3431
+ const requestedRootReal = realpathSync(requestedRoot);
3432
+ if (!statSync(requestedRootReal).isDirectory()) throw new Error("File browser root does not refer to a directory.");
3433
+ const matchingRootGrant = resourceGrants.findGrant(requestedRootReal, { cwd: fallbackCwd });
3434
+ if (!matchingRootGrant || matchingRootGrant.kind !== "directory") {
3435
+ throw new StudioDirectoryGrantRequiredError(requestedRootReal);
3436
+ }
3437
+ const rootReal = matchingRootGrant.path;
3276
3438
  const rawDir = typeof dirPath === "string" ? dirPath.trim() : "";
3277
- const baseDir = context.baseDir;
3439
+ const expandedDir = recoverLikelyDroppedLeadingSlashPath(expandHome(rawDir));
3278
3440
  const requested = rawDir
3279
- ? (isAbsolute(recoverLikelyDroppedLeadingSlashPath(expandHome(rawDir)))
3280
- ? recoverLikelyDroppedLeadingSlashPath(expandHome(rawDir))
3281
- : resolve(baseDir, recoverLikelyDroppedLeadingSlashPath(expandHome(rawDir))))
3282
- : baseDir;
3441
+ ? (isAbsolute(expandedDir) ? expandedDir : resolve(rootReal, expandedDir))
3442
+ : rootReal;
3283
3443
  const currentReal = realpathSync(requested);
3284
3444
  const currentStat = statSync(currentReal);
3285
3445
  if (!currentStat.isDirectory()) throw new Error("File browser path does not refer to a directory.");
3286
3446
  if (!isPathInsideOrEqualDirectory(currentReal, rootReal)) {
3287
- throw new Error("File browser path must stay within the current Studio resource directory.");
3447
+ throw new Error("File browser path must stay within an allowed Studio folder.");
3288
3448
  }
3289
3449
  const parent = dirname(currentReal);
3290
3450
  const parentDir = parent !== currentReal && isPathInsideOrEqualDirectory(parent, rootReal) ? parent : null;
@@ -3294,12 +3454,14 @@ function resolveStudioFileBrowserDirectory(
3294
3454
 
3295
3455
  function listStudioFileBrowserDirectory(
3296
3456
  dirPath: string | undefined,
3457
+ rootPath: string | undefined,
3297
3458
  sourcePath: string | undefined,
3298
3459
  resourceDir: string | undefined,
3299
3460
  fallbackCwd: string,
3461
+ resourceGrants: StudioResourceGrantRegistry,
3300
3462
  sortMode?: string | null,
3301
- ): { rootDir: string; currentDir: string; relativeDir: string; parentDir: string | null; entries: StudioFileBrowserEntry[]; omitted: number; omittedIgnored: number; sort: StudioFileBrowserSortMode } {
3302
- const context = resolveStudioFileBrowserDirectory(dirPath, sourcePath, resourceDir, fallbackCwd);
3463
+ ): { rootDir: string; currentDir: string; relativeDir: string; parentDir: string | null; entries: StudioFileBrowserEntry[]; exactFiles: StudioFileBrowserEntry[]; locations: StudioFileBrowserLocation[]; omitted: number; omittedIgnored: number; sort: StudioFileBrowserSortMode } {
3464
+ const context = resolveStudioFileBrowserDirectory(dirPath, rootPath, sourcePath, resourceDir, fallbackCwd, resourceGrants);
3303
3465
  const sort = normalizeStudioFileBrowserSortMode(sortMode);
3304
3466
  const entries: StudioFileBrowserEntry[] = [];
3305
3467
  let omitted = 0;
@@ -3342,7 +3504,7 @@ function listStudioFileBrowserDirectory(
3342
3504
  entries.sort((a, b) => compareStudioFileBrowserEntries(a, b, sort));
3343
3505
  const limitedEntries = entries.slice(0, STUDIO_FILE_BROWSER_MAX_ENTRIES);
3344
3506
  omitted += Math.max(0, entries.length - limitedEntries.length);
3345
- return { ...context, entries: limitedEntries, omitted, omittedIgnored, sort };
3507
+ return { ...context, ...getStudioFileBrowserGrantLocations(resourceGrants), entries: limitedEntries, omitted, omittedIgnored, sort };
3346
3508
  }
3347
3509
 
3348
3510
  function resolveStudioHtmlPreviewResourcePath(
@@ -3350,27 +3512,32 @@ function resolveStudioHtmlPreviewResourcePath(
3350
3512
  sourcePath: string | undefined,
3351
3513
  resourceDir: string | undefined,
3352
3514
  fallbackCwd: string,
3515
+ resourceGrants?: StudioResourceGrantRegistry,
3353
3516
  ): { filePath: string; mimeType: string } {
3354
3517
  const rawPath = typeof resourcePath === "string" ? resourcePath.trim() : "";
3355
3518
  if (!rawPath) throw new Error("Missing HTML preview resource path.");
3356
3519
  if (/\0/.test(rawPath)) throw new Error("Invalid HTML preview resource path.");
3357
- if (/^[a-z][a-z0-9+.-]*:/i.test(rawPath) && !/^[a-z]:[\\/]/i.test(rawPath)) {
3520
+ if (/^[a-z][a-z0-9+.-]*:/i.test(rawPath) && !/^file:/i.test(rawPath) && !/^[a-z]:[\\/]/i.test(rawPath)) {
3358
3521
  throw new Error("Only local HTML preview resources are supported.");
3359
3522
  }
3360
3523
 
3361
3524
  const context = resolveStudioPreviewResourceContext(sourcePath, resourceDir, fallbackCwd);
3362
- const cleanedPath = decodeStudioHtmlPreviewResourcePath(stripStudioHtmlPreviewResourceUrlSuffix(rawPath));
3525
+ const cleanedPath = decodeStudioLocalPreviewResourceReference(rawPath);
3363
3526
  const expandedPath = recoverLikelyDroppedLeadingSlashPath(expandHome(cleanedPath));
3364
3527
  const candidate = isAbsolute(expandedPath) ? expandedPath : resolve(context.baseDir, expandedPath);
3365
3528
  const ext = extname(candidate).toLowerCase();
3366
- const mimeType = STUDIO_HTML_PREVIEW_IMAGE_MIME_BY_EXT.get(ext);
3367
- if (!mimeType) throw new Error("Only local PNG, JPEG, GIF, and WebP images can be embedded in HTML previews.");
3529
+ const mimeType = STUDIO_HTML_PREVIEW_MEDIA_MIME_BY_EXT.get(ext);
3530
+ if (!mimeType) throw new Error("Only local PNG, JPEG, GIF, WebP, and PDF media can be embedded in Studio previews.");
3368
3531
 
3369
- const boundaryReal = realpathSync(context.boundaryDir);
3370
3532
  const candidateReal = realpathSync(candidate);
3371
- const rel = relative(boundaryReal, candidateReal);
3372
- if (rel.startsWith("..") || isAbsolute(rel)) {
3373
- throw new Error("HTML preview resource path must stay within the current Studio resource directory.");
3533
+ const kind: StudioLocalPreviewResourceKind = ext === ".pdf" ? "pdf" : "image";
3534
+ if (resourceGrants) {
3535
+ if (!resourceGrants.allows(candidateReal)) throw new StudioResourceGrantRequiredError(candidateReal, kind);
3536
+ } else {
3537
+ const boundaryReal = realpathSync(context.boundaryDir);
3538
+ if (!isPathInsideOrEqualDirectory(candidateReal, boundaryReal)) {
3539
+ throw new Error("HTML preview resource path must stay within the current Studio resource directory.");
3540
+ }
3374
3541
  }
3375
3542
 
3376
3543
  const stat = statSync(candidateReal);
@@ -3381,6 +3548,39 @@ function resolveStudioHtmlPreviewResourcePath(
3381
3548
  return { filePath: candidateReal, mimeType };
3382
3549
  }
3383
3550
 
3551
+ function resolveStudioAuthorizedPreviewRenderContext(
3552
+ sourcePath: string | undefined,
3553
+ resourceDir: string | undefined,
3554
+ fallbackCwd: string,
3555
+ resourceGrants: StudioResourceGrantRegistry,
3556
+ ): { resourcePath?: string; sourcePath?: string } {
3557
+ const context = resolveStudioPreviewResourceContext(sourcePath, resourceDir, fallbackCwd);
3558
+ let resourcePath: string;
3559
+ try {
3560
+ resourcePath = realpathSync(context.baseDir);
3561
+ if (!statSync(resourcePath).isDirectory()) return {};
3562
+ } catch {
3563
+ return {};
3564
+ }
3565
+ const directoryGrant = resourceGrants.findGrant(resourcePath, { cwd: fallbackCwd });
3566
+ if (!directoryGrant || directoryGrant.kind !== "directory") return {};
3567
+
3568
+ const rawSource = typeof sourcePath === "string" ? sourcePath.trim() : "";
3569
+ if (!rawSource) return { resourcePath };
3570
+ try {
3571
+ const expandedSource = recoverLikelyDroppedLeadingSlashPath(expandHome(rawSource));
3572
+ const requestedSource = isAbsolute(expandedSource) ? expandedSource : resolve(fallbackCwd, expandedSource);
3573
+ const canonicalSource = realpathSync(requestedSource);
3574
+ const sourceGrant = resourceGrants.findGrant(dirname(canonicalSource), { cwd: fallbackCwd });
3575
+ if (statSync(canonicalSource).isFile() && sourceGrant?.kind === "directory") {
3576
+ return { resourcePath, sourcePath: canonicalSource };
3577
+ }
3578
+ } catch {
3579
+ // The submitted Markdown can still render without file-adjacent metadata.
3580
+ }
3581
+ return { resourcePath };
3582
+ }
3583
+
3384
3584
  function resolveStudioPandocWorkingDir(baseDir: string | undefined): string | undefined {
3385
3585
  const normalized = typeof baseDir === "string" ? baseDir.trim() : "";
3386
3586
  if (!normalized) return undefined;
@@ -5191,11 +5391,34 @@ function hasStudioYamlHeaderIncludes(markdown: string): boolean {
5191
5391
  return /^\s*header-includes\s*:/im.test(split.frontMatter);
5192
5392
  }
5193
5393
 
5394
+ function encodeStudioLocalFileUrlForPandoc(resourceUrl: string): string {
5395
+ try {
5396
+ const url = new URL(resourceUrl);
5397
+ if (url.protocol !== "file:" || (url.hostname && url.hostname !== "localhost")) return resourceUrl;
5398
+ const suffix = `${url.search}${url.hash}`;
5399
+ url.search = "";
5400
+ url.hash = "";
5401
+ const localPath = fileURLToPath(url);
5402
+ return encodeURI(localPath).replace(/\?/g, "%3F").replace(/#/g, "%23") + suffix;
5403
+ } catch {
5404
+ return resourceUrl;
5405
+ }
5406
+ }
5407
+
5408
+ function normalizeStudioMarkdownFileUrlDestinationsForPandoc(markdown: string): string {
5409
+ return transformStudioMarkdownOutsideFences(markdown, (segment: string) => (
5410
+ segment.replace(/(\]\(\s*<?)(file:\/\/(?:localhost)?\/[^<>\s)]+)/gi, (_match, prefix: string, resourceUrl: string) => (
5411
+ `${prefix}${encodeStudioLocalFileUrlForPandoc(resourceUrl)}`
5412
+ ))
5413
+ ));
5414
+ }
5415
+
5194
5416
  function prepareStudioMarkdownForPandoc(markdown: string, options?: { preserveLiteralLatexCommands?: boolean }): string {
5195
5417
  const shouldPreserveLiteralLatexCommands = options?.preserveLiteralLatexCommands !== false;
5196
5418
  return mapStudioMarkdownBodyPreservingYamlFrontMatter(markdown, (body) => {
5197
5419
  const normalizedFences = normalizeStudioMarkdownSmartFences(body);
5198
- const normalizedMath = normalizeMathDelimiters(normalizedFences);
5420
+ const normalizedFileUrls = normalizeStudioMarkdownFileUrlDestinationsForPandoc(normalizedFences);
5421
+ const normalizedMath = normalizeMathDelimiters(normalizedFileUrls);
5199
5422
  const latexReady = shouldPreserveLiteralLatexCommands
5200
5423
  ? preserveLiteralLatexCommandsInMarkdown(normalizedMath)
5201
5424
  : normalizedMath;
@@ -6413,7 +6636,13 @@ function preprocessStudioLatexFootnotemarksForPreview(latex: string): string {
6413
6636
  });
6414
6637
  }
6415
6638
 
6416
- async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolean, resourcePath?: string, sourcePath?: string): Promise<string> {
6639
+ async function renderStudioMarkdownWithPandoc(
6640
+ markdown: string,
6641
+ isLatex?: boolean,
6642
+ resourcePath?: string,
6643
+ sourcePath?: string,
6644
+ options?: { embedResources?: boolean },
6645
+ ): Promise<string> {
6417
6646
  const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
6418
6647
  const pandocWorkingDir = resolveStudioPandocWorkingDir(resourcePath)
6419
6648
  ?? resolveStudioPandocWorkingDir(sourcePath ? dirname(sourcePath) : undefined);
@@ -6451,7 +6680,9 @@ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolea
6451
6680
  await mkdir(htmlTemplateDir, { recursive: true });
6452
6681
  const htmlTemplatePath = join(htmlTemplateDir, "template.html");
6453
6682
  await writeFile(htmlTemplatePath, STUDIO_PANDOC_HTML_FRAGMENT_TEMPLATE, "utf-8");
6454
- if (resourcePath) args.push(await resolveStudioPandocHtmlResourceFlag(pandocCommand));
6683
+ if (resourcePath && options?.embedResources !== false) {
6684
+ args.push(await resolveStudioPandocHtmlResourceFlag(pandocCommand));
6685
+ }
6455
6686
  args.push("--standalone", `--template=${htmlTemplatePath}`);
6456
6687
  }
6457
6688
  const normalizedMarkdown = isLatex
@@ -7599,6 +7830,29 @@ function respondStudioPendingError(res: ServerResponse, status: number, text: st
7599
7830
  res.end(text);
7600
7831
  }
7601
7832
 
7833
+ function respondStudioResourceGrantRequiredJson(res: ServerResponse, error: StudioResourceGrantRequiredError): void {
7834
+ respondJson(res, 403, {
7835
+ ok: false,
7836
+ code: error.code,
7837
+ error: error.message,
7838
+ path: error.filePath,
7839
+ directoryPath: error.directoryPath,
7840
+ label: basename(error.filePath),
7841
+ resourceKind: error.resourceKind,
7842
+ });
7843
+ }
7844
+
7845
+ function respondStudioDirectoryGrantRequiredJson(res: ServerResponse, error: StudioDirectoryGrantRequiredError): void {
7846
+ respondJson(res, 403, {
7847
+ ok: false,
7848
+ code: error.code,
7849
+ error: error.message,
7850
+ path: error.directoryPath,
7851
+ directoryPath: error.directoryPath,
7852
+ label: basename(error.directoryPath) || error.directoryPath,
7853
+ });
7854
+ }
7855
+
7602
7856
  function respondPdfFile(req: IncomingMessage, res: ServerResponse, filePath: string): void {
7603
7857
  const method = (req.method ?? "GET").toUpperCase();
7604
7858
  if (method !== "GET" && method !== "HEAD") {
@@ -7661,7 +7915,7 @@ function sanitizeStudioPreviewBlockLine(value: string): string {
7661
7915
 
7662
7916
  function buildStudioLocalResourcePreviewDocument(resource: StudioLocalPreviewResource, options?: { watchPdf?: boolean }): InitialStudioDocument {
7663
7917
  const label = basename(resource.filePath) || resource.label || "local preview";
7664
- const resourcePath = resource.label || basename(resource.filePath) || resource.filePath;
7918
+ const resourcePath = resource.referencePath || resource.label || basename(resource.filePath) || resource.filePath;
7665
7919
  const title = sanitizeStudioPreviewBlockLine(label);
7666
7920
  let text = "";
7667
7921
  if (resource.kind === "pdf") {
@@ -7720,7 +7974,14 @@ async function convertStudioOfficeDocumentToMarkdown(resource: StudioLocalPrevie
7720
7974
  return { text: `${note}\n\n${body}\n`, label };
7721
7975
  }
7722
7976
 
7723
- async function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResponse, requestUrl: URL, resource: StudioLocalPreviewResource, serverState: StudioServerState): Promise<void> {
7977
+ async function respondLocalPreviewLinkJson(
7978
+ req: IncomingMessage,
7979
+ res: ServerResponse,
7980
+ requestUrl: URL,
7981
+ resource: StudioLocalPreviewResource,
7982
+ serverState: StudioServerState,
7983
+ resourceGrants?: StudioResourceGrantRegistry,
7984
+ ): Promise<void> {
7724
7985
  const method = (req.method ?? "GET").toUpperCase();
7725
7986
  if (method !== "GET" && method !== "HEAD") {
7726
7987
  res.setHeader("Allow", "GET, HEAD");
@@ -7729,6 +7990,13 @@ async function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResp
7729
7990
  }
7730
7991
 
7731
7992
  const action = (requestUrl.searchParams.get("action") ?? "resolve").trim().toLowerCase();
7993
+ const originDocId = (requestUrl.searchParams.get("docId") ?? "").trim();
7994
+ const originDocument = originDocId ? readTransientStudioDocument(originDocId) : null;
7995
+ const originatesFromWatchedPreview = requestUrl.searchParams.get("watchedFile") === "1" || originDocument?.watchFile === true;
7996
+ if (action === "document" && originatesFromWatchedPreview) {
7997
+ respondJson(res, 409, { ok: false, error: "Open here is unavailable in a watched preview. Open a separate editable file tab instead." });
7998
+ return;
7999
+ }
7732
8000
  const basePayload = {
7733
8001
  ok: true,
7734
8002
  kind: resource.kind,
@@ -7758,10 +8026,14 @@ async function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResp
7758
8026
  return;
7759
8027
  }
7760
8028
 
7761
- if (action !== "document" && action !== "editor-url") {
8029
+ if (action !== "document" && action !== "editor-url" && action !== "watch-url") {
7762
8030
  respondJson(res, 400, { ok: false, error: "Unsupported local link action." });
7763
8031
  return;
7764
8032
  }
8033
+ if (action === "watch-url" && resource.kind !== "text") {
8034
+ respondJson(res, 400, { ok: false, error: "Follow-changes preview is available only for readable text files." });
8035
+ return;
8036
+ }
7765
8037
  if (resource.kind !== "text" && resource.kind !== "office") {
7766
8038
  respondJson(res, 400, { ok: false, error: "This local resource is not a document Studio can load into the editor." });
7767
8039
  return;
@@ -7770,6 +8042,7 @@ async function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResp
7770
8042
  let document: InitialStudioDocument;
7771
8043
  let responseText = "";
7772
8044
  let converted = false;
8045
+ let documentResourceDir = resource.resourceDir;
7773
8046
  if (resource.kind === "office") {
7774
8047
  let conversion: { text: string; label: string };
7775
8048
  try {
@@ -7793,12 +8066,19 @@ async function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResp
7793
8066
  return;
7794
8067
  }
7795
8068
  responseText = file.text;
8069
+ try {
8070
+ const documentGrant = resourceGrants?.grantDocument(file.resolvedPath, { source: "document" });
8071
+ if (documentGrant?.path) documentResourceDir = documentGrant.path;
8072
+ } catch {
8073
+ // The exact-file grant remains usable if the bounded registry cannot add the document directory.
8074
+ }
7796
8075
  document = {
7797
8076
  text: file.text,
7798
8077
  label: resource.label || file.label,
7799
8078
  source: "file",
7800
8079
  path: file.resolvedPath,
7801
- resourceDir: resource.resourceDir,
8080
+ resourceDir: documentResourceDir,
8081
+ diskRevision: file.diskRevision,
7802
8082
  };
7803
8083
  }
7804
8084
  if (action === "document") {
@@ -7807,15 +8087,24 @@ async function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResp
7807
8087
  text: responseText,
7808
8088
  label: document.label,
7809
8089
  converted,
7810
- resourceDir: resource.resourceDir,
8090
+ resourceDir: documentResourceDir,
8091
+ diskRevision: document.diskRevision || null,
7811
8092
  });
7812
8093
  return;
7813
8094
  }
7814
8095
 
8096
+ if (action === "watch-url") {
8097
+ if (responseText.length > PREVIEW_RENDER_MAX_CHARS) {
8098
+ respondJson(res, 413, { ok: false, error: `Watched preview source exceeds ${PREVIEW_RENDER_MAX_CHARS.toLocaleString()} characters.` });
8099
+ return;
8100
+ }
8101
+ document = { ...document, watchFile: true };
8102
+ }
7815
8103
  const docId = storeTransientStudioDocument(document);
7816
8104
  respondJson(res, 200, {
7817
8105
  ...basePayload,
7818
8106
  converted,
8107
+ resourceDir: documentResourceDir,
7819
8108
  relativeUrl: buildStudioRelativeUrl(serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true }),
7820
8109
  });
7821
8110
  }
@@ -7906,7 +8195,61 @@ async function handleImportStudioFileCopyRequest(req: IncomingMessage, res: Serv
7906
8195
  });
7907
8196
  }
7908
8197
 
7909
- async function handleRevealLocalPreviewResourceRequest(req: IncomingMessage, res: ServerResponse, studioCwd: string): Promise<void> {
8198
+ async function handleStudioResourceGrantRequest(
8199
+ req: IncomingMessage,
8200
+ res: ServerResponse,
8201
+ resourceGrants: StudioResourceGrantRegistry,
8202
+ studioCwd: string,
8203
+ ): Promise<void> {
8204
+ const method = (req.method ?? "GET").toUpperCase();
8205
+ if (method !== "POST") {
8206
+ res.setHeader("Allow", "POST");
8207
+ respondJson(res, 405, { ok: false, error: "Method not allowed. Use POST." });
8208
+ return;
8209
+ }
8210
+
8211
+ const rawBody = await readRequestBody(req, REQUEST_BODY_MAX_BYTES);
8212
+ let payload: Record<string, unknown> = {};
8213
+ try {
8214
+ payload = rawBody ? JSON.parse(rawBody) : {};
8215
+ } catch {
8216
+ respondJson(res, 400, { ok: false, error: "Invalid JSON body." });
8217
+ return;
8218
+ }
8219
+
8220
+ const grantKind = payload.grantKind === "directory" ? "directory" : payload.grantKind === "file" ? "file" : "";
8221
+ const path = typeof payload.path === "string" ? payload.path.trim() : "";
8222
+ if (!grantKind || !path) {
8223
+ respondJson(res, 400, { ok: false, error: "Choose an exact file or folder to allow." });
8224
+ return;
8225
+ }
8226
+
8227
+ try {
8228
+ const grant = grantKind === "directory"
8229
+ ? resourceGrants.grantDirectory(path, { cwd: studioCwd, source: "explicit-directory" })
8230
+ : resourceGrants.grantFile(path, { cwd: studioCwd, source: "explicit-file" });
8231
+ respondJson(res, 200, {
8232
+ ok: true,
8233
+ grant,
8234
+ resourceGrants: resourceGrants.snapshot(),
8235
+ message: grantKind === "directory"
8236
+ ? `Allowed this folder for the current Studio session: ${grant.path}`
8237
+ : `Allowed this file for the current Studio session: ${grant.path}`,
8238
+ });
8239
+ } catch (error) {
8240
+ respondJson(res, 400, {
8241
+ ok: false,
8242
+ error: `Could not allow this ${grantKind}: ${error instanceof Error ? error.message : String(error)}`,
8243
+ });
8244
+ }
8245
+ }
8246
+
8247
+ async function handleRevealLocalPreviewResourceRequest(
8248
+ req: IncomingMessage,
8249
+ res: ServerResponse,
8250
+ studioCwd: string,
8251
+ resourceGrants?: StudioResourceGrantRegistry,
8252
+ ): Promise<void> {
7910
8253
  const method = (req.method ?? "GET").toUpperCase();
7911
8254
  if (method !== "POST") {
7912
8255
  res.setHeader("Allow", "POST");
@@ -7929,6 +8272,7 @@ async function handleRevealLocalPreviewResourceRequest(req: IncomingMessage, res
7929
8272
  typeof payload.sourcePath === "string" ? payload.sourcePath : undefined,
7930
8273
  typeof payload.resourceDir === "string" ? payload.resourceDir : undefined,
7931
8274
  studioCwd,
8275
+ resourceGrants,
7932
8276
  );
7933
8277
  const result = revealStudioLocalFile(resource.filePath);
7934
8278
  if (!result.ok) {
@@ -7937,11 +8281,20 @@ async function handleRevealLocalPreviewResourceRequest(req: IncomingMessage, res
7937
8281
  }
7938
8282
  respondJson(res, 200, { ok: true, message: result.message, path: resource.filePath, label: resource.label });
7939
8283
  } catch (error) {
8284
+ if (error instanceof StudioResourceGrantRequiredError) {
8285
+ respondStudioResourceGrantRequiredJson(res, error);
8286
+ return;
8287
+ }
7940
8288
  respondJson(res, 404, { ok: false, error: `Local resource unavailable: ${error instanceof Error ? error.message : String(error)}` });
7941
8289
  }
7942
8290
  }
7943
8291
 
7944
- async function handleOpenLocalPreviewResourceRequest(req: IncomingMessage, res: ServerResponse, studioCwd: string): Promise<void> {
8292
+ async function handleOpenLocalPreviewResourceRequest(
8293
+ req: IncomingMessage,
8294
+ res: ServerResponse,
8295
+ studioCwd: string,
8296
+ resourceGrants?: StudioResourceGrantRegistry,
8297
+ ): Promise<void> {
7945
8298
  const method = (req.method ?? "GET").toUpperCase();
7946
8299
  if (method !== "POST") {
7947
8300
  res.setHeader("Allow", "POST");
@@ -7968,6 +8321,7 @@ async function handleOpenLocalPreviewResourceRequest(req: IncomingMessage, res:
7968
8321
  typeof payload.sourcePath === "string" ? payload.sourcePath : undefined,
7969
8322
  typeof payload.resourceDir === "string" ? payload.resourceDir : undefined,
7970
8323
  studioCwd,
8324
+ resourceGrants,
7971
8325
  );
7972
8326
  if (resource.kind !== "pdf") {
7973
8327
  respondJson(res, 400, { ok: false, error: "Only local PDF previews can be opened in the system PDF viewer." });
@@ -7981,6 +8335,10 @@ async function handleOpenLocalPreviewResourceRequest(req: IncomingMessage, res:
7981
8335
  label: resource.label,
7982
8336
  });
7983
8337
  } catch (error) {
8338
+ if (error instanceof StudioResourceGrantRequiredError) {
8339
+ respondStudioResourceGrantRequiredJson(res, error);
8340
+ return;
8341
+ }
7984
8342
  respondJson(res, 404, { ok: false, error: `Local PDF unavailable: ${error instanceof Error ? error.message : String(error)}` });
7985
8343
  }
7986
8344
  }
@@ -8006,7 +8364,12 @@ function openPathInDefaultViewer(path: string): Promise<void> {
8006
8364
  });
8007
8365
  }
8008
8366
 
8009
- async function handleOpenStudioFileBrowserDirectoryRequest(req: IncomingMessage, res: ServerResponse, studioCwd: string): Promise<void> {
8367
+ async function handleOpenStudioFileBrowserDirectoryRequest(
8368
+ req: IncomingMessage,
8369
+ res: ServerResponse,
8370
+ studioCwd: string,
8371
+ resourceGrants: StudioResourceGrantRegistry,
8372
+ ): Promise<void> {
8010
8373
  const method = (req.method ?? "GET").toUpperCase();
8011
8374
  if (method !== "POST") {
8012
8375
  res.setHeader("Allow", "POST");
@@ -8030,13 +8393,25 @@ async function handleOpenStudioFileBrowserDirectoryRequest(req: IncomingMessage,
8030
8393
  try {
8031
8394
  const directory = resolveStudioFileBrowserDirectory(
8032
8395
  typeof payload.dir === "string" ? payload.dir : undefined,
8396
+ typeof payload.root === "string" ? payload.root : undefined,
8033
8397
  typeof payload.sourcePath === "string" ? payload.sourcePath : undefined,
8034
8398
  typeof payload.resourceDir === "string" ? payload.resourceDir : undefined,
8035
8399
  studioCwd,
8400
+ resourceGrants,
8036
8401
  );
8037
8402
  await openPathInDefaultViewer(directory.currentDir);
8038
8403
  respondJson(res, 200, { ok: true, message: "Opened folder in file manager.", path: directory.currentDir, rootDir: directory.rootDir });
8039
8404
  } catch (error) {
8405
+ if (error instanceof StudioDirectoryGrantRequiredError) {
8406
+ respondJson(res, 403, {
8407
+ ok: false,
8408
+ code: error.code,
8409
+ error: error.message,
8410
+ path: error.directoryPath,
8411
+ directoryPath: error.directoryPath,
8412
+ });
8413
+ return;
8414
+ }
8040
8415
  respondJson(res, 404, { ok: false, error: `Could not open file-browser folder: ${error instanceof Error ? error.message : String(error)}` });
8041
8416
  }
8042
8417
  }
@@ -8504,10 +8879,11 @@ async function searchStudioSideQuestionLocalContext(
8504
8879
  const query = String(queryInput || "").trim();
8505
8880
  if (!query) throw new Error("Local context search query is empty.");
8506
8881
  if (query.length > 500) throw new Error("Local context search query is too long.");
8882
+ const stableRoot = assertStudioSideQuestionRootStable(contextRoot);
8507
8883
  const target = options.path
8508
- ? resolveStudioSideQuestionPath(contextRoot, options.path, { directory: true }).path
8509
- : contextRoot;
8510
- const targetRelative = relative(contextRoot, target) || ".";
8884
+ ? resolveStudioSideQuestionPath(stableRoot, options.path, { directory: true }).path
8885
+ : stableRoot;
8886
+ const targetRelative = relative(stableRoot, target) || ".";
8511
8887
  const maxResults = Math.max(1, Math.min(200, Math.floor(Number(options.maxResults) || 60)));
8512
8888
  const args = [
8513
8889
  "--fixed-strings",
@@ -8527,7 +8903,7 @@ async function searchStudioSideQuestionLocalContext(
8527
8903
  args.push("--", query, targetRelative);
8528
8904
  try {
8529
8905
  const result = await runStudioSubprocess("rg", args, {
8530
- cwd: contextRoot,
8906
+ cwd: stableRoot,
8531
8907
  signal: options.signal,
8532
8908
  timeoutMs: 20_000,
8533
8909
  stdoutMaxBytes: 250_000,
@@ -8541,11 +8917,11 @@ async function searchStudioSideQuestionLocalContext(
8541
8917
  if (!match) return [];
8542
8918
  return [{ path: match[1].split("\\").join("/"), line: Number(match[2]), text: match[3].trim().slice(0, 1_000) }];
8543
8919
  }).slice(0, maxResults);
8544
- return { root: contextRoot, query, results: matches, truncated: result.stdoutTruncated || matches.length >= maxResults };
8920
+ return { root: stableRoot, query, results: matches, truncated: result.stdoutTruncated || matches.length >= maxResults };
8545
8921
  } catch (error) {
8546
8922
  if (options.signal?.aborted) throw new Error("Local context search was cancelled.");
8547
8923
  if (!(error instanceof Error) || !error.message.includes("__PI_STUDIO_RG_NOT_FOUND__")) throw error;
8548
- return searchStudioSideQuestionContext(contextRoot, query, {
8924
+ return searchStudioSideQuestionContext(stableRoot, query, {
8549
8925
  path: targetRelative,
8550
8926
  caseSensitive: options.caseSensitive,
8551
8927
  maxResults,
@@ -8647,7 +9023,12 @@ function formatStudioSideQuestionGitSnapshotResult(
8647
9023
  };
8648
9024
  }
8649
9025
 
8650
- function createStudioSideQuestionTools(contextRoot: string, webEnabled: boolean, gitSnapshot: StudioSideQuestionGitSnapshot | null = null) {
9026
+ function createStudioSideQuestionTools(
9027
+ contextRoot: string,
9028
+ webEnabled: boolean,
9029
+ gitSnapshot: StudioSideQuestionGitSnapshot | null = null,
9030
+ assertContextRoot?: () => string,
9031
+ ) {
8651
9032
  const mapTool = defineTool({
8652
9033
  name: "studio_context_map",
8653
9034
  label: "Map local context",
@@ -8658,6 +9039,7 @@ function createStudioSideQuestionTools(contextRoot: string, webEnabled: boolean,
8658
9039
  }),
8659
9040
  async execute(_toolCallId, params, signal) {
8660
9041
  if (signal?.aborted) throw new Error("Context map was cancelled.");
9042
+ if (assertContextRoot) assertContextRoot();
8661
9043
  const target = params.path
8662
9044
  ? resolveStudioSideQuestionPath(contextRoot, params.path, { directory: true }).path
8663
9045
  : contextRoot;
@@ -8680,6 +9062,7 @@ function createStudioSideQuestionTools(contextRoot: string, webEnabled: boolean,
8680
9062
  }),
8681
9063
  async execute(_toolCallId, params, signal) {
8682
9064
  if (signal?.aborted) throw new Error("Context read was cancelled.");
9065
+ if (assertContextRoot) assertContextRoot();
8683
9066
  const result = readStudioSideQuestionContextText(contextRoot, params.path, { offset: params.offset, limit: params.limit, maxChars: STUDIO_SIDE_CONTEXT_MAX_OUTPUT_CHARS });
8684
9067
  const extractableResult = result as { path: string; relativePath: string; extension?: string };
8685
9068
  const content: { text: string; startLine: number; endLine: number; totalLines: number; truncated: boolean } = result.requiresExtraction
@@ -8703,6 +9086,7 @@ function createStudioSideQuestionTools(contextRoot: string, webEnabled: boolean,
8703
9086
  maxResults: Type.Optional(Type.Integer({ minimum: 1, maximum: 200 })),
8704
9087
  }),
8705
9088
  async execute(_toolCallId, params, signal) {
9089
+ if (assertContextRoot) assertContextRoot();
8706
9090
  const result = await searchStudioSideQuestionLocalContext(contextRoot, params.query, {
8707
9091
  path: params.path,
8708
9092
  caseSensitive: params.caseSensitive,
@@ -9621,21 +10005,35 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
9621
10005
  msg.type === "save_as_request" &&
9622
10006
  typeof msg.requestId === "string" &&
9623
10007
  typeof msg.path === "string" &&
9624
- typeof msg.content === "string"
10008
+ typeof msg.content === "string" &&
10009
+ (msg.overwrite === undefined || typeof msg.overwrite === "boolean") &&
10010
+ (msg.expectedRevision === undefined || typeof msg.expectedRevision === "string")
9625
10011
  ) {
9626
10012
  return {
9627
10013
  type: "save_as_request",
9628
10014
  requestId: msg.requestId,
9629
10015
  path: msg.path,
9630
10016
  content: msg.content,
10017
+ overwrite: msg.overwrite === true,
10018
+ expectedRevision: typeof msg.expectedRevision === "string" ? msg.expectedRevision : undefined,
9631
10019
  };
9632
10020
  }
9633
10021
 
9634
- if (msg.type === "save_over_request" && typeof msg.requestId === "string" && typeof msg.content === "string") {
10022
+ if (
10023
+ msg.type === "save_over_request"
10024
+ && typeof msg.requestId === "string"
10025
+ && typeof msg.path === "string"
10026
+ && typeof msg.content === "string"
10027
+ && (msg.expectedRevision === undefined || typeof msg.expectedRevision === "string")
10028
+ && (msg.force === undefined || typeof msg.force === "boolean")
10029
+ ) {
9635
10030
  return {
9636
10031
  type: "save_over_request",
9637
10032
  requestId: msg.requestId,
10033
+ path: msg.path,
9638
10034
  content: msg.content,
10035
+ expectedRevision: typeof msg.expectedRevision === "string" ? msg.expectedRevision : undefined,
10036
+ force: msg.force === true,
9639
10037
  };
9640
10038
  }
9641
10039
 
@@ -9651,6 +10049,19 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
9651
10049
  };
9652
10050
  }
9653
10051
 
10052
+ if (
10053
+ msg.type === "watch_file_subscribe"
10054
+ && typeof msg.path === "string"
10055
+ && msg.path.length <= 16_384
10056
+ && (msg.revision === undefined || typeof msg.revision === "string")
10057
+ ) {
10058
+ return {
10059
+ type: "watch_file_subscribe",
10060
+ path: msg.path,
10061
+ revision: typeof msg.revision === "string" ? msg.revision : undefined,
10062
+ };
10063
+ }
10064
+
9654
10065
  if (msg.type === "send_to_editor_request" && typeof msg.requestId === "string" && typeof msg.content === "string") {
9655
10066
  return {
9656
10067
  type: "send_to_editor_request",
@@ -10987,6 +11398,7 @@ function buildStudioRelativeUrl(
10987
11398
  if (doc?.path) params.set("docPath", doc.path);
10988
11399
  if (doc?.draftId) params.set("draftId", doc.draftId);
10989
11400
  if (doc?.resourceDir) params.set("resourceDir", doc.resourceDir);
11401
+ if (doc?.watchFile) params.set("watchFile", "1");
10990
11402
  if (options?.skipWorkspaceRestore) params.set("skipWorkspaceRestore", "1");
10991
11403
  if (options?.paneFocus) params.set("paneFocus", options.paneFocus);
10992
11404
  return `/?${params.toString()}`;
@@ -11076,16 +11488,36 @@ function resolveRequestedStudioDocumentFromUrl(
11076
11488
  const requestedLabel = (requestUrl.searchParams.get("docLabel") ?? "").trim();
11077
11489
  const requestedDraftId = (requestUrl.searchParams.get("draftId") ?? "").trim();
11078
11490
  const requestedResourceDir = (requestUrl.searchParams.get("resourceDir") ?? "").trim();
11491
+ const requestedWatchFile = requestUrl.searchParams.get("watchFile") === "1";
11079
11492
 
11080
11493
  if (requestedPath) {
11081
11494
  const file = readStudioFile(requestedPath, studioCwd);
11082
- if (file.ok !== false) {
11495
+ const requestedCanonicalTextPath = resolve(requestedPath);
11496
+ const resolvedFilePath = file.ok !== false ? resolve(file.resolvedPath) : "";
11497
+ const watchedPathStayedCanonical = !requestedWatchFile || (
11498
+ process.platform === "win32"
11499
+ ? requestedCanonicalTextPath.toLowerCase() === resolvedFilePath.toLowerCase()
11500
+ : requestedCanonicalTextPath === resolvedFilePath
11501
+ );
11502
+ if (file.ok !== false && watchedPathStayedCanonical) {
11083
11503
  return {
11084
11504
  text: file.text,
11085
11505
  label: requestedLabel || file.label,
11086
11506
  source: "file",
11087
11507
  path: file.resolvedPath,
11088
11508
  resourceDir: requestedResourceDir || fallback?.resourceDir || studioCwd,
11509
+ diskRevision: file.diskRevision,
11510
+ watchFile: requestedWatchFile,
11511
+ };
11512
+ }
11513
+ if (requestedWatchFile && isAbsolute(requestedPath)) {
11514
+ return {
11515
+ text: fallback?.watchFile && fallback.path === requestedPath ? fallback.text : "",
11516
+ label: requestedLabel || basename(requestedPath),
11517
+ source: "file",
11518
+ path: resolve(requestedPath),
11519
+ resourceDir: requestedResourceDir || dirname(resolve(requestedPath)),
11520
+ watchFile: true,
11089
11521
  };
11090
11522
  }
11091
11523
  }
@@ -11370,6 +11802,8 @@ function buildStudioHtml(
11370
11802
  const initialPath = escapeHtmlForInline(initialDocument?.path ?? "");
11371
11803
  const initialDraftId = escapeHtmlForInline(initialDocument?.draftId ?? "");
11372
11804
  const initialResourceDir = escapeHtmlForInline(initialDocument?.resourceDir ?? "");
11805
+ const initialDiskRevision = escapeHtmlForInline(initialDocument?.diskRevision ?? "");
11806
+ const initialWatchFile = initialDocument?.watchFile === true ? "1" : "0";
11373
11807
  const initialModel = escapeHtmlForInline(initialModelLabel ?? "none");
11374
11808
  const initialTerminal = escapeHtmlForInline(initialTerminalLabel ?? "unknown");
11375
11809
  const initialTerminalDetailAttr = escapeHtmlForInline(initialTerminalDetail ?? initialTerminalLabel ?? "unknown");
@@ -11446,12 +11880,12 @@ ${cssVarsBlock}
11446
11880
  </style>
11447
11881
  <link rel="stylesheet" href="${stylesheetHref}" />
11448
11882
  </head>
11449
- <body data-initial-source="${initialSource}" data-initial-label="${initialLabel}" data-initial-path="${initialPath}" data-initial-draft-id="${initialDraftId}" data-initial-resource-dir="${initialResourceDir}" 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}">
11883
+ <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}">
11450
11884
  <header id="studioHeader">
11451
11885
  <h1><span class="app-logo" aria-hidden="true">π</span> Studio <span class="app-subtitle">${appSubtitle}</span></h1>
11452
11886
  <div class="controls">
11453
- <button id="saveAsBtn" type="button" title="Save editor content to a new file path. Cmd/Ctrl+S falls back here when no direct save path is available.">Save editor as…</button>
11454
- <button id="saveOverBtn" type="button" title="Overwrite current file with editor content. Shortcut: Cmd/Ctrl+S.">Save editor</button>
11887
+ <button id="saveAsBtn" type="button" title="Save editor content to a new file path. Shortcut: Cmd/Ctrl+Shift+S; Cmd/Ctrl+S falls back here for new drafts.">Save editor as…</button>
11888
+ <button id="saveOverBtn" type="button" title="Save only when the current file still matches Studio's disk revision. Shortcut: Cmd/Ctrl+S.">Save editor</button>
11455
11889
  <button id="refreshFromDiskBtn" type="button" title="Reload the current file-backed document from disk.">Refresh from disk</button>
11456
11890
  <button id="clearWorkspaceBtn" type="button" title="Clear editor text and reset this tab to a fresh blank draft. Saved files and responses are not changed.">Reset editor</button>
11457
11891
  <button id="importFileBtn" type="button" title="Import a file as an editable copy.">Import file copy…</button>
@@ -11671,7 +12105,7 @@ ${cssVarsBlock}
11671
12105
  <section id="rightPane">
11672
12106
  <div id="rightSectionHeader" class="section-header">
11673
12107
  <div class="section-header-main">
11674
- <select id="rightViewSelect" aria-label="Response view mode" title="Right pane view mode. F7 cycles when the right pane is active; Cmd/Ctrl+Alt+1–8 switches directly between all right-pane views. Cmd/Ctrl+Alt+P/E/W/Q keep their mnemonic Preview/Editor Preview/Working/Side questions shortcuts.">
12108
+ <select id="rightViewSelect" aria-label="Response view mode" title="Right pane view mode. F7 cycles when the right pane is active; Cmd/Ctrl+Alt+1–8 switches directly between all right-pane views. Cmd/Ctrl+Alt+P/E/W/F/Q keep mnemonic shortcuts for Preview, Editor Preview, Working, Files, and Side questions.">
11675
12109
  <option value="markdown">Response (Raw)</option>
11676
12110
  <option value="preview" selected>Response (Preview)</option>
11677
12111
  <option value="editor-preview">Editor (Preview)</option>
@@ -11685,6 +12119,7 @@ ${cssVarsBlock}
11685
12119
  </div>
11686
12120
  <div class="section-header-actions">
11687
12121
  <button id="rightFocusBtn" class="pane-focus-btn" type="button" title="Show only the response pane. Shortcut: F10 or Cmd/Ctrl+Esc.">Focus pane</button>
12122
+ <button id="watchedOpenEditableBtn" type="button" hidden title="Open this watched file in a separate editable Studio tab with conflict-safe saving.">Open editable tab</button>
11688
12123
  <span id="exportPreviewControls" class="export-preview-controls">
11689
12124
  <button id="exportPdfBtn" class="export-preview-trigger" type="button" aria-haspopup="menu" aria-expanded="false" title="Choose a format and export the current right-pane preview.">Export right preview</button>
11690
12125
  <div id="exportPreviewMenu" class="export-preview-menu" role="menu" hidden>
@@ -11780,6 +12215,7 @@ ${cssVarsBlock}
11780
12215
  <div><dt>Cmd/Ctrl+Alt+P</dt><dd>Switch the right pane directly to Response Preview; in editor-only views, Editor Preview</dd></div>
11781
12216
  <div><dt>Cmd/Ctrl+Alt+E</dt><dd>Switch the right pane directly to Editor Preview</dd></div>
11782
12217
  <div><dt>Cmd/Ctrl+Alt+W</dt><dd>Switch the right pane directly to Working</dd></div>
12218
+ <div><dt>Cmd/Ctrl+Alt+F</dt><dd>Switch the right pane directly to Files</dd></div>
11783
12219
  <div><dt>Cmd/Ctrl+Alt+Q</dt><dd>Switch the right pane directly to Side questions</dd></div>
11784
12220
  <div><dt>F8</dt><dd>Focus editor text</dd></div>
11785
12221
  <div><dt>Shift+F8</dt><dd>Focus right-pane content</dd></div>
@@ -11801,7 +12237,8 @@ ${cssVarsBlock}
11801
12237
  <section class="shortcuts-group">
11802
12238
  <h3>Editor</h3>
11803
12239
  <dl>
11804
- <div><dt>Cmd/Ctrl+S</dt><dd>Save editor</dd></div>
12240
+ <div><dt>Cmd/Ctrl+S</dt><dd>Save editor when the disk revision still matches</dd></div>
12241
+ <div><dt>Cmd/Ctrl+Shift+S</dt><dd>Save editor as a new file</dd></div>
11805
12242
  <div class="shortcuts-full-only"><dt>Cmd/Ctrl+Enter</dt><dd>Run editor text, or queue steering during an active run</dd></div>
11806
12243
  <div><dt>Option/Alt+Tab or Cmd/Ctrl+Shift+Space</dt><dd>Suggest a completion at the editor cursor</dd></div>
11807
12244
  <div><dt>Tab</dt><dd>Insert a visible completion suggestion; otherwise indent selected editor text</dd></div>
@@ -11876,6 +12313,10 @@ ${cssVarsBlock}
11876
12313
  export default function (pi: ExtensionAPI) {
11877
12314
  let serverState: StudioServerState | null = null;
11878
12315
  const studioWorkspaceStateStore = createStudioWorkspaceStateStore();
12316
+ const studioResourceGrantRegistry = createStudioResourceGrantRegistry();
12317
+ const studioFileWatchers = new Map<WebSocket, ReturnType<typeof createStudioFileWatcher>>();
12318
+ const studioWatchedClientPaths = new Map<WebSocket, string>();
12319
+ const studioFileWatchSubscriptionGenerations = new Map<WebSocket, number>();
11879
12320
  let activeRequest: ActiveStudioRequest | null = null;
11880
12321
  let studioDirectRunChain: StudioDirectRunChain | null = null;
11881
12322
  let queuedStudioDirectRequests: QueuedStudioDirectRequest[] = [];
@@ -11936,6 +12377,38 @@ export default function (pi: ExtensionAPI) {
11936
12377
  actionRequestId: null,
11937
12378
  };
11938
12379
 
12380
+ const closeStudioFileWatcher = async (client: WebSocket): Promise<void> => {
12381
+ const watcher = studioFileWatchers.get(client);
12382
+ if (!watcher) return;
12383
+ studioFileWatchers.delete(client);
12384
+ await watcher.close().catch(() => undefined);
12385
+ };
12386
+
12387
+ const closeAllStudioFileWatchers = async (): Promise<void> => {
12388
+ const clients = Array.from(studioFileWatchers.keys());
12389
+ await Promise.all(clients.map((client) => closeStudioFileWatcher(client)));
12390
+ // Keep socket capability metadata until each socket is closed. Clearing it
12391
+ // while an authenticated socket is still open would briefly make a watched
12392
+ // preview look writable to the save handlers.
12393
+ };
12394
+
12395
+ const recordStudioDocumentResourceGrants = (document: InitialStudioDocument, cwd = studioCwd) => {
12396
+ if (document.path) {
12397
+ try {
12398
+ studioResourceGrantRegistry.grantDocument(document.path, { cwd, source: "document" });
12399
+ } catch {
12400
+ // A failed grant must not broaden access or prevent an otherwise valid launch.
12401
+ }
12402
+ }
12403
+ if (document.resourceDir) {
12404
+ try {
12405
+ studioResourceGrantRegistry.grantDirectory(document.resourceDir, { cwd, source: "workspace" });
12406
+ } catch {
12407
+ // A failed workspace grant must not broaden access or prevent an otherwise valid launch.
12408
+ }
12409
+ }
12410
+ };
12411
+
11939
12412
  const selectStudioReplSessionForTool = (params: { sessionName?: string; target?: string }): { session: StudioReplSessionInfo | null; error?: string; sessions: StudioReplSessionInfo[] } => {
11940
12413
  const state = listStudioReplSessions();
11941
12414
  const sessions = state.sessions;
@@ -13604,7 +14077,7 @@ export default function (pi: ExtensionAPI) {
13604
14077
  await closeStudioSideQuestionRuntime(runtime);
13605
14078
  };
13606
14079
 
13607
- const resolveStudioSideQuestionContextRoot = (context: StudioSideQuestionContextInput): string => {
14080
+ const resolveStudioSideQuestionContextRoot = (context: StudioSideQuestionContextRootInput): string => {
13608
14081
  if (context.gatherScope === "none") return "";
13609
14082
  const sourcePath = resolveStudioQuizContextPath(context.sourcePath, studioCwd);
13610
14083
  const resourceDir = resolveStudioQuizContextPath(context.resourceDir, studioCwd);
@@ -13627,6 +14100,51 @@ export default function (pi: ExtensionAPI) {
13627
14100
  return resolveStudioSideQuestionRoot(root, studioCwd);
13628
14101
  };
13629
14102
 
14103
+ const assertStudioSideQuestionContextRootAuthorized = (contextRoot: string): string => {
14104
+ if (!contextRoot) return "";
14105
+ const stableRoot = assertStudioSideQuestionRootStable(contextRoot);
14106
+ const grant = studioResourceGrantRegistry.findGrant(stableRoot, { cwd: studioCwd });
14107
+ if (!grant || grant.kind !== "directory") throw new StudioDirectoryGrantRequiredError(stableRoot);
14108
+ return stableRoot;
14109
+ };
14110
+
14111
+ const resolveAuthorizedStudioSideQuestionContextRoot = (context: StudioSideQuestionContextRootInput): string => (
14112
+ assertStudioSideQuestionContextRootAuthorized(resolveStudioSideQuestionContextRoot(context))
14113
+ );
14114
+
14115
+ const handleStudioSideQuestionContextRootRequest = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
14116
+ const method = (req.method ?? "GET").toUpperCase();
14117
+ if (method !== "POST") {
14118
+ res.setHeader("Allow", "POST");
14119
+ respondJson(res, 405, { ok: false, error: "Method not allowed. Use POST." });
14120
+ return;
14121
+ }
14122
+ const rawBody = await readRequestBody(req, REQUEST_BODY_MAX_BYTES);
14123
+ let payload: Record<string, unknown> = {};
14124
+ try {
14125
+ payload = rawBody ? JSON.parse(rawBody) as Record<string, unknown> : {};
14126
+ } catch {
14127
+ respondJson(res, 400, { ok: false, error: "Invalid JSON body." });
14128
+ return;
14129
+ }
14130
+ const context: StudioSideQuestionContextRootInput = {
14131
+ gatherScope: normalizeStudioSideQuestionGatherScope(payload.gatherScope) as StudioSideQuestionGatherScope,
14132
+ sourcePath: typeof payload.sourcePath === "string" ? payload.sourcePath.slice(0, 16_384) : undefined,
14133
+ resourceDir: typeof payload.resourceDir === "string" ? payload.resourceDir.slice(0, 16_384) : undefined,
14134
+ contextPath: typeof payload.contextPath === "string" ? payload.contextPath.slice(0, 16_384) : undefined,
14135
+ };
14136
+ try {
14137
+ const contextRoot = resolveAuthorizedStudioSideQuestionContextRoot(context);
14138
+ respondJson(res, 200, { ok: true, contextRoot, authorized: Boolean(contextRoot) });
14139
+ } catch (error) {
14140
+ if (error instanceof StudioDirectoryGrantRequiredError) {
14141
+ respondStudioDirectoryGrantRequiredJson(res, error);
14142
+ return;
14143
+ }
14144
+ respondJson(res, 400, { ok: false, error: `Side-question context unavailable: ${error instanceof Error ? error.message : String(error)}` });
14145
+ }
14146
+ };
14147
+
13630
14148
  const describeStudioSideQuestionActivity = (toolName: string, args: unknown): string => {
13631
14149
  const value = args && typeof args === "object" ? args as Record<string, unknown> : {};
13632
14150
  if (toolName === "studio_context_read") return `Reading ${String(value.path || "local context")}`;
@@ -13729,19 +14247,20 @@ export default function (pi: ExtensionAPI) {
13729
14247
  const createStudioSideQuestionRuntime = async (context: StudioSideQuestionContextInput): Promise<StudioSideQuestionRuntime> => {
13730
14248
  const ctx = lastCommandCtx;
13731
14249
  if (!ctx) throw new Error("No active Studio command context is available. Re-open Studio and try again.");
13732
- const model = latestModelRequestCtx?.model ?? ctx.model;
13733
- if (!model) throw new Error("No active Pi model is available for side questions.");
13734
- await resolveStudioModelRequestAuth({ model, modelRegistry: ctx.modelRegistry }, model);
13735
- const contextRoot = resolveStudioSideQuestionContextRoot(context);
14250
+ const contextRoot = resolveAuthorizedStudioSideQuestionContextRoot(context);
13736
14251
  if (context.gitContext && context.gatherScope !== "repo") {
13737
14252
  throw new Error("Git context requires Related files to be set to Repository.");
13738
14253
  }
14254
+ const model = latestModelRequestCtx?.model ?? ctx.model;
14255
+ if (!model) throw new Error("No active Pi model is available for side questions.");
14256
+ await resolveStudioModelRequestAuth({ model, modelRegistry: ctx.modelRegistry }, model);
13739
14257
  const gitSnapshot = context.gitContext
13740
14258
  ? await captureStudioSideQuestionGitSnapshot(contextRoot, { runGit: runStudioSideQuestionGitCommand }) as StudioSideQuestionGitSnapshot
13741
14259
  : null;
13742
14260
  const webAvailable = Boolean(String(process.env.BRAVE_API_KEY || "").trim());
13743
14261
  const webEnabled = context.webSearch && webAvailable;
13744
- const { tools, toolNames } = createStudioSideQuestionTools(contextRoot || studioCwd, webEnabled, gitSnapshot);
14262
+ const assertContextRoot = contextRoot ? () => assertStudioSideQuestionContextRootAuthorized(contextRoot) : undefined;
14263
+ const { tools, toolNames } = createStudioSideQuestionTools(contextRoot || studioCwd, webEnabled, gitSnapshot, assertContextRoot);
13745
14264
  const localActiveToolNames = context.gatherScope === "none" ? (webEnabled ? ["studio_web_search"] : []) : toolNames;
13746
14265
  const toolSelection = selectStudioSideQuestionTools(getStudioSideQuestionToolCatalog(), context.toolIds);
13747
14266
  if (toolSelection.missing.length > 0) {
@@ -13924,6 +14443,7 @@ export default function (pi: ExtensionAPI) {
13924
14443
  sendStudioSideQuestionState();
13925
14444
  try {
13926
14445
  const context = msg.context;
14446
+ if (runtime.contextRoot) assertStudioSideQuestionContextRootAuthorized(runtime.contextRoot);
13927
14447
  let prompt: string;
13928
14448
  if (isFollowUp) {
13929
14449
  prompt = buildStudioSideQuestionFollowUpPrompt(msg.question);
@@ -13992,6 +14512,92 @@ export default function (pi: ExtensionAPI) {
13992
14512
  }
13993
14513
  };
13994
14514
 
14515
+ const subscribeStudioWatchedFile = async (client: WebSocket, msg: WatchFileSubscribeMessage): Promise<void> => {
14516
+ const subscriptionGeneration = (studioFileWatchSubscriptionGenerations.get(client) ?? 0) + 1;
14517
+ studioFileWatchSubscriptionGenerations.set(client, subscriptionGeneration);
14518
+ await closeStudioFileWatcher(client);
14519
+ if (studioFileWatchSubscriptionGenerations.get(client) !== subscriptionGeneration || client.readyState !== WebSocket.OPEN) return;
14520
+ if (studioFileWatchers.size >= STUDIO_WATCHED_FILE_MAX_CLIENTS) {
14521
+ sendToClient(client, {
14522
+ type: "watched_file_error",
14523
+ path: msg.path,
14524
+ message: `Studio watched-file client limit reached (${STUDIO_WATCHED_FILE_MAX_CLIENTS}).`,
14525
+ });
14526
+ return;
14527
+ }
14528
+ const canonicalPath = resolve(msg.path);
14529
+ const clientWatchedPath = studioWatchedClientPaths.get(client) ?? "";
14530
+ if (clientWatchedPath !== canonicalPath) {
14531
+ sendToClient(client, {
14532
+ type: "watched_file_error",
14533
+ path: canonicalPath,
14534
+ message: "This socket is not authorized for that server-created watched preview.",
14535
+ });
14536
+ return;
14537
+ }
14538
+
14539
+ const watcher = createStudioFileWatcher({
14540
+ filePath: canonicalPath,
14541
+ initialRevision: msg.revision,
14542
+ readSnapshot: (filePath: string) => {
14543
+ const file = readStudioFile(filePath, dirname(filePath), { requireCanonicalPath: true });
14544
+ if (file.ok === false) throw new Error(file.message);
14545
+ if (file.text.length > PREVIEW_RENDER_MAX_CHARS) {
14546
+ throw new Error(`Watched file exceeds the ${PREVIEW_RENDER_MAX_CHARS.toLocaleString()}-character preview limit.`);
14547
+ }
14548
+ return { path: file.resolvedPath, text: file.text, revision: file.diskRevision };
14549
+ },
14550
+ onUpdate: (snapshot: { path: string; text: string; revision: string }, details: { generation: number; recovered: boolean }) => {
14551
+ if (studioFileWatchers.get(client) !== watcher || client.readyState !== WebSocket.OPEN) return;
14552
+ sendToClient(client, {
14553
+ type: "watched_file_update",
14554
+ path: snapshot.path,
14555
+ text: snapshot.text,
14556
+ diskRevision: snapshot.revision,
14557
+ generation: details.generation,
14558
+ recovered: details.recovered,
14559
+ message: `Updated ${basename(snapshot.path)} from disk.`,
14560
+ });
14561
+ },
14562
+ onError: (error: unknown, details: { generation: number; lastRevision: string | null }) => {
14563
+ if (studioFileWatchers.get(client) !== watcher || client.readyState !== WebSocket.OPEN) return;
14564
+ sendToClient(client, {
14565
+ type: "watched_file_error",
14566
+ path: canonicalPath,
14567
+ diskRevision: details.lastRevision,
14568
+ generation: details.generation,
14569
+ message: `Could not refresh ${basename(canonicalPath)}; keeping the last good preview: ${error instanceof Error ? error.message : String(error)}`,
14570
+ });
14571
+ },
14572
+ onRecovered: (snapshot: { path: string; revision: string }, details: { generation: number; changed: boolean }) => {
14573
+ if (studioFileWatchers.get(client) !== watcher || client.readyState !== WebSocket.OPEN || details.changed) return;
14574
+ sendToClient(client, {
14575
+ type: "watched_file_ready",
14576
+ path: snapshot.path,
14577
+ diskRevision: snapshot.revision,
14578
+ generation: details.generation,
14579
+ recovered: true,
14580
+ message: `Watching ${basename(snapshot.path)} for disk changes.`,
14581
+ });
14582
+ },
14583
+ });
14584
+ studioFileWatchers.set(client, watcher);
14585
+ await watcher.refresh();
14586
+ if (
14587
+ studioFileWatchers.get(client) === watcher
14588
+ && client.readyState === WebSocket.OPEN
14589
+ && !watcher.error
14590
+ ) {
14591
+ sendToClient(client, {
14592
+ type: "watched_file_ready",
14593
+ path: canonicalPath,
14594
+ diskRevision: watcher.revision,
14595
+ generation: watcher.generation,
14596
+ message: `Watching ${basename(canonicalPath)} for disk changes.`,
14597
+ });
14598
+ }
14599
+ };
14600
+
13995
14601
  const handleStudioMessage = (client: WebSocket, msg: IncomingStudioMessage) => {
13996
14602
  if (msg.type === "ping") {
13997
14603
  sendToClient(client, { type: "pong", timestamp: Date.now() });
@@ -14001,6 +14607,16 @@ export default function (pi: ExtensionAPI) {
14001
14607
  studioWorkspaceStateStore.set(msg.tabStateId, msg.state);
14002
14608
  return;
14003
14609
  }
14610
+ if (msg.type === "watch_file_subscribe") {
14611
+ void subscribeStudioWatchedFile(client, msg).catch((error) => {
14612
+ sendToClient(client, {
14613
+ type: "watched_file_error",
14614
+ path: msg.path,
14615
+ message: `Could not start watched preview: ${error instanceof Error ? error.message : String(error)}`,
14616
+ });
14617
+ });
14618
+ return;
14619
+ }
14004
14620
 
14005
14621
  emitDebugEvent("studio_message", {
14006
14622
  type: msg.type,
@@ -14040,6 +14656,7 @@ export default function (pi: ExtensionAPI) {
14040
14656
  responseHistory: studioResponseHistory,
14041
14657
  traceState: studioTraceState,
14042
14658
  initialDocument: initialStudioDocument,
14659
+ resourceGrants: studioResourceGrantRegistry.snapshot(),
14043
14660
  quartoPreview: getStudioQuartoPreviewSnapshot(),
14044
14661
  sideQuestion: getStudioSideQuestionPublicState(),
14045
14662
  webSearchAvailable: Boolean(String(process.env.BRAVE_API_KEY || "").trim()),
@@ -14970,6 +15587,10 @@ export default function (pi: ExtensionAPI) {
14970
15587
  }
14971
15588
 
14972
15589
  if (msg.type === "save_as_request") {
15590
+ if (studioWatchedClientPaths.has(client)) {
15591
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: "Watched previews are read-only. Open an editable Studio tab before saving." });
15592
+ return;
15593
+ }
14973
15594
  if (!isValidRequestId(msg.requestId)) {
14974
15595
  sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
14975
15596
  return;
@@ -14978,14 +15599,21 @@ export default function (pi: ExtensionAPI) {
14978
15599
  sendToClient(client, { type: "busy", requestId: msg.requestId, message: "Studio is busy." });
14979
15600
  return;
14980
15601
  }
14981
- if (!msg.content.trim()) {
14982
- sendToClient(client, { type: "error", requestId: msg.requestId, message: "Nothing to save." });
14983
- return;
14984
- }
14985
15602
 
14986
- const result = writeStudioFile(msg.path, studioCwd, msg.content);
15603
+ const result = writeStudioFile(msg.path, studioCwd, msg.content, msg.overwrite === true, msg.expectedRevision);
14987
15604
  if (result.ok === false) {
14988
- sendToClient(client, { type: "error", requestId: msg.requestId, message: result.message });
15605
+ if (result.conflict) {
15606
+ sendToClient(client, {
15607
+ type: "save_as_conflict",
15608
+ requestId: msg.requestId,
15609
+ path: result.path || msg.path,
15610
+ currentRevision: result.currentRevision || null,
15611
+ reason: result.reason || "target-exists",
15612
+ message: result.message,
15613
+ });
15614
+ } else {
15615
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: result.message });
15616
+ }
14989
15617
  return;
14990
15618
  }
14991
15619
 
@@ -14995,7 +15623,9 @@ export default function (pi: ExtensionAPI) {
14995
15623
  source: "file",
14996
15624
  path: result.resolvedPath,
14997
15625
  resourceDir: dirname(result.resolvedPath),
15626
+ diskRevision: result.diskRevision,
14998
15627
  };
15628
+ recordStudioDocumentResourceGrants(initialStudioDocument);
14999
15629
 
15000
15630
  sendToClient(client, {
15001
15631
  type: "saved",
@@ -15003,12 +15633,17 @@ export default function (pi: ExtensionAPI) {
15003
15633
  path: result.resolvedPath,
15004
15634
  label: result.label,
15005
15635
  resourceDir: dirname(result.resolvedPath),
15636
+ diskRevision: result.diskRevision,
15006
15637
  message: `Saved editor text to ${result.label}`,
15007
15638
  });
15008
15639
  return;
15009
15640
  }
15010
15641
 
15011
15642
  if (msg.type === "save_over_request") {
15643
+ if (studioWatchedClientPaths.has(client)) {
15644
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: "Watched previews are read-only. Open an editable Studio tab before saving." });
15645
+ return;
15646
+ }
15012
15647
  if (!isValidRequestId(msg.requestId)) {
15013
15648
  sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
15014
15649
  return;
@@ -15017,35 +15652,52 @@ export default function (pi: ExtensionAPI) {
15017
15652
  sendToClient(client, { type: "busy", requestId: msg.requestId, message: "Studio is busy." });
15018
15653
  return;
15019
15654
  }
15020
- if (!initialStudioDocument || initialStudioDocument.source !== "file" || !initialStudioDocument.path) {
15021
- sendToClient(client, {
15022
- type: "error",
15023
- requestId: msg.requestId,
15024
- message: "Save file is only available for file-backed documents.",
15025
- });
15655
+
15656
+ const result = saveStudioDiskFileIfRevision({
15657
+ path: msg.path,
15658
+ content: msg.content,
15659
+ expectedRevision: msg.expectedRevision,
15660
+ force: msg.force === true,
15661
+ }) as StudioDiskWriteResult;
15662
+ if (result.ok === false) {
15663
+ if (result.conflict) {
15664
+ sendToClient(client, {
15665
+ type: "save_conflict",
15666
+ requestId: msg.requestId,
15667
+ path: result.path || msg.path,
15668
+ currentRevision: result.currentRevision || null,
15669
+ reason: result.reason || "disk-changed",
15670
+ canOverwrite: result.reason !== "location-changed" && result.reason !== "hard-linked-file",
15671
+ message: result.message,
15672
+ });
15673
+ } else {
15674
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: result.message });
15675
+ }
15026
15676
  return;
15027
15677
  }
15028
15678
 
15029
- try {
15030
- writeFileSync(initialStudioDocument.path, msg.content, "utf-8");
15031
- initialStudioDocument = {
15032
- ...initialStudioDocument,
15033
- text: msg.content,
15034
- };
15035
- sendToClient(client, {
15036
- type: "saved",
15037
- requestId: msg.requestId,
15038
- path: initialStudioDocument.path,
15039
- label: initialStudioDocument.label,
15040
- message: `Saved over ${initialStudioDocument.label}`,
15041
- });
15042
- } catch (error) {
15043
- sendToClient(client, {
15044
- type: "error",
15045
- requestId: msg.requestId,
15046
- message: `Failed to save over file: ${error instanceof Error ? error.message : String(error)}`,
15047
- });
15048
- }
15679
+ const previousDocument = initialStudioDocument;
15680
+ const existingLabel = previousDocument?.path === result.path
15681
+ ? previousDocument.label
15682
+ : basename(result.path);
15683
+ initialStudioDocument = {
15684
+ text: msg.content,
15685
+ label: existingLabel,
15686
+ source: "file",
15687
+ path: result.path,
15688
+ resourceDir: dirname(result.path),
15689
+ diskRevision: result.revision,
15690
+ };
15691
+ recordStudioDocumentResourceGrants(initialStudioDocument);
15692
+ sendToClient(client, {
15693
+ type: "saved",
15694
+ requestId: msg.requestId,
15695
+ path: result.path,
15696
+ label: existingLabel,
15697
+ resourceDir: dirname(result.path),
15698
+ diskRevision: result.revision,
15699
+ message: `Saved over ${existingLabel}`,
15700
+ });
15049
15701
  return;
15050
15702
  }
15051
15703
 
@@ -15069,7 +15721,7 @@ export default function (pi: ExtensionAPI) {
15069
15721
  return;
15070
15722
  }
15071
15723
 
15072
- const refreshed = readStudioFile(refreshPath, studioCwd);
15724
+ const refreshed = readStudioFile(refreshPath, studioCwd, { requireCanonicalPath: true });
15073
15725
  if (refreshed.ok === false) {
15074
15726
  sendToClient(client, {
15075
15727
  type: "error",
@@ -15085,9 +15737,11 @@ export default function (pi: ExtensionAPI) {
15085
15737
  source: "file",
15086
15738
  path: refreshed.resolvedPath,
15087
15739
  resourceDir: dirname(refreshed.resolvedPath),
15740
+ diskRevision: refreshed.diskRevision,
15088
15741
  };
15089
15742
  if (!requestedPath || initialStudioDocument?.path === refreshed.resolvedPath) {
15090
15743
  initialStudioDocument = refreshedDocument;
15744
+ recordStudioDocumentResourceGrants(refreshedDocument);
15091
15745
  }
15092
15746
 
15093
15747
  sendToClient(client, {
@@ -15592,14 +16246,25 @@ export default function (pi: ExtensionAPI) {
15592
16246
  parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { editorLanguage?: unknown }).editorLanguage === "string"
15593
16247
  ? (parsedBody as { editorLanguage: string }).editorLanguage
15594
16248
  : "";
15595
- const resourcePath = resolveStudioBaseDir(sourcePath || undefined, userResourceDir || undefined, studioCwd);
16249
+ const authorizedContext = resolveStudioAuthorizedPreviewRenderContext(
16250
+ sourcePath || undefined,
16251
+ userResourceDir || undefined,
16252
+ studioCwd,
16253
+ studioResourceGrantRegistry,
16254
+ );
15596
16255
  const editorPreviewLanguage = normalizeStudioEditorLanguage(requestedEditorLanguage);
15597
16256
  const isLatex = editorPreviewLanguage === "latex"
15598
16257
  || (
15599
16258
  (editorPreviewLanguage === undefined || editorPreviewLanguage === "markdown")
15600
16259
  && isLikelyStandaloneLatexPreview(markdown)
15601
16260
  );
15602
- const html = await renderStudioMarkdownWithPandoc(markdown, isLatex, resourcePath, sourcePath || undefined);
16261
+ const html = await renderStudioMarkdownWithPandoc(
16262
+ markdown,
16263
+ isLatex,
16264
+ authorizedContext.resourcePath,
16265
+ authorizedContext.sourcePath,
16266
+ { embedResources: false },
16267
+ );
15603
16268
  respondJson(res, 200, { ok: true, html, renderer: "pandoc" });
15604
16269
  } catch (error) {
15605
16270
  const message = error instanceof Error ? error.message : String(error);
@@ -16342,13 +17007,31 @@ export default function (pi: ExtensionAPI) {
16342
17007
  try {
16343
17008
  const listing = listStudioFileBrowserDirectory(
16344
17009
  requestUrl.searchParams.get("dir") ?? undefined,
17010
+ requestUrl.searchParams.get("root") ?? undefined,
16345
17011
  requestUrl.searchParams.get("sourcePath") ?? undefined,
16346
17012
  requestUrl.searchParams.get("resourceDir") ?? undefined,
16347
17013
  studioCwd,
17014
+ studioResourceGrantRegistry,
16348
17015
  requestUrl.searchParams.get("sort") ?? undefined,
16349
17016
  );
16350
- respondJson(res, 200, { ok: true, ...listing, entries: method === "HEAD" ? [] : listing.entries });
17017
+ respondJson(res, 200, {
17018
+ ok: true,
17019
+ ...listing,
17020
+ entries: method === "HEAD" ? [] : listing.entries,
17021
+ exactFiles: method === "HEAD" ? [] : listing.exactFiles,
17022
+ });
16351
17023
  } catch (error) {
17024
+ if (error instanceof StudioDirectoryGrantRequiredError) {
17025
+ respondJson(res, 403, {
17026
+ ok: false,
17027
+ code: error.code,
17028
+ error: error.message,
17029
+ path: error.directoryPath,
17030
+ directoryPath: error.directoryPath,
17031
+ ...getStudioFileBrowserGrantLocations(studioResourceGrantRegistry),
17032
+ });
17033
+ return;
17034
+ }
16352
17035
  respondJson(res, 404, { ok: false, error: `File browser unavailable: ${error instanceof Error ? error.message : String(error)}` });
16353
17036
  }
16354
17037
  return;
@@ -16361,7 +17044,7 @@ export default function (pi: ExtensionAPI) {
16361
17044
  return;
16362
17045
  }
16363
17046
 
16364
- void handleOpenStudioFileBrowserDirectoryRequest(req, res, studioCwd).catch((error) => {
17047
+ void handleOpenStudioFileBrowserDirectoryRequest(req, res, studioCwd, studioResourceGrantRegistry).catch((error) => {
16365
17048
  respondJson(res, 500, { ok: false, error: `Open folder failed: ${error instanceof Error ? error.message : String(error)}` });
16366
17049
  });
16367
17050
  return;
@@ -16380,6 +17063,32 @@ export default function (pi: ExtensionAPI) {
16380
17063
  return;
16381
17064
  }
16382
17065
 
17066
+ if (requestUrl.pathname === "/resource-grants") {
17067
+ const token = requestUrl.searchParams.get("token") ?? "";
17068
+ if (token !== serverState.token) {
17069
+ respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
17070
+ return;
17071
+ }
17072
+
17073
+ void handleStudioResourceGrantRequest(req, res, studioResourceGrantRegistry, studioCwd).catch((error) => {
17074
+ respondJson(res, 500, { ok: false, error: `Resource grant failed: ${error instanceof Error ? error.message : String(error)}` });
17075
+ });
17076
+ return;
17077
+ }
17078
+
17079
+ if (requestUrl.pathname === "/side-question-context-root") {
17080
+ const token = requestUrl.searchParams.get("token") ?? "";
17081
+ if (token !== serverState.token) {
17082
+ respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
17083
+ return;
17084
+ }
17085
+
17086
+ void handleStudioSideQuestionContextRootRequest(req, res).catch((error) => {
17087
+ respondJson(res, 500, { ok: false, error: `Side-question context check failed: ${error instanceof Error ? error.message : String(error)}` });
17088
+ });
17089
+ return;
17090
+ }
17091
+
16383
17092
  if (requestUrl.pathname === "/local-preview-link") {
16384
17093
  const token = requestUrl.searchParams.get("token") ?? "";
16385
17094
  if (token !== serverState.token) {
@@ -16394,9 +17103,21 @@ export default function (pi: ExtensionAPI) {
16394
17103
  requestUrl.searchParams.get("sourcePath") ?? undefined,
16395
17104
  requestUrl.searchParams.get("resourceDir") ?? undefined,
16396
17105
  studioCwd,
17106
+ studioResourceGrantRegistry,
17107
+ );
17108
+ await respondLocalPreviewLinkJson(
17109
+ req,
17110
+ res,
17111
+ requestUrl,
17112
+ resource,
17113
+ serverState,
17114
+ studioResourceGrantRegistry,
16397
17115
  );
16398
- await respondLocalPreviewLinkJson(req, res, requestUrl, resource, serverState);
16399
17116
  } catch (error) {
17117
+ if (error instanceof StudioResourceGrantRequiredError) {
17118
+ respondStudioResourceGrantRequiredJson(res, error);
17119
+ return;
17120
+ }
16400
17121
  respondJson(res, 404, { ok: false, error: `Local resource unavailable: ${error instanceof Error ? error.message : String(error)}` });
16401
17122
  }
16402
17123
  })();
@@ -16410,7 +17131,7 @@ export default function (pi: ExtensionAPI) {
16410
17131
  return;
16411
17132
  }
16412
17133
 
16413
- void handleRevealLocalPreviewResourceRequest(req, res, studioCwd).catch((error) => {
17134
+ void handleRevealLocalPreviewResourceRequest(req, res, studioCwd, studioResourceGrantRegistry).catch((error) => {
16414
17135
  respondJson(res, 500, { ok: false, error: `Reveal failed: ${error instanceof Error ? error.message : String(error)}` });
16415
17136
  });
16416
17137
  return;
@@ -16423,7 +17144,7 @@ export default function (pi: ExtensionAPI) {
16423
17144
  return;
16424
17145
  }
16425
17146
 
16426
- void handleOpenLocalPreviewResourceRequest(req, res, studioCwd).catch((error) => {
17147
+ void handleOpenLocalPreviewResourceRequest(req, res, studioCwd, studioResourceGrantRegistry).catch((error) => {
16427
17148
  respondJson(res, 500, { ok: false, error: `Open in system viewer failed: ${error instanceof Error ? error.message : String(error)}` });
16428
17149
  });
16429
17150
  return;
@@ -16442,9 +17163,14 @@ export default function (pi: ExtensionAPI) {
16442
17163
  requestUrl.searchParams.get("sourcePath") ?? undefined,
16443
17164
  requestUrl.searchParams.get("resourceDir") ?? undefined,
16444
17165
  studioCwd,
17166
+ studioResourceGrantRegistry,
16445
17167
  );
16446
17168
  respondPdfFile(req, res, filePath);
16447
17169
  } catch (error) {
17170
+ if (error instanceof StudioResourceGrantRequiredError) {
17171
+ respondStudioResourceGrantRequiredJson(res, error);
17172
+ return;
17173
+ }
16448
17174
  respondText(res, 404, `PDF resource unavailable: ${error instanceof Error ? error.message : String(error)}`);
16449
17175
  }
16450
17176
  return;
@@ -16463,10 +17189,15 @@ export default function (pi: ExtensionAPI) {
16463
17189
  requestUrl.searchParams.get("sourcePath") ?? undefined,
16464
17190
  requestUrl.searchParams.get("resourceDir") ?? undefined,
16465
17191
  studioCwd,
17192
+ studioResourceGrantRegistry,
16466
17193
  );
16467
17194
  respondHtmlPreviewResourceJson(req, res, resource.filePath, resource.mimeType);
16468
17195
  } catch (error) {
16469
- respondJson(res, 404, { ok: false, error: `HTML preview resource unavailable: ${error instanceof Error ? error.message : String(error)}` });
17196
+ if (error instanceof StudioResourceGrantRequiredError) {
17197
+ respondStudioResourceGrantRequiredJson(res, error);
17198
+ return;
17199
+ }
17200
+ respondJson(res, 404, { ok: false, error: `Studio preview resource unavailable: ${error instanceof Error ? error.message : String(error)}` });
16470
17201
  }
16471
17202
  return;
16472
17203
  }
@@ -16545,6 +17276,32 @@ export default function (pi: ExtensionAPI) {
16545
17276
  const host = req.headers.host ?? `127.0.0.1:${state.port}`;
16546
17277
  const requestUrl = new URL(req.url ?? "/ws", `http://${host}`);
16547
17278
  const clientMode = normalizeStudioUiMode(requestUrl.searchParams.get("mode"));
17279
+ const requestedWatchedPath = (requestUrl.searchParams.get("watchPath") ?? "").trim();
17280
+ const canonicalWatchedPath = requestedWatchedPath ? resolve(requestedWatchedPath) : "";
17281
+ const requestedDocId = (requestUrl.searchParams.get("docId") ?? "").trim();
17282
+ const capabilityDocument = requestedDocId ? readTransientStudioDocument(requestedDocId) : null;
17283
+ const capabilityWatchedPath = capabilityDocument?.watchFile && capabilityDocument.path
17284
+ ? resolve(capabilityDocument.path)
17285
+ : "";
17286
+ const comparableWatchedPath = process.platform === "win32" ? canonicalWatchedPath.toLowerCase() : canonicalWatchedPath;
17287
+ const comparableCapabilityPath = process.platform === "win32" ? capabilityWatchedPath.toLowerCase() : capabilityWatchedPath;
17288
+ const requestsWatchedCapability = Boolean(canonicalWatchedPath || capabilityWatchedPath);
17289
+ if (
17290
+ requestsWatchedCapability
17291
+ && (
17292
+ clientMode !== "editor-only"
17293
+ || !canonicalWatchedPath
17294
+ || !capabilityWatchedPath
17295
+ || comparableWatchedPath !== comparableCapabilityPath
17296
+ )
17297
+ ) {
17298
+ try {
17299
+ ws.close(4003, "Watched preview authorization unavailable");
17300
+ } catch {
17301
+ // Ignore close errors.
17302
+ }
17303
+ return;
17304
+ }
16548
17305
  if (clientMode === "full") {
16549
17306
  for (const client of clients) {
16550
17307
  if (client.readyState !== WebSocket.OPEN) continue;
@@ -16560,6 +17317,7 @@ export default function (pi: ExtensionAPI) {
16560
17317
  }
16561
17318
  clients.add(ws);
16562
17319
  clientModes.set(ws, clientMode);
17320
+ if (canonicalWatchedPath) studioWatchedClientPaths.set(ws, canonicalWatchedPath);
16563
17321
  emitDebugEvent("studio_ws_connected", { clients: clients.size, mode: clientMode });
16564
17322
  broadcastState();
16565
17323
 
@@ -16572,15 +17330,33 @@ export default function (pi: ExtensionAPI) {
16572
17330
  handleStudioMessage(ws, parsed);
16573
17331
  });
16574
17332
 
16575
- ws.on("close", () => {
17333
+ const cleanupClient = () => {
16576
17334
  clients.delete(ws);
16577
17335
  clientModes.delete(ws);
17336
+ studioWatchedClientPaths.delete(ws);
17337
+ const cleanupGeneration = (studioFileWatchSubscriptionGenerations.get(ws) ?? 0) + 1;
17338
+ studioFileWatchSubscriptionGenerations.set(ws, cleanupGeneration);
17339
+ void closeStudioFileWatcher(ws).finally(() => {
17340
+ if (studioFileWatchSubscriptionGenerations.get(ws) === cleanupGeneration) {
17341
+ studioFileWatchSubscriptionGenerations.delete(ws);
17342
+ }
17343
+ });
17344
+ };
17345
+
17346
+ ws.on("close", () => {
17347
+ cleanupClient();
16578
17348
  emitDebugEvent("studio_ws_disconnected", { clients: clients.size });
16579
17349
  });
16580
17350
 
16581
17351
  ws.on("error", () => {
16582
- clients.delete(ws);
16583
- clientModes.delete(ws);
17352
+ // Make the socket unusable before removing its watched-path marker;
17353
+ // otherwise an error event could briefly weaken read-only enforcement.
17354
+ try {
17355
+ ws.terminate();
17356
+ } catch {
17357
+ try { ws.close(); } catch {}
17358
+ }
17359
+ cleanupClient();
16584
17360
  });
16585
17361
  });
16586
17362
 
@@ -16648,7 +17424,12 @@ export default function (pi: ExtensionAPI) {
16648
17424
  const stopServer = async () => {
16649
17425
  studioSideQuestionStartRequestId = null;
16650
17426
  await disposeStudioSideQuestionRuntime();
16651
- if (!serverState) return;
17427
+ await closeAllStudioFileWatchers();
17428
+ if (!serverState) {
17429
+ studioWatchedClientPaths.clear();
17430
+ studioFileWatchSubscriptionGenerations.clear();
17431
+ return;
17432
+ }
16652
17433
  clearStudioDirectRunState();
16653
17434
  clearActiveRequest();
16654
17435
  clearPendingStudioCompletion();
@@ -16674,7 +17455,10 @@ export default function (pi: ExtensionAPI) {
16674
17455
  await new Promise<void>((resolve) => {
16675
17456
  state.server.close(() => resolve());
16676
17457
  });
17458
+ studioWatchedClientPaths.clear();
17459
+ studioFileWatchSubscriptionGenerations.clear();
16677
17460
  studioWorkspaceStateStore.clear();
17461
+ studioResourceGrantRegistry.clear();
16678
17462
  };
16679
17463
 
16680
17464
  const hydrateLatestAssistant = (entries: SessionEntry[]) => {
@@ -16691,6 +17475,13 @@ export default function (pi: ExtensionAPI) {
16691
17475
  await disposeStudioSideQuestionRuntime();
16692
17476
  studioTraceHistory.clear();
16693
17477
  lastCommandCtx = null;
17478
+ for (const client of studioWatchedClientPaths.keys()) {
17479
+ try { client.close(4001, "Watched preview session changed"); } catch {}
17480
+ }
17481
+ await closeAllStudioFileWatchers();
17482
+ transientStudioDocuments.clear();
17483
+ studioResourceGrantRegistry.clear();
17484
+ initialStudioDocument = null;
16694
17485
  }
16695
17486
  latestModelRequestCtx = ctx;
16696
17487
  hydrateLatestAssistant(ctx.sessionManager.getBranch());
@@ -17042,7 +17833,7 @@ export default function (pi: ExtensionAPI) {
17042
17833
  const resolveStudioLaunchDocument = (
17043
17834
  trimmed: string,
17044
17835
  ctx: ExtensionCommandContext,
17045
- options?: { defaultSource?: "blank" | "last-response"; commandLabel?: string; allowPdfPreview?: boolean; watchPdf?: boolean },
17836
+ options?: { defaultSource?: "blank" | "last-response"; commandLabel?: string; allowPdfPreview?: boolean; watchPdf?: boolean; watchText?: boolean },
17046
17837
  ): StudioLaunchSelection | null => {
17047
17838
  const defaultSource = options?.defaultSource === "blank" ? "blank" : "last-response";
17048
17839
  const commandLabel = options?.commandLabel ?? "/studio";
@@ -17149,6 +17940,30 @@ export default function (pi: ExtensionAPI) {
17149
17940
  return null;
17150
17941
  }
17151
17942
 
17943
+ if (options?.watchText) {
17944
+ if (file.text.length > PREVIEW_RENDER_MAX_CHARS) {
17945
+ ctx.ui.notify(`Watched preview source exceeds ${PREVIEW_RENDER_MAX_CHARS.toLocaleString()} characters.`, "error");
17946
+ return null;
17947
+ }
17948
+ return {
17949
+ document: {
17950
+ text: file.text,
17951
+ label: file.label,
17952
+ source: "file",
17953
+ path: file.resolvedPath,
17954
+ resourceDir: dirname(file.resolvedPath),
17955
+ diskRevision: file.diskRevision,
17956
+ watchFile: true,
17957
+ },
17958
+ kind: "watched-preview",
17959
+ mode: "editor-only",
17960
+ transient: true,
17961
+ skipWorkspaceRestore: true,
17962
+ paneFocus: "right",
17963
+ resourcePath: file.resolvedPath,
17964
+ };
17965
+ }
17966
+
17152
17967
  if (file.text.length > 200_000) {
17153
17968
  ctx.ui.notify(
17154
17969
  "Loaded a large file. Studio critique requests currently reject documents over 200k characters.",
@@ -17162,6 +17977,7 @@ export default function (pi: ExtensionAPI) {
17162
17977
  source: "file",
17163
17978
  path: file.resolvedPath,
17164
17979
  resourceDir: ctx.cwd,
17980
+ diskRevision: file.diskRevision,
17165
17981
  });
17166
17982
  };
17167
17983
 
@@ -17419,7 +18235,7 @@ export default function (pi: ExtensionAPI) {
17419
18235
  trimmed: string,
17420
18236
  ctx: ExtensionCommandContext,
17421
18237
  mode: StudioUiMode,
17422
- options?: { defaultSource?: "blank" | "last-response"; commandLabel?: string; replaceExistingFull?: boolean; allowPdfPreview?: boolean; watchPdf?: boolean },
18238
+ options?: { defaultSource?: "blank" | "last-response"; commandLabel?: string; replaceExistingFull?: boolean; allowPdfPreview?: boolean; watchPdf?: boolean; watchText?: boolean },
17423
18239
  ) => {
17424
18240
  const launchOpenFlags = parseStudioLaunchOpenFlags(trimmed);
17425
18241
  if (launchOpenFlags.error) {
@@ -17435,11 +18251,12 @@ export default function (pi: ExtensionAPI) {
17435
18251
  const launchesPdfPreview = parsedLaunchPath
17436
18252
  ? Boolean(parseStudioPdfLaunchTarget(normalizePathInput(parsedLaunchPath)))
17437
18253
  : false;
17438
- if (launchOpenFlags.watchPdf && !launchesPdfPreview) {
17439
- ctx.ui.notify("--watch requires a local PDF path, for example: /studio --watch main.pdf", "error");
18254
+ if (launchOpenFlags.watchPdf && !parsedLaunchPath) {
18255
+ ctx.ui.notify("--watch requires a local text or PDF path, for example: /studio --watch notes.md", "error");
17440
18256
  return;
17441
18257
  }
17442
- const requestedLaunchMode: StudioUiMode = launchesPdfPreview ? "editor-only" : mode;
18258
+ const launchesWatchedTextPreview = Boolean(launchOpenFlags.watchPdf && parsedLaunchPath && !launchesPdfPreview);
18259
+ const requestedLaunchMode: StudioUiMode = launchesPdfPreview || launchesWatchedTextPreview ? "editor-only" : mode;
17443
18260
  if (requestedLaunchMode === "full" && hasConnectedFullStudioView()) {
17444
18261
  if (options?.replaceExistingFull) {
17445
18262
  closeStudioClientsByMode("full", 4001, "Full Studio replaced");
@@ -17473,7 +18290,8 @@ export default function (pi: ExtensionAPI) {
17473
18290
 
17474
18291
  const selection = resolveStudioLaunchDocument(launchArgs, ctx, {
17475
18292
  ...options,
17476
- watchPdf: launchOpenFlags.watchPdf,
18293
+ watchPdf: launchOpenFlags.watchPdf && launchesPdfPreview,
18294
+ watchText: launchesWatchedTextPreview,
17477
18295
  });
17478
18296
  if (!selection) return;
17479
18297
  const selected = selection.document;
@@ -17489,6 +18307,7 @@ export default function (pi: ExtensionAPI) {
17489
18307
  ctx.ui.notify(`Failed to start Studio server${portText}: ${message}`, "error");
17490
18308
  return;
17491
18309
  }
18310
+ recordStudioDocumentResourceGrants(selected, ctx.cwd);
17492
18311
  const docId = selection.transient ? storeTransientStudioDocument(selected) : undefined;
17493
18312
  const url = buildStudioUrl(state.port, state.token, launchMode, selected, docId, {
17494
18313
  skipWorkspaceRestore: selection.skipWorkspaceRestore,
@@ -17498,7 +18317,9 @@ export default function (pi: ExtensionAPI) {
17498
18317
  ?? (launchOpenFlags.noBrowser ? buildStudioForwardingHint(state.port, url, { prefix: "Browser auto-open was skipped because --no-browser was used." }) : null);
17499
18318
  const openedLabel = selection.kind === "pdf-preview"
17500
18319
  ? "pi Studio PDF preview"
17501
- : (launchMode === "editor-only" ? "pi Studio editor-only view" : "pi Studio");
18320
+ : (selection.kind === "watched-preview"
18321
+ ? "pi Studio watched file preview"
18322
+ : (launchMode === "editor-only" ? "pi Studio editor-only view" : "pi Studio"));
17502
18323
 
17503
18324
  const shouldOpenBrowser = shouldAutoOpenStudioBrowser({
17504
18325
  openRemoteBrowser: launchOpenFlags.openRemoteBrowser,
@@ -17513,6 +18334,8 @@ export default function (pi: ExtensionAPI) {
17513
18334
  if (selection.kind === "pdf-preview") {
17514
18335
  const watchLabel = launchOpenFlags.watchPdf ? " (auto-refresh on)" : "";
17515
18336
  ctx.ui.notify(`Opened ${openedLabel}${watchLabel}: ${selection.resourcePath ?? selected.label}`, "info");
18337
+ } else if (selection.kind === "watched-preview") {
18338
+ ctx.ui.notify(`Opened ${openedLabel}: ${selection.resourcePath ?? selected.label}`, "info");
17516
18339
  } else if (selected.source === "file") {
17517
18340
  ctx.ui.notify(`Opened ${openedLabel} with file loaded: ${selected.label}`, "info");
17518
18341
  } else if (selected.source === "last-response") {
@@ -17535,7 +18358,7 @@ export default function (pi: ExtensionAPI) {
17535
18358
  };
17536
18359
 
17537
18360
  pi.registerCommand("studio", {
17538
- description: "Open pi Studio browser UI or a PDF preview (/studio, /studio <file>, /studio --watch <pdf>, /studio --blank, /studio --last, /studio --no-browser)",
18361
+ 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)",
17539
18362
  handler: async (args: string, ctx: ExtensionCommandContext) => {
17540
18363
  const trimmed = args.trim();
17541
18364
 
@@ -17566,7 +18389,7 @@ export default function (pi: ExtensionAPI) {
17566
18389
  "Usage: /studio [path|--blank|--last]\n"
17567
18390
  + " /studio Open studio with last model response (fallback: blank)\n"
17568
18391
  + " /studio <path> Open a text file in Studio, or a PDF in a read-only companion preview\n"
17569
- + " /studio --watch <pdf> Open a PDF with auto-refresh enabled\n"
18392
+ + " /studio --watch <file> Open a read-only text preview that follows disk changes, or a PDF with auto-refresh\n"
17570
18393
  + " /studio --blank Open with blank editor\n"
17571
18394
  + " /studio --last Open with last model response\n"
17572
18395
  + " /studio --no-browser Print the Studio URL without opening a browser\n"
@@ -17617,7 +18440,7 @@ export default function (pi: ExtensionAPI) {
17617
18440
  });
17618
18441
 
17619
18442
  pi.registerCommand("studio-editor-only", {
17620
- description: "Open pi Studio in editor-only mode or preview a PDF (/studio-editor-only, /studio-editor-only <file>, /studio-editor-only --watch <pdf>)",
18443
+ description: "Open pi Studio in editor-only mode or a read-only watched preview (/studio-editor-only, /studio-editor-only <file>, /studio-editor-only --watch <file>)",
17621
18444
  handler: async (args: string, ctx: ExtensionCommandContext) => {
17622
18445
  const trimmed = args.trim();
17623
18446
  if (trimmed === "help" || trimmed === "--help" || trimmed === "-h") {
@@ -17625,7 +18448,7 @@ export default function (pi: ExtensionAPI) {
17625
18448
  "Usage: /studio-editor-only [path|--blank|--last]\n"
17626
18449
  + " /studio-editor-only Open an editor-only Studio view (default: blank editor)\n"
17627
18450
  + " /studio-editor-only <path> Open a text file for editing, or a PDF in a read-only preview\n"
17628
- + " /studio-editor-only --watch <pdf> Open a PDF with auto-refresh enabled\n"
18451
+ + " /studio-editor-only --watch <file> Open a read-only text preview that follows disk changes, or a PDF with auto-refresh\n"
17629
18452
  + " /studio-editor-only --blank Open with blank editor\n"
17630
18453
  + " /studio-editor-only --last Open with last model response loaded into the editor\n"
17631
18454
  + " /studio-editor-only --no-browser Print URL without opening a browser\n"
@@ -17981,8 +18804,10 @@ export default function (pi: ExtensionAPI) {
17981
18804
  label: file.label,
17982
18805
  source: "file",
17983
18806
  path: file.resolvedPath,
18807
+ diskRevision: file.diskRevision,
17984
18808
  };
17985
18809
  initialStudioDocument = nextDoc;
18810
+ recordStudioDocumentResourceGrants(nextDoc, ctx.cwd);
17986
18811
 
17987
18812
  broadcastState();
17988
18813
  broadcastResponseHistory();