pi-studio 0.9.39 → 0.9.41

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
@@ -29,7 +29,14 @@ import {
29
29
  } from "./shared/studio-markdown-latex-literals.js";
30
30
  import { escapeStudioPdfLatexTextFragment } from "./shared/studio-pdf-escape.js";
31
31
  import { resolveStudioPdfResourceFile } from "./shared/studio-pdf-resource.js";
32
+ import { buildStudioReplTmuxStartArgs } from "./shared/studio-repl-tmux.js";
32
33
  import { buildStudioForwardingHint, buildStudioSshTunnelHint, isStudioSshSession as isSshSession } from "./shared/studio-ssh-hint.js";
34
+ import {
35
+ buildStudioPendingPage,
36
+ buildStudioPendingSecurityHeaders,
37
+ isValidStudioLaunchId,
38
+ normalizeStudioPendingKind,
39
+ } from "./shared/studio-tab-launcher.js";
33
40
  import { renderStudioAnnotationInlineHtml } from "./shared/studio-annotation-render.js";
34
41
  import {
35
42
  buildStudioMermaidCliIconArgs,
@@ -89,6 +96,7 @@ interface StudioQuartoPreviewState {
89
96
  const STUDIO_CSS_URL = new URL("./client/studio.css", import.meta.url);
90
97
  const STUDIO_ANNOTATION_HELPERS_URL = new URL("./client/studio-annotation-helpers.js", import.meta.url);
91
98
  const STUDIO_MERMAID_HELPERS_URL = new URL("./client/studio-mermaid-helpers.js", import.meta.url);
99
+ const STUDIO_NAVIGATION_HELPERS_URL = new URL("./client/studio-navigation-helpers.js", import.meta.url);
92
100
  const STUDIO_CLIENT_URL = new URL("./client/studio-client.js", import.meta.url);
93
101
 
94
102
  interface StudioServerState {
@@ -7258,6 +7266,17 @@ function respondText(res: ServerResponse, status: number, text: string): void {
7258
7266
  res.end(text);
7259
7267
  }
7260
7268
 
7269
+ function respondStudioPendingError(res: ServerResponse, status: number, text: string, allow?: string): void {
7270
+ const nonce = randomUUID().replace(/-/g, "");
7271
+ const headers = {
7272
+ ...buildStudioPendingSecurityHeaders(nonce),
7273
+ "Content-Type": "text/plain; charset=utf-8",
7274
+ ...(allow ? { Allow: allow } : {}),
7275
+ };
7276
+ res.writeHead(status, headers);
7277
+ res.end(text);
7278
+ }
7279
+
7261
7280
  function respondPdfFile(req: IncomingMessage, res: ServerResponse, filePath: string): void {
7262
7281
  const method = (req.method ?? "GET").toUpperCase();
7263
7282
  if (method !== "GET" && method !== "HEAD") {
@@ -7393,12 +7412,9 @@ async function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResp
7393
7412
  }
7394
7413
  const document = buildStudioLocalResourcePreviewDocument(resource);
7395
7414
  const docId = storeTransientStudioDocument(document);
7396
- const url = buildStudioUrl(serverState.port, serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true });
7397
- const parsedUrl = new URL(url);
7398
7415
  respondJson(res, 200, {
7399
7416
  ...basePayload,
7400
- url,
7401
- relativeUrl: `${parsedUrl.pathname}${parsedUrl.search}`,
7417
+ relativeUrl: buildStudioRelativeUrl(serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true }),
7402
7418
  });
7403
7419
  return;
7404
7420
  }
@@ -7458,13 +7474,10 @@ async function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResp
7458
7474
  }
7459
7475
 
7460
7476
  const docId = storeTransientStudioDocument(document);
7461
- const url = buildStudioUrl(serverState.port, serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true });
7462
- const parsedUrl = new URL(url);
7463
7477
  respondJson(res, 200, {
7464
7478
  ...basePayload,
7465
7479
  converted,
7466
- url,
7467
- relativeUrl: `${parsedUrl.pathname}${parsedUrl.search}`,
7480
+ relativeUrl: buildStudioRelativeUrl(serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true }),
7468
7481
  });
7469
7482
  }
7470
7483
 
@@ -9626,7 +9639,7 @@ function startStudioReplSession(runtime: StudioReplRuntime, cwd: string, options
9626
9639
  };
9627
9640
  }
9628
9641
  const command = getStudioReplRuntimeCommand(runtime, commandOverride);
9629
- const result = runStudioTmux(["new-session", "-d", "-s", sessionName, "-c", cwd || process.cwd(), command], { timeout: 5_000 });
9642
+ const result = runStudioTmux(buildStudioReplTmuxStartArgs(sessionName, cwd || process.cwd(), command), { timeout: 5_000 });
9630
9643
  if (!result.ok) return { ok: false, message: result.message || `Failed to start ${STUDIO_REPL_RUNTIME_LABELS[runtime]} REPL.` };
9631
9644
  return {
9632
9645
  ok: true,
@@ -10077,8 +10090,7 @@ function readTransientStudioDocument(id: string): InitialStudioDocument | null {
10077
10090
  return entry ? { ...entry.document } : null;
10078
10091
  }
10079
10092
 
10080
- function buildStudioUrl(
10081
- port: number,
10093
+ function buildStudioRelativeUrl(
10082
10094
  token: string,
10083
10095
  mode: StudioUiMode = "full",
10084
10096
  doc?: InitialStudioDocument | null,
@@ -10094,7 +10106,18 @@ function buildStudioUrl(
10094
10106
  if (doc?.draftId) params.set("draftId", doc.draftId);
10095
10107
  if (doc?.resourceDir) params.set("resourceDir", doc.resourceDir);
10096
10108
  if (options?.skipWorkspaceRestore) params.set("skipWorkspaceRestore", "1");
10097
- return `http://127.0.0.1:${port}/?${params.toString()}`;
10109
+ return `/?${params.toString()}`;
10110
+ }
10111
+
10112
+ function buildStudioUrl(
10113
+ port: number,
10114
+ token: string,
10115
+ mode: StudioUiMode = "full",
10116
+ doc?: InitialStudioDocument | null,
10117
+ docId?: string,
10118
+ options?: { skipWorkspaceRestore?: boolean },
10119
+ ): string {
10120
+ return `http://127.0.0.1:${port}${buildStudioRelativeUrl(token, mode, doc, docId, options)}`;
10098
10121
  }
10099
10122
 
10100
10123
  interface StudioLaunchFlags {
@@ -10508,6 +10531,7 @@ function buildStudioHtml(
10508
10531
  const stylesheetHref = `/studio.css?token=${encodeURIComponent(studioToken ?? "")}`;
10509
10532
  const annotationHelpersScriptHref = `/studio-annotation-helpers.js?token=${encodeURIComponent(studioToken ?? "")}`;
10510
10533
  const mermaidHelpersScriptHref = `/studio-mermaid-helpers.js?token=${encodeURIComponent(studioToken ?? "")}`;
10534
+ const navigationHelpersScriptHref = `/studio-navigation-helpers.js?token=${encodeURIComponent(studioToken ?? "")}`;
10511
10535
  const clientScriptHref = `/studio-client.js?token=${encodeURIComponent(studioToken ?? "")}`;
10512
10536
  const faviconHref = buildStudioFaviconDataUri(style);
10513
10537
  const bootConfigJson = JSON.stringify({ mermaidConfig }).replace(/</g, "\\u003c");
@@ -10926,6 +10950,7 @@ ${cssVarsBlock}
10926
10950
  </script>
10927
10951
  <script src="${annotationHelpersScriptHref}"></script>
10928
10952
  <script src="${mermaidHelpersScriptHref}"></script>
10953
+ <script src="${navigationHelpersScriptHref}"></script>
10929
10954
  <script src="${clientScriptHref}"></script>
10930
10955
  </body>
10931
10956
  </html>`;
@@ -12851,13 +12876,10 @@ export default function (pi: ExtensionAPI) {
12851
12876
  resourceDir,
12852
12877
  };
12853
12878
  const docId = storeTransientStudioDocument(document);
12854
- const url = buildStudioUrl(serverState.port, serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true });
12855
- const parsedUrl = new URL(url);
12856
12879
  sendToClient(client, {
12857
12880
  type: "editor_only_ready",
12858
12881
  requestId: msg.requestId,
12859
- url,
12860
- relativeUrl: `${parsedUrl.pathname}${parsedUrl.search}`,
12882
+ relativeUrl: buildStudioRelativeUrl(serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true }),
12861
12883
  message: hasContent
12862
12884
  ? "Editor tab is ready with a detached copy of the current editor text."
12863
12885
  : "Blank editor tab is ready.",
@@ -14237,8 +14259,6 @@ export default function (pi: ExtensionAPI) {
14237
14259
  resourceDir: dirname(exportedPath),
14238
14260
  };
14239
14261
  const docId = storeTransientStudioDocument(document);
14240
- const url = buildStudioUrl(serverState.port, serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true });
14241
- const parsedUrl = new URL(url);
14242
14262
  respondJson(res, 200, {
14243
14263
  ok: true,
14244
14264
  filename,
@@ -14246,8 +14266,7 @@ export default function (pi: ExtensionAPI) {
14246
14266
  writeError: writeResult.error,
14247
14267
  warning: warning ?? null,
14248
14268
  openedStudio: true,
14249
- url,
14250
- relativeUrl: `${parsedUrl.pathname}${parsedUrl.search}`,
14269
+ relativeUrl: buildStudioRelativeUrl(serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true }),
14251
14270
  downloadUrl: `/export-pdf?token=${encodeURIComponent(token)}&id=${encodeURIComponent(exportId)}`,
14252
14271
  });
14253
14272
  return;
@@ -14384,8 +14403,6 @@ export default function (pi: ExtensionAPI) {
14384
14403
  draftId: exportedPath ? undefined : createStudioDraftId(),
14385
14404
  };
14386
14405
  const docId = storeTransientStudioDocument(document);
14387
- const url = buildStudioUrl(serverState.port, serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true });
14388
- const parsedUrl = new URL(url);
14389
14406
  respondJson(res, 200, {
14390
14407
  ok: true,
14391
14408
  filename,
@@ -14393,8 +14410,7 @@ export default function (pi: ExtensionAPI) {
14393
14410
  writeError: writeResult.error,
14394
14411
  warning: warning ?? null,
14395
14412
  openedStudio: true,
14396
- url,
14397
- relativeUrl: `${parsedUrl.pathname}${parsedUrl.search}`,
14413
+ relativeUrl: buildStudioRelativeUrl(serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true }),
14398
14414
  downloadUrl: `/export-html?token=${encodeURIComponent(token)}&id=${encodeURIComponent(exportId)}`,
14399
14415
  });
14400
14416
  return;
@@ -14453,6 +14469,39 @@ export default function (pi: ExtensionAPI) {
14453
14469
  return;
14454
14470
  }
14455
14471
 
14472
+ if (requestUrl.pathname === "/studio-open-pending") {
14473
+ const tokens = requestUrl.searchParams.getAll("token");
14474
+ if (tokens.length !== 1 || tokens[0] !== serverState.token) {
14475
+ respondStudioPendingError(res, 403, "Invalid or expired studio token. Re-run /studio.");
14476
+ return;
14477
+ }
14478
+
14479
+ const method = (req.method ?? "GET").toUpperCase();
14480
+ if (method !== "GET") {
14481
+ respondStudioPendingError(res, 405, "Method not allowed. Use GET.", "GET");
14482
+ return;
14483
+ }
14484
+
14485
+ const allowedKeys = new Set(["token", "launchId", "kind"]);
14486
+ if (Array.from(requestUrl.searchParams.keys()).some((key) => !allowedKeys.has(key))) {
14487
+ respondStudioPendingError(res, 400, "Unsupported Studio pending-page parameter.");
14488
+ return;
14489
+ }
14490
+ const launchIds = requestUrl.searchParams.getAll("launchId");
14491
+ const kinds = requestUrl.searchParams.getAll("kind");
14492
+ const launchId = launchIds.length === 1 ? launchIds[0] : "";
14493
+ const kind = kinds.length === 1 ? normalizeStudioPendingKind(kinds[0]) : null;
14494
+ if (!isValidStudioLaunchId(launchId) || !kind) {
14495
+ respondStudioPendingError(res, 400, "Invalid Studio pending-page request.");
14496
+ return;
14497
+ }
14498
+
14499
+ const nonce = randomUUID().replace(/-/g, "");
14500
+ res.writeHead(200, buildStudioPendingSecurityHeaders(nonce));
14501
+ res.end(buildStudioPendingPage({ token: serverState.token, launchId, kind, nonce }));
14502
+ return;
14503
+ }
14504
+
14456
14505
  if (requestUrl.pathname === "/studio.css") {
14457
14506
  const token = requestUrl.searchParams.get("token") ?? "";
14458
14507
  if (token !== serverState.token) {
@@ -14482,7 +14531,12 @@ export default function (pi: ExtensionAPI) {
14482
14531
  return;
14483
14532
  }
14484
14533
 
14485
- if (requestUrl.pathname === "/studio-annotation-helpers.js" || requestUrl.pathname === "/studio-mermaid-helpers.js" || requestUrl.pathname === "/studio-client.js") {
14534
+ if (
14535
+ requestUrl.pathname === "/studio-annotation-helpers.js"
14536
+ || requestUrl.pathname === "/studio-mermaid-helpers.js"
14537
+ || requestUrl.pathname === "/studio-navigation-helpers.js"
14538
+ || requestUrl.pathname === "/studio-client.js"
14539
+ ) {
14486
14540
  const token = requestUrl.searchParams.get("token") ?? "";
14487
14541
  if (token !== serverState.token) {
14488
14542
  respondText(res, 403, "Invalid or expired studio token. Re-run /studio.");
@@ -14500,12 +14554,16 @@ export default function (pi: ExtensionAPI) {
14500
14554
  ? STUDIO_ANNOTATION_HELPERS_URL
14501
14555
  : requestUrl.pathname === "/studio-mermaid-helpers.js"
14502
14556
  ? STUDIO_MERMAID_HELPERS_URL
14503
- : STUDIO_CLIENT_URL;
14557
+ : requestUrl.pathname === "/studio-navigation-helpers.js"
14558
+ ? STUDIO_NAVIGATION_HELPERS_URL
14559
+ : STUDIO_CLIENT_URL;
14504
14560
  const targetLabel = requestUrl.pathname === "/studio-annotation-helpers.js"
14505
14561
  ? "studio annotation helper script"
14506
14562
  : requestUrl.pathname === "/studio-mermaid-helpers.js"
14507
14563
  ? "studio Mermaid helper script"
14508
- : "studio client script";
14564
+ : requestUrl.pathname === "/studio-navigation-helpers.js"
14565
+ ? "studio navigation helper script"
14566
+ : "studio client script";
14509
14567
 
14510
14568
  try {
14511
14569
  const clientScript = readFileSync(targetUrl, "utf-8");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.9.39",
3
+ "version": "0.9.41",
4
4
  "description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, active quiz, prompt/response history, live previews, and tmux-backed REPL/literate REPL workflows",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,6 +30,7 @@
30
30
  ],
31
31
  "scripts": {
32
32
  "test": "node --test",
33
+ "test:browser": "node --test test/studio-tab-launcher-browser.test.js",
33
34
  "typecheck": "tsc --noEmit"
34
35
  },
35
36
  "pi": {
@@ -0,0 +1,28 @@
1
+ const STUDIO_REPL_PROMPT_COMMAND_ENVIRONMENT = "PROMPT_COMMAND=";
2
+
3
+ /**
4
+ * Build the tmux arguments for a Studio-owned REPL session.
5
+ *
6
+ * PROMPT_COMMAND contains shell-local function names on some terminals (notably
7
+ * cmux/Ghostty with macOS Bash 3.2). The variable may be exported to Pi while
8
+ * the corresponding functions cannot be inherited by a child shell, so start
9
+ * each detached REPL with a clean prompt-command environment.
10
+ *
11
+ * @param {string} sessionName
12
+ * @param {string} cwd
13
+ * @param {string} command
14
+ * @returns {string[]}
15
+ */
16
+ export function buildStudioReplTmuxStartArgs(sessionName, cwd, command) {
17
+ return [
18
+ "new-session",
19
+ "-d",
20
+ "-s",
21
+ sessionName,
22
+ "-c",
23
+ cwd,
24
+ "-e",
25
+ STUDIO_REPL_PROMPT_COMMAND_ENVIRONMENT,
26
+ command,
27
+ ];
28
+ }
@@ -0,0 +1,86 @@
1
+ const STUDIO_PENDING_KINDS = Object.freeze(["document", "preview", "export"]);
2
+ const STUDIO_LAUNCH_ID_PATTERN = /^[a-zA-Z0-9_-]{20,128}$/;
3
+ const STUDIO_CSP_NONCE_PATTERN = /^[a-zA-Z0-9_-]{16,128}$/;
4
+
5
+ function escapeHtmlAttribute(value) {
6
+ return String(value ?? "")
7
+ .replace(/&/g, "&amp;")
8
+ .replace(/"/g, "&quot;")
9
+ .replace(/</g, "&lt;")
10
+ .replace(/>/g, "&gt;");
11
+ }
12
+
13
+ export function normalizeStudioPendingKind(value) {
14
+ return STUDIO_PENDING_KINDS.includes(value) ? value : null;
15
+ }
16
+
17
+ export function isValidStudioLaunchId(value) {
18
+ return typeof value === "string" && STUDIO_LAUNCH_ID_PATTERN.test(value);
19
+ }
20
+
21
+ export function buildStudioPendingSecurityHeaders(nonce) {
22
+ if (typeof nonce !== "string" || !STUDIO_CSP_NONCE_PATTERN.test(nonce)) {
23
+ throw new Error("Invalid Studio pending-page CSP nonce.");
24
+ }
25
+ return {
26
+ "Content-Type": "text/html; charset=utf-8",
27
+ "Cache-Control": "no-store",
28
+ "X-Content-Type-Options": "nosniff",
29
+ "Referrer-Policy": "no-referrer",
30
+ "Cross-Origin-Opener-Policy": "same-origin",
31
+ "Cross-Origin-Resource-Policy": "same-origin",
32
+ "Permissions-Policy": "camera=(), microphone=(), geolocation=(), payment=(), usb=()",
33
+ "Content-Security-Policy": [
34
+ "default-src 'none'",
35
+ `script-src 'nonce-${nonce}'`,
36
+ `style-src 'nonce-${nonce}'`,
37
+ "base-uri 'none'",
38
+ "form-action 'none'",
39
+ "object-src 'none'",
40
+ "frame-ancestors 'none'",
41
+ ].join("; "),
42
+ };
43
+ }
44
+
45
+ export function buildStudioPendingPage(options) {
46
+ const config = options && typeof options === "object" ? options : {};
47
+ const token = typeof config.token === "string" ? config.token : "";
48
+ const launchId = typeof config.launchId === "string" ? config.launchId : "";
49
+ const kind = normalizeStudioPendingKind(config.kind);
50
+ const nonce = typeof config.nonce === "string" ? config.nonce : "";
51
+ if (!token) throw new Error("Studio pending page requires a token.");
52
+ if (!isValidStudioLaunchId(launchId)) throw new Error("Invalid Studio launch ID.");
53
+ if (!kind) throw new Error("Invalid Studio pending-page kind.");
54
+ buildStudioPendingSecurityHeaders(nonce);
55
+
56
+ const kindLabel = kind === "document" ? "document" : (kind === "export" ? "export" : "preview");
57
+ const helperQuery = new URLSearchParams({ token }).toString();
58
+ return `<!doctype html>
59
+ <html lang="en">
60
+ <head>
61
+ <meta charset="utf-8" />
62
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
63
+ <title>Preparing Studio tab…</title>
64
+ <style nonce="${escapeHtmlAttribute(nonce)}">
65
+ :root { color-scheme: light dark; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
66
+ body { min-height: 100vh; margin: 0; display: grid; place-items: center; background: Canvas; color: CanvasText; }
67
+ main { width: min(34rem, calc(100vw - 3rem)); padding: 2rem; border: 1px solid color-mix(in srgb, CanvasText 18%, transparent); border-radius: 12px; }
68
+ h1 { margin: 0 0 0.65rem; font-size: 1.15rem; }
69
+ p { margin: 0; line-height: 1.5; opacity: 0.8; overflow-wrap: anywhere; }
70
+ button { margin-top: 1.25rem; padding: 0.45rem 0.8rem; font: inherit; }
71
+ [hidden] { display: none !important; }
72
+ </style>
73
+ </head>
74
+ <body data-studio-pending-launch="1" data-launch-id="${escapeHtmlAttribute(launchId)}" data-launch-kind="${escapeHtmlAttribute(kind)}" data-studio-token="${escapeHtmlAttribute(token)}">
75
+ <main aria-live="polite" aria-atomic="true">
76
+ <h1 id="pendingTitle">Preparing Studio ${kindLabel}…</h1>
77
+ <p id="pendingDetail">Waiting for the originating Studio page.</p>
78
+ <button id="pendingCloseBtn" type="button" hidden>Close tab</button>
79
+ </main>
80
+ <noscript>This Studio tab requires JavaScript. You can close it and return to the originating Studio page.</noscript>
81
+ <script nonce="${escapeHtmlAttribute(nonce)}" src="/studio-navigation-helpers.js?${escapeHtmlAttribute(helperQuery)}"></script>
82
+ </body>
83
+ </html>`;
84
+ }
85
+
86
+ export { STUDIO_PENDING_KINDS };