pi-studio 0.9.0 → 0.9.2

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
@@ -1,8 +1,9 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext, SessionEntry, Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
3
+ import { Type } from "@sinclair/typebox";
3
4
  import { spawn, spawnSync } from "node:child_process";
4
5
  import { createHash, randomUUID } from "node:crypto";
5
- import { readFileSync, statSync, writeFileSync } from "node:fs";
6
+ import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
6
7
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
7
8
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
8
9
  import { homedir, tmpdir } from "node:os";
@@ -411,6 +412,14 @@ const STUDIO_TRACE_SNAPSHOT_MAX_IMAGE_BASE64_CHARS = 6_000_000;
411
412
  const STUDIO_TRACE_IMAGE_SAFE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
412
413
  const STUDIO_REPL_CAPTURE_LINES = 800;
413
414
  const STUDIO_REPL_SEND_MAX_CHARS = 200_000;
415
+ const STUDIO_REPL_SEND_DEFAULT_TIMEOUT_MS = 20_000;
416
+ const STUDIO_REPL_SEND_MAX_TIMEOUT_MS = 120_000;
417
+ const STUDIO_REPL_CONTROL_ROOT = join(tmpdir(), "pi-studio-repl");
418
+ const STUDIO_SUBPROCESS_OUTPUT_MAX_BYTES = 2_000_000;
419
+ const STUDIO_PANDOC_TIMEOUT_MS = readStudioPositiveEnvMs("PI_STUDIO_PANDOC_TIMEOUT_MS", 120_000, 5_000, 15 * 60_000);
420
+ const STUDIO_LATEX_TIMEOUT_MS = readStudioPositiveEnvMs("PI_STUDIO_LATEX_TIMEOUT_MS", 120_000, 5_000, 15 * 60_000);
421
+ const STUDIO_MERMAID_TIMEOUT_MS = readStudioPositiveEnvMs("PI_STUDIO_MERMAID_TIMEOUT_MS", 60_000, 5_000, 10 * 60_000);
422
+ const STUDIO_HTML_RENDER_OUTPUT_MAX_BYTES = readStudioPositiveEnvMs("PI_STUDIO_HTML_RENDER_OUTPUT_MAX_BYTES", 50_000_000, 1_000_000, 500_000_000);
414
423
  const STUDIO_REPL_RUNTIME_LABELS: Record<StudioReplRuntime, string> = {
415
424
  shell: "Shell",
416
425
  python: "Python",
@@ -420,6 +429,16 @@ const STUDIO_REPL_RUNTIME_LABELS: Record<StudioReplRuntime, string> = {
420
429
  ghci: "GHCi",
421
430
  clojure: "Clojure",
422
431
  };
432
+ const STUDIO_REPL_SEND_TOOL_PARAMS = Type.Object({
433
+ code: Type.String({ description: "Code to execute in the active or selected Studio REPL session." }),
434
+ sessionName: Type.Optional(Type.String({ description: "Exact Studio/pi-repl tmux session name. If omitted, Studio uses the active REPL session, or the first session matching target." })),
435
+ target: Type.Optional(Type.String({ description: "Optional runtime target: shell, python, ipython, julia, r, ghci, or clojure. Used when sessionName is omitted." })),
436
+ timeoutMs: Type.Optional(Type.Number({ description: "Maximum time to wait for completion when Studio can detect it (default 20000, max 120000).", minimum: 1000, maximum: STUDIO_REPL_SEND_MAX_TIMEOUT_MS })),
437
+ });
438
+ const STUDIO_REPL_STATUS_TOOL_PARAMS = Type.Object({
439
+ sessionName: Type.Optional(Type.String({ description: "Exact Studio/pi-repl tmux session name to inspect." })),
440
+ target: Type.Optional(Type.String({ description: "Optional runtime target: shell, python, ipython, julia, r, ghci, or clojure. If omitted, report all Studio-visible REPL sessions." })),
441
+ });
423
442
  const MAX_STUDIO_TRACE_SNAPSHOTS = RESPONSE_HISTORY_LIMIT;
424
443
  const TRANSIENT_STUDIO_DOCUMENT_TTL_MS = 30 * 60 * 1000;
425
444
  const MAX_TRANSIENT_STUDIO_DOCUMENTS = 16;
@@ -432,9 +451,155 @@ const STUDIO_DEFAULT_SCRATCHPAD_DOCUMENT_KEY = "doc:blank:blank";
432
451
  const STUDIO_PERSISTENT_STATE_DIR = join(getAgentDir(), "pi-studio");
433
452
  const STUDIO_PERSISTENT_STATE_PATH = join(STUDIO_PERSISTENT_STATE_DIR, "local-state.json");
434
453
 
454
+ type StudioSubprocessResult = {
455
+ code: number | null;
456
+ signal: NodeJS.Signals | null;
457
+ stdout: string;
458
+ stderr: string;
459
+ stdoutTruncated: boolean;
460
+ stderrTruncated: boolean;
461
+ };
462
+
463
+ type StudioSubprocessOptions = {
464
+ cwd?: string;
465
+ input?: string;
466
+ timeoutMs?: number;
467
+ stdoutMaxBytes?: number;
468
+ stderrMaxBytes?: number;
469
+ notFoundMessage?: string;
470
+ notFoundError?: () => Error;
471
+ label?: string;
472
+ };
473
+
474
+ function readStudioPositiveEnvMs(name: string, fallback: number, min: number, max: number): number {
475
+ const parsed = Number.parseInt(String(process.env[name] ?? ""), 10);
476
+ if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
477
+ return Math.max(min, Math.min(max, parsed));
478
+ }
479
+
480
+ function appendStudioSubprocessChunk(chunks: Buffer[], chunk: Buffer | string, state: { bytes: number; truncated: boolean }, maxBytes = STUDIO_SUBPROCESS_OUTPUT_MAX_BYTES): void {
481
+ if (state.bytes >= maxBytes) {
482
+ state.truncated = true;
483
+ return;
484
+ }
485
+ const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
486
+ const remaining = maxBytes - state.bytes;
487
+ if (buffer.length <= remaining) {
488
+ chunks.push(buffer);
489
+ state.bytes += buffer.length;
490
+ return;
491
+ }
492
+ chunks.push(buffer.subarray(0, remaining));
493
+ state.bytes += remaining;
494
+ state.truncated = true;
495
+ }
496
+
497
+ function finalizeStudioSubprocessOutput(chunks: Buffer[], truncated: boolean): string {
498
+ const value = Buffer.concat(chunks).toString("utf-8").trim();
499
+ return truncated ? `${value}\n[output truncated by Studio]`.trim() : value;
500
+ }
501
+
502
+ function runStudioSubprocess(command: string, args: string[], options: StudioSubprocessOptions = {}): Promise<StudioSubprocessResult> {
503
+ return new Promise((resolvePromise, rejectPromise) => {
504
+ const timeoutMs = Math.max(1_000, Math.floor(options.timeoutMs ?? STUDIO_PANDOC_TIMEOUT_MS));
505
+ const child = spawn(command, args, {
506
+ cwd: options.cwd,
507
+ stdio: [typeof options.input === "string" ? "pipe" : "ignore", "pipe", "pipe"],
508
+ });
509
+ const stdoutChunks: Buffer[] = [];
510
+ const stderrChunks: Buffer[] = [];
511
+ const stdoutMaxBytes = Math.max(1, Math.floor(options.stdoutMaxBytes ?? STUDIO_SUBPROCESS_OUTPUT_MAX_BYTES));
512
+ const stderrMaxBytes = Math.max(1, Math.floor(options.stderrMaxBytes ?? STUDIO_SUBPROCESS_OUTPUT_MAX_BYTES));
513
+ const stdoutState = { bytes: 0, truncated: false };
514
+ const stderrState = { bytes: 0, truncated: false };
515
+ let settled = false;
516
+ let timedOut = false;
517
+ let killTimer: NodeJS.Timeout | null = null;
518
+
519
+ const cleanup = () => {
520
+ clearTimeout(timeoutTimer);
521
+ if (killTimer) clearTimeout(killTimer);
522
+ };
523
+ const fail = (error: Error) => {
524
+ if (settled) return;
525
+ settled = true;
526
+ cleanup();
527
+ rejectPromise(error);
528
+ };
529
+ const succeed = (result: StudioSubprocessResult) => {
530
+ if (settled) return;
531
+ settled = true;
532
+ cleanup();
533
+ resolvePromise(result);
534
+ };
535
+ const label = options.label || basename(command) || command;
536
+ const timeoutTimer = setTimeout(() => {
537
+ timedOut = true;
538
+ try { child.kill("SIGTERM"); } catch {}
539
+ killTimer = setTimeout(() => {
540
+ try { child.kill("SIGKILL"); } catch {}
541
+ }, 2_000);
542
+ }, timeoutMs);
543
+
544
+ child.stdout?.on("data", (chunk: Buffer | string) => appendStudioSubprocessChunk(stdoutChunks, chunk, stdoutState, stdoutMaxBytes));
545
+ child.stderr?.on("data", (chunk: Buffer | string) => appendStudioSubprocessChunk(stderrChunks, chunk, stderrState, stderrMaxBytes));
546
+
547
+ child.once("error", (error) => {
548
+ const errno = error as NodeJS.ErrnoException;
549
+ if (errno.code === "ENOENT") {
550
+ fail(options.notFoundError ? options.notFoundError() : new Error(options.notFoundMessage || `${command} was not found.`));
551
+ return;
552
+ }
553
+ fail(error);
554
+ });
555
+
556
+ child.once("close", (code, signal) => {
557
+ if (timedOut) {
558
+ fail(new Error(`${label} timed out after ${Math.round(timeoutMs / 1000)}s.`));
559
+ return;
560
+ }
561
+ succeed({
562
+ code,
563
+ signal,
564
+ stdout: finalizeStudioSubprocessOutput(stdoutChunks, stdoutState.truncated),
565
+ stderr: finalizeStudioSubprocessOutput(stderrChunks, stderrState.truncated),
566
+ stdoutTruncated: stdoutState.truncated,
567
+ stderrTruncated: stderrState.truncated,
568
+ });
569
+ });
570
+
571
+ if (typeof options.input === "string") {
572
+ child.stdin?.end(options.input);
573
+ }
574
+ });
575
+ }
576
+
577
+ function buildStudioPandocPdfEngineOptArgs(pdfEngine: string): string[] {
578
+ const engineName = basename(String(pdfEngine || "")).toLowerCase();
579
+ if (!/^(?:pdf|xe|lua)?latex$/.test(engineName)) return [];
580
+ return [
581
+ "--pdf-engine-opt=-interaction=nonstopmode",
582
+ "--pdf-engine-opt=-halt-on-error",
583
+ "--pdf-engine-opt=-file-line-error",
584
+ ];
585
+ }
586
+
587
+ const STUDIO_PANDOC_HTML_FRAGMENT_TEMPLATE = `<!doctype html>
588
+ <html>
589
+ <head>
590
+ <meta charset="utf-8" />
591
+ <title>pi Studio preview</title>
592
+ </head>
593
+ <body>
594
+ $body$
595
+ </body>
596
+ </html>
597
+ `;
598
+
435
599
  let studioPersistentStateCache: StudioPersistentState | null = null;
436
600
  let studioPersistentStateQueue: Promise<void> = Promise.resolve();
437
601
  let transientStudioDocuments: Map<string, { document: InitialStudioDocument; createdAt: number }> = new Map();
602
+ const studioReplControlSubmissionLabels = new Map<string, string>();
438
603
 
439
604
  function createEmptyStudioPersistentState(): StudioPersistentState {
440
605
  return {
@@ -4469,46 +4634,17 @@ async function renderStudioMermaidDiagramForPdf(source: string, workDir: string,
4469
4634
  const outputPath = join(workDir, `mermaid-diagram-${blockNumber}.pdf`);
4470
4635
 
4471
4636
  await writeFile(inputPath, source, "utf-8");
4472
- await new Promise<void>((resolve, reject) => {
4473
- const args = ["-i", inputPath, "-o", outputPath, "-t", mermaidTheme, "-f"];
4474
- const child = spawn(mermaidCommand, args, { stdio: ["ignore", "ignore", "pipe"] });
4475
- const stderrChunks: Buffer[] = [];
4476
- let settled = false;
4477
-
4478
- const fail = (error: Error) => {
4479
- if (settled) return;
4480
- settled = true;
4481
- reject(error);
4482
- };
4483
-
4484
- child.stderr.on("data", (chunk: Buffer | string) => {
4485
- stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
4486
- });
4487
-
4488
- child.once("error", (error) => {
4489
- const errno = error as NodeJS.ErrnoException;
4490
- if (errno.code === "ENOENT") {
4491
- fail(
4492
- new MermaidCliMissingError(
4493
- "Mermaid CLI (mmdc) not found. Install with `npm install -g @mermaid-js/mermaid-cli` or set MERMAID_CLI_PATH.",
4494
- ),
4495
- );
4496
- return;
4497
- }
4498
- fail(error);
4499
- });
4500
-
4501
- child.once("close", (code) => {
4502
- if (settled) return;
4503
- settled = true;
4504
- if (code === 0) {
4505
- resolve();
4506
- return;
4507
- }
4508
- const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
4509
- reject(new Error(`Mermaid CLI failed with exit code ${code}${stderr ? `: ${stderr}` : ""}`));
4510
- });
4637
+ const args = ["-i", inputPath, "-o", outputPath, "-t", mermaidTheme, "-f"];
4638
+ const result = await runStudioSubprocess(mermaidCommand, args, {
4639
+ timeoutMs: STUDIO_MERMAID_TIMEOUT_MS,
4640
+ label: "Mermaid CLI",
4641
+ notFoundError: () => new MermaidCliMissingError(
4642
+ "Mermaid CLI (mmdc) not found. Install with `npm install -g @mermaid-js/mermaid-cli` or set MERMAID_CLI_PATH.",
4643
+ ),
4511
4644
  });
4645
+ if (result.code !== 0) {
4646
+ throw new Error(`Mermaid CLI failed with exit code ${result.code}${result.stderr ? `: ${result.stderr}` : ""}`);
4647
+ }
4512
4648
 
4513
4649
  return outputPath;
4514
4650
  }
@@ -4693,82 +4829,67 @@ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolea
4693
4829
  const inputFormat = isLatex ? "latex" : "markdown+lists_without_preceding_blankline-blank_before_blockquote-blank_before_header+tex_math_dollars+tex_math_single_backslash+tex_math_double_backslash+autolink_bare_uris-raw_html";
4694
4830
  const bibliographyArgs = buildStudioPandocBibliographyArgs(markdown, isLatex, resourcePath);
4695
4831
  const args = ["-f", inputFormat, "-t", "html5", "--mathml", "--wrap=none", ...bibliographyArgs];
4832
+ let htmlTemplateDir: string | null = null;
4696
4833
  if (resourcePath) {
4697
4834
  args.push(`--resource-path=${resourcePath}`);
4698
- // Embed images as data URIs so they render in the browser preview
4699
- args.push("--embed-resources", "--standalone");
4835
+ // Embed images as data URIs so browser previews and exported HTML keep local figures.
4836
+ // A minimal template prevents Pandoc's standalone default CSS/title block from leaking
4837
+ // into Studio's own standalone export wrapper.
4838
+ htmlTemplateDir = join(tmpdir(), `pi-studio-pandoc-html-${randomUUID()}`);
4839
+ await mkdir(htmlTemplateDir, { recursive: true });
4840
+ const htmlTemplatePath = join(htmlTemplateDir, "template.html");
4841
+ await writeFile(htmlTemplatePath, STUDIO_PANDOC_HTML_FRAGMENT_TEMPLATE, "utf-8");
4842
+ args.push("--embed-resources", "--standalone", `--template=${htmlTemplatePath}`);
4700
4843
  }
4701
4844
  const normalizedMarkdown = isLatex
4702
4845
  ? sourceWithResolvedRefs
4703
4846
  : normalizeStudioMarkdownFencedBlocks(prepareStudioMarkdownForPandoc(sourceWithResolvedRefs));
4704
4847
  const pandocWorkingDir = resolveStudioPandocWorkingDir(resourcePath);
4705
4848
 
4706
- let renderedHtml = await new Promise<string>((resolve, reject) => {
4707
- const child = spawn(pandocCommand, args, { stdio: ["pipe", "pipe", "pipe"], cwd: pandocWorkingDir });
4708
- const stdoutChunks: Buffer[] = [];
4709
- const stderrChunks: Buffer[] = [];
4710
- let settled = false;
4711
-
4712
- const fail = (error: Error) => {
4713
- if (settled) return;
4714
- settled = true;
4715
- reject(error);
4716
- };
4717
-
4718
- const succeed = (html: string) => {
4719
- if (settled) return;
4720
- settled = true;
4721
- resolve(html);
4722
- };
4723
-
4724
- child.stdout.on("data", (chunk: Buffer | string) => {
4725
- stdoutChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
4726
- });
4727
- child.stderr.on("data", (chunk: Buffer | string) => {
4728
- stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
4729
- });
4730
-
4731
- child.once("error", (error) => {
4732
- const errno = error as NodeJS.ErrnoException;
4733
- if (errno.code === "ENOENT") {
4734
- fail(new Error("pandoc was not found. Install pandoc or set PANDOC_PATH to the pandoc binary."));
4735
- return;
4736
- }
4737
- fail(error);
4738
- });
4739
-
4740
- child.once("close", (code) => {
4741
- if (settled) return;
4742
- if (code === 0) {
4743
- let html = Buffer.concat(stdoutChunks).toString("utf-8");
4744
- // When --standalone was used, extract only the <body> content
4745
- if (resourcePath) {
4746
- const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
4747
- if (bodyMatch) html = bodyMatch[1];
4748
- }
4749
- if (isLatex) {
4750
- html = decorateStudioLatexRenderedHtml(
4751
- html,
4752
- sourcePath,
4753
- resourcePath,
4754
- latexSubfigurePreviewTransform.subfigureGroups,
4755
- latexAlgorithmPreviewTransform.algorithmBlocks,
4756
- );
4757
- } else {
4758
- html = decorateStudioPreviewPageBreakHtml(html);
4759
- }
4760
- html = decorateStudioPandocSyntaxHtml(html);
4761
- succeed(stripMathMlAnnotationTags(html));
4762
- return;
4763
- }
4764
- const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
4765
- fail(new Error(`pandoc failed with exit code ${code}${stderr ? `: ${stderr}` : ""}`));
4849
+ let pandocResult: StudioSubprocessResult;
4850
+ try {
4851
+ pandocResult = await runStudioSubprocess(pandocCommand, args, {
4852
+ cwd: pandocWorkingDir,
4853
+ input: normalizedMarkdown,
4854
+ timeoutMs: STUDIO_PANDOC_TIMEOUT_MS,
4855
+ stdoutMaxBytes: STUDIO_HTML_RENDER_OUTPUT_MAX_BYTES,
4856
+ label: "pandoc HTML render",
4857
+ notFoundMessage: "pandoc was not found. Install pandoc or set PANDOC_PATH to the pandoc binary.",
4766
4858
  });
4859
+ } finally {
4860
+ if (htmlTemplateDir) {
4861
+ await rm(htmlTemplateDir, { recursive: true, force: true }).catch(() => undefined);
4862
+ }
4863
+ }
4864
+ if (pandocResult.code !== 0) {
4865
+ throw new Error(`pandoc failed with exit code ${pandocResult.code}${pandocResult.stderr ? `: ${pandocResult.stderr}` : ""}`);
4866
+ }
4867
+ if (pandocResult.stdoutTruncated) {
4868
+ throw new Error(`pandoc HTML output exceeded ${Math.round(STUDIO_HTML_RENDER_OUTPUT_MAX_BYTES / 1_000_000)} MB. Reduce embedded assets or set PI_STUDIO_HTML_RENDER_OUTPUT_MAX_BYTES higher.`);
4869
+ }
4767
4870
 
4768
- child.stdin.end(normalizedMarkdown);
4769
- });
4770
-
4771
- return renderedHtml;
4871
+ let renderedHtml = pandocResult.stdout;
4872
+ // When --standalone was used for --embed-resources, extract only the <body> content.
4873
+ if (resourcePath) {
4874
+ const bodyMatch = renderedHtml.match(/<body[^>]*>([\s\S]*)<\/body>/i);
4875
+ if (!bodyMatch) {
4876
+ throw new Error("pandoc HTML render did not include a complete body element.");
4877
+ }
4878
+ renderedHtml = bodyMatch[1];
4879
+ }
4880
+ if (isLatex) {
4881
+ renderedHtml = decorateStudioLatexRenderedHtml(
4882
+ renderedHtml,
4883
+ sourcePath,
4884
+ resourcePath,
4885
+ latexSubfigurePreviewTransform.subfigureGroups,
4886
+ latexAlgorithmPreviewTransform.algorithmBlocks,
4887
+ );
4888
+ } else {
4889
+ renderedHtml = decorateStudioPreviewPageBreakHtml(renderedHtml);
4890
+ }
4891
+ renderedHtml = decorateStudioPandocSyntaxHtml(renderedHtml);
4892
+ return stripMathMlAnnotationTags(renderedHtml);
4772
4893
  }
4773
4894
 
4774
4895
  function escapeStudioRegExpLiteral(text: string): string {
@@ -5160,54 +5281,22 @@ ${literalPdfConfig.fontSizeCommand}\\section*{${title.replace(/[{}\\]/g, "").tri
5160
5281
  await writeFile(texPath, texDocument, "utf-8");
5161
5282
 
5162
5283
  try {
5163
- await new Promise<void>((resolve, reject) => {
5164
- const child = spawn(pdfEngine, [
5165
- "-interaction=nonstopmode",
5166
- "-halt-on-error",
5167
- "input.tex",
5168
- ], { stdio: ["ignore", "pipe", "pipe"], cwd: tempDir });
5169
- const stdoutChunks: Buffer[] = [];
5170
- const stderrChunks: Buffer[] = [];
5171
- let settled = false;
5172
-
5173
- const fail = (error: Error) => {
5174
- if (settled) return;
5175
- settled = true;
5176
- reject(error);
5177
- };
5178
-
5179
- child.stdout.on("data", (chunk: Buffer | string) => {
5180
- stdoutChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
5181
- });
5182
- child.stderr.on("data", (chunk: Buffer | string) => {
5183
- stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
5184
- });
5185
-
5186
- child.once("error", (error) => {
5187
- const errno = error as NodeJS.ErrnoException;
5188
- if (errno.code === "ENOENT") {
5189
- fail(new Error(
5190
- `${pdfEngine} was not found. Install TeX Live (e.g. brew install --cask mactex) or set PANDOC_PDF_ENGINE.`,
5191
- ));
5192
- return;
5193
- }
5194
- fail(error);
5195
- });
5196
-
5197
- child.once("close", (code) => {
5198
- if (settled) return;
5199
- if (code === 0) {
5200
- settled = true;
5201
- resolve();
5202
- return;
5203
- }
5204
- const stdout = Buffer.concat(stdoutChunks).toString("utf-8");
5205
- const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
5206
- const errorMatch = stdout.match(/^! .+$/m);
5207
- const hint = errorMatch ? `: ${errorMatch[0]}` : (stderr ? `: ${stderr}` : "");
5208
- fail(new Error(`${pdfEngine} literal-text PDF export failed with exit code ${code}${hint}`));
5209
- });
5284
+ const latexResult = await runStudioSubprocess(pdfEngine, [
5285
+ "-interaction=nonstopmode",
5286
+ "-halt-on-error",
5287
+ "-file-line-error",
5288
+ "input.tex",
5289
+ ], {
5290
+ cwd: tempDir,
5291
+ timeoutMs: STUDIO_LATEX_TIMEOUT_MS,
5292
+ label: `${pdfEngine} literal-text PDF export`,
5293
+ notFoundMessage: `${pdfEngine} was not found. Install TeX Live (e.g. brew install --cask mactex) or set PANDOC_PDF_ENGINE.`,
5210
5294
  });
5295
+ if (latexResult.code !== 0) {
5296
+ const errorMatch = latexResult.stdout.match(/^! .+$/m);
5297
+ const hint = errorMatch ? `: ${errorMatch[0]}` : (latexResult.stderr ? `: ${latexResult.stderr}` : "");
5298
+ throw new Error(`${pdfEngine} literal-text PDF export failed with exit code ${latexResult.code}${hint}`);
5299
+ }
5211
5300
 
5212
5301
  return await readFile(outputPath);
5213
5302
  } finally {
@@ -5469,46 +5558,18 @@ async function renderStudioPdfFromGeneratedLatex(
5469
5558
  const pandocSource = inputFormat === "latex" ? markdown : normalizeStudioMarkdownFencedBlocks(markdown);
5470
5559
 
5471
5560
  try {
5472
- await new Promise<void>((resolve, reject) => {
5473
- const child = spawn(pandocCommand, pandocArgs, { stdio: ["pipe", "pipe", "pipe"], cwd: pandocWorkingDir });
5474
- const stderrChunks: Buffer[] = [];
5475
- let settled = false;
5476
-
5477
- const fail = (error: Error) => {
5478
- if (settled) return;
5479
- settled = true;
5480
- reject(error);
5481
- };
5482
-
5483
- child.stderr.on("data", (chunk: Buffer | string) => {
5484
- stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
5485
- });
5486
-
5487
- child.once("error", (error) => {
5488
- const errno = error as NodeJS.ErrnoException;
5489
- if (errno.code === "ENOENT") {
5490
- const commandHint = pandocCommand === "pandoc"
5491
- ? "pandoc was not found. Install pandoc or set PANDOC_PATH to the pandoc binary."
5492
- : `${pandocCommand} was not found. Check PANDOC_PATH.`;
5493
- fail(new Error(commandHint));
5494
- return;
5495
- }
5496
- fail(error);
5497
- });
5498
-
5499
- child.once("close", (code) => {
5500
- if (settled) return;
5501
- if (code === 0) {
5502
- settled = true;
5503
- resolve();
5504
- return;
5505
- }
5506
- const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
5507
- fail(new Error(`pandoc LaTeX generation failed with exit code ${code}${stderr ? `: ${stderr}` : ""}`));
5508
- });
5509
-
5510
- child.stdin.end(pandocSource);
5561
+ const pandocResult = await runStudioSubprocess(pandocCommand, pandocArgs, {
5562
+ cwd: pandocWorkingDir,
5563
+ input: pandocSource,
5564
+ timeoutMs: STUDIO_PANDOC_TIMEOUT_MS,
5565
+ label: "pandoc LaTeX generation",
5566
+ notFoundMessage: pandocCommand === "pandoc"
5567
+ ? "pandoc was not found. Install pandoc or set PANDOC_PATH to the pandoc binary."
5568
+ : `${pandocCommand} was not found. Check PANDOC_PATH.`,
5511
5569
  });
5570
+ if (pandocResult.code !== 0) {
5571
+ throw new Error(`pandoc LaTeX generation failed with exit code ${pandocResult.code}${pandocResult.stderr ? `: ${pandocResult.stderr}` : ""}`);
5572
+ }
5512
5573
 
5513
5574
  const generatedLatex = await readFile(latexPath, "utf-8");
5514
5575
  const injectedLatex = injectStudioLatexPdfSubfigureBlocks(generatedLatex, subfigureGroups, sourcePath, resourcePath);
@@ -5519,55 +5580,23 @@ async function renderStudioPdfFromGeneratedLatex(
5519
5580
  const normalizedLatex = normalizeStudioGeneratedFigureCaptions(alignedReadyLatex);
5520
5581
  await writeFile(latexPath, normalizedLatex, "utf-8");
5521
5582
 
5522
- await new Promise<void>((resolve, reject) => {
5523
- const child = spawn(pdfEngine, [
5524
- "-interaction=nonstopmode",
5525
- "-halt-on-error",
5526
- `-output-directory=${tempDir}`,
5527
- latexPath,
5528
- ], { stdio: ["ignore", "pipe", "pipe"], cwd: pandocWorkingDir });
5529
- const stdoutChunks: Buffer[] = [];
5530
- const stderrChunks: Buffer[] = [];
5531
- let settled = false;
5532
-
5533
- const fail = (error: Error) => {
5534
- if (settled) return;
5535
- settled = true;
5536
- reject(error);
5537
- };
5538
-
5539
- child.stdout.on("data", (chunk: Buffer | string) => {
5540
- stdoutChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
5541
- });
5542
- child.stderr.on("data", (chunk: Buffer | string) => {
5543
- stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
5544
- });
5545
-
5546
- child.once("error", (error) => {
5547
- const errno = error as NodeJS.ErrnoException;
5548
- if (errno.code === "ENOENT") {
5549
- fail(new Error(
5550
- `${pdfEngine} was not found. Install TeX Live (e.g. brew install --cask mactex) or set PANDOC_PDF_ENGINE.`,
5551
- ));
5552
- return;
5553
- }
5554
- fail(error);
5555
- });
5556
-
5557
- child.once("close", (code) => {
5558
- if (settled) return;
5559
- if (code === 0) {
5560
- settled = true;
5561
- resolve();
5562
- return;
5563
- }
5564
- const stdout = Buffer.concat(stdoutChunks).toString("utf-8");
5565
- const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
5566
- const errorMatch = stdout.match(/^! .+$/m);
5567
- const hint = errorMatch ? `: ${errorMatch[0]}` : (stderr ? `: ${stderr}` : "");
5568
- fail(new Error(`${pdfEngine} PDF export failed with exit code ${code}${hint}`));
5569
- });
5583
+ const latexResult = await runStudioSubprocess(pdfEngine, [
5584
+ "-interaction=nonstopmode",
5585
+ "-halt-on-error",
5586
+ "-file-line-error",
5587
+ `-output-directory=${tempDir}`,
5588
+ latexPath,
5589
+ ], {
5590
+ cwd: pandocWorkingDir,
5591
+ timeoutMs: STUDIO_LATEX_TIMEOUT_MS,
5592
+ label: `${pdfEngine} PDF export`,
5593
+ notFoundMessage: `${pdfEngine} was not found. Install TeX Live (e.g. brew install --cask mactex) or set PANDOC_PDF_ENGINE.`,
5570
5594
  });
5595
+ if (latexResult.code !== 0) {
5596
+ const errorMatch = latexResult.stdout.match(/^! .+$/m);
5597
+ const hint = errorMatch ? `: ${errorMatch[0]}` : (latexResult.stderr ? `: ${latexResult.stderr}` : "");
5598
+ throw new Error(`${pdfEngine} PDF export failed with exit code ${latexResult.code}${hint}`);
5599
+ }
5571
5600
 
5572
5601
  return { pdf: await readFile(outputPath) };
5573
5602
  } finally {
@@ -5627,6 +5656,7 @@ async function renderStudioPdfWithPandoc(
5627
5656
  "-f", inputFormat,
5628
5657
  "-o", outputPath,
5629
5658
  `--pdf-engine=${pdfEngine}`,
5659
+ ...buildStudioPandocPdfEngineOptArgs(pdfEngine),
5630
5660
  ...buildStudioPdfPandocVariableArgs(pdfOptions, inputFormat !== "latex"),
5631
5661
  "-V", "urlcolor=blue",
5632
5662
  "-V", "linkcolor=blue",
@@ -5636,49 +5666,22 @@ async function renderStudioPdfWithPandoc(
5636
5666
  if (resourcePath) args.push(`--resource-path=${resourcePath}`);
5637
5667
 
5638
5668
  try {
5639
- await new Promise<void>((resolve, reject) => {
5640
- const child = spawn(pandocCommand, args, { stdio: ["pipe", "pipe", "pipe"], cwd: pandocWorkingDir });
5641
- const stderrChunks: Buffer[] = [];
5642
- let settled = false;
5643
-
5644
- const fail = (error: Error) => {
5645
- if (settled) return;
5646
- settled = true;
5647
- reject(error);
5648
- };
5649
-
5650
- child.stderr.on("data", (chunk: Buffer | string) => {
5651
- stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
5652
- });
5653
-
5654
- child.once("error", (error) => {
5655
- const errno = error as NodeJS.ErrnoException;
5656
- if (errno.code === "ENOENT") {
5657
- const commandHint = pandocCommand === "pandoc"
5658
- ? "pandoc was not found. Install pandoc or set PANDOC_PATH to the pandoc binary."
5659
- : `${pandocCommand} was not found. Check PANDOC_PATH.`;
5660
- fail(new Error(commandHint));
5661
- return;
5662
- }
5663
- fail(error);
5664
- });
5665
-
5666
- child.once("close", (code) => {
5667
- if (settled) return;
5668
- if (code === 0) {
5669
- settled = true;
5670
- resolve();
5671
- return;
5672
- }
5673
- const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
5674
- const hint = stderr.includes("not found") || stderr.includes("xelatex") || stderr.includes("pdflatex")
5675
- ? "\nPDF export requires a LaTeX engine. Install TeX Live (e.g. brew install --cask mactex) or set PANDOC_PDF_ENGINE."
5676
- : "";
5677
- fail(new Error(`pandoc PDF export failed with exit code ${code}${stderr ? `: ${stderr}` : ""}${hint}`));
5678
- });
5679
-
5680
- child.stdin.end(pandocSource);
5669
+ const pandocResult = await runStudioSubprocess(pandocCommand, args, {
5670
+ cwd: pandocWorkingDir,
5671
+ input: pandocSource,
5672
+ timeoutMs: STUDIO_PANDOC_TIMEOUT_MS,
5673
+ label: "pandoc PDF export",
5674
+ notFoundMessage: pandocCommand === "pandoc"
5675
+ ? "pandoc was not found. Install pandoc or set PANDOC_PATH to the pandoc binary."
5676
+ : `${pandocCommand} was not found. Check PANDOC_PATH.`,
5681
5677
  });
5678
+ if (pandocResult.code !== 0) {
5679
+ const stderr = pandocResult.stderr;
5680
+ const hint = stderr.includes("not found") || stderr.includes("xelatex") || stderr.includes("pdflatex")
5681
+ ? "\nPDF export requires a LaTeX engine. Install TeX Live (e.g. brew install --cask mactex) or set PANDOC_PDF_ENGINE."
5682
+ : "";
5683
+ throw new Error(`pandoc PDF export failed with exit code ${pandocResult.code}${stderr ? `: ${stderr}` : ""}${hint}`);
5684
+ }
5682
5685
 
5683
5686
  return { pdf: await readFile(outputPath), warning };
5684
5687
  } finally {
@@ -5780,6 +5783,7 @@ async function renderStudioPdfWithPandoc(
5780
5783
  "-f", inputFormat,
5781
5784
  "-o", outputPath,
5782
5785
  `--pdf-engine=${pdfEngine}`,
5786
+ ...buildStudioPandocPdfEngineOptArgs(pdfEngine),
5783
5787
  ...buildStudioPdfPandocVariableArgs(pdfOptions, !isLatex),
5784
5788
  "-V", "urlcolor=blue",
5785
5789
  "-V", "linkcolor=blue",
@@ -5790,49 +5794,22 @@ async function renderStudioPdfWithPandoc(
5790
5794
  const pandocSource = isLatex ? markdownForPdf : normalizeStudioMarkdownFencedBlocks(markdownForPdf);
5791
5795
 
5792
5796
  try {
5793
- await new Promise<void>((resolve, reject) => {
5794
- const child = spawn(pandocCommand, args, { stdio: ["pipe", "pipe", "pipe"], cwd: pandocWorkingDir });
5795
- const stderrChunks: Buffer[] = [];
5796
- let settled = false;
5797
-
5798
- const fail = (error: Error) => {
5799
- if (settled) return;
5800
- settled = true;
5801
- reject(error);
5802
- };
5803
-
5804
- child.stderr.on("data", (chunk: Buffer | string) => {
5805
- stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
5806
- });
5807
-
5808
- child.once("error", (error) => {
5809
- const errno = error as NodeJS.ErrnoException;
5810
- if (errno.code === "ENOENT") {
5811
- const commandHint = pandocCommand === "pandoc"
5812
- ? "pandoc was not found. Install pandoc or set PANDOC_PATH to the pandoc binary."
5813
- : `${pandocCommand} was not found. Check PANDOC_PATH.`;
5814
- fail(new Error(commandHint));
5815
- return;
5816
- }
5817
- fail(error);
5818
- });
5819
-
5820
- child.once("close", (code) => {
5821
- if (settled) return;
5822
- if (code === 0) {
5823
- settled = true;
5824
- resolve();
5825
- return;
5826
- }
5827
- const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
5828
- const hint = stderr.includes("not found") || stderr.includes("xelatex") || stderr.includes("pdflatex")
5829
- ? "\nPDF export requires a LaTeX engine. Install TeX Live (e.g. brew install --cask mactex) or set PANDOC_PDF_ENGINE."
5830
- : "";
5831
- fail(new Error(`pandoc PDF export failed with exit code ${code}${stderr ? `: ${stderr}` : ""}${hint}`));
5832
- });
5833
-
5834
- child.stdin.end(pandocSource);
5797
+ const pandocResult = await runStudioSubprocess(pandocCommand, args, {
5798
+ cwd: pandocWorkingDir,
5799
+ input: pandocSource,
5800
+ timeoutMs: STUDIO_PANDOC_TIMEOUT_MS,
5801
+ label: "pandoc PDF export",
5802
+ notFoundMessage: pandocCommand === "pandoc"
5803
+ ? "pandoc was not found. Install pandoc or set PANDOC_PATH to the pandoc binary."
5804
+ : `${pandocCommand} was not found. Check PANDOC_PATH.`,
5835
5805
  });
5806
+ if (pandocResult.code !== 0) {
5807
+ const stderr = pandocResult.stderr;
5808
+ const hint = stderr.includes("not found") || stderr.includes("xelatex") || stderr.includes("pdflatex")
5809
+ ? "\nPDF export requires a LaTeX engine. Install TeX Live (e.g. brew install --cask mactex) or set PANDOC_PDF_ENGINE."
5810
+ : "";
5811
+ throw new Error(`pandoc PDF export failed with exit code ${pandocResult.code}${stderr ? `: ${stderr}` : ""}${hint}`);
5812
+ }
5836
5813
 
5837
5814
  return { pdf: await readFile(outputPath), warning: mermaidPrepared.warning };
5838
5815
  } finally {
@@ -7031,7 +7008,7 @@ function summarizeStudioTraceToolArgs(toolName: string, args: unknown): string |
7031
7008
  if (normalizedTool === "read" || normalizedTool === "write" || normalizedTool === "edit") {
7032
7009
  return trimSummary(typeof payload.path === "string" ? payload.path : "");
7033
7010
  }
7034
- if (normalizedTool === "repl_send") {
7011
+ if (normalizedTool === "repl_send" || normalizedTool === "studio_repl_send") {
7035
7012
  return trimSummary(typeof payload.code === "string" ? payload.code : "");
7036
7013
  }
7037
7014
  try {
@@ -7158,6 +7135,23 @@ function listStudioReplSessions(): { tmuxAvailable: boolean; sessions: StudioRep
7158
7135
  return { tmuxAvailable: true, sessions };
7159
7136
  }
7160
7137
 
7138
+ function getStudioReplPromptPrefix(line: string): string {
7139
+ const source = String(line || "");
7140
+ const match = source.match(/^(\s*(?:(?:In \[\d+\]:)|(?:\.\.\.)|(?:>>>)|(?:julia>)|(?:ghci>)|(?:Prelude>)|(?:\*?[A-Za-z0-9_.:]+>)|(?:[^\s>]+=>)|(?:>)|(?:\+))\s*)/);
7141
+ return match ? (match[1] || "") : "";
7142
+ }
7143
+
7144
+ function sanitizeStudioReplTranscript(transcript: string): string {
7145
+ let value = String(transcript || "");
7146
+ for (const [sourceFile, label] of studioReplControlSubmissionLabels) {
7147
+ if (!value.includes(sourceFile)) continue;
7148
+ const escaped = sourceFile.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7149
+ const linePattern = new RegExp(`^.*${escaped}.*$`, "gm");
7150
+ value = value.replace(linePattern, (line) => `${getStudioReplPromptPrefix(line)}${label}`.trimEnd());
7151
+ }
7152
+ return value.replace(/[\t ]+$/gm, "").trimEnd();
7153
+ }
7154
+
7161
7155
  function captureStudioReplSession(sessionName: string): { ok: true; transcript: string; session: StudioReplSessionInfo } | { ok: false; message: string } {
7162
7156
  if (!/^[-_.A-Za-z0-9]+$/.test(sessionName)) return { ok: false, message: "Invalid REPL session name." };
7163
7157
  const inferred = inferStudioReplSessionRuntime(sessionName);
@@ -7168,9 +7162,9 @@ function captureStudioReplSession(sessionName: string): { ok: true; transcript:
7168
7162
  label: formatStudioReplSessionLabel(sessionName, inferred.runtime, inferred.source),
7169
7163
  source: inferred.source,
7170
7164
  };
7171
- const result = runStudioTmux(["capture-pane", "-p", "-t", session.target, "-S", `-${STUDIO_REPL_CAPTURE_LINES}`], { timeout: 3_000 });
7165
+ const result = runStudioTmux(["capture-pane", "-J", "-p", "-t", session.target, "-S", `-${STUDIO_REPL_CAPTURE_LINES}`], { timeout: 3_000 });
7172
7166
  if (!result.ok) return { ok: false, message: result.message };
7173
- return { ok: true, transcript: result.stdout.replace(/[\t ]+$/gm, "").trimEnd(), session };
7167
+ return { ok: true, transcript: sanitizeStudioReplTranscript(result.stdout), session };
7174
7168
  }
7175
7169
 
7176
7170
  function startStudioReplSession(runtime: StudioReplRuntime, cwd: string, options?: { newSession?: boolean }): { ok: true; session: StudioReplSessionInfo; message: string } | { ok: false; message: string } {
@@ -7218,32 +7212,298 @@ function stopStudioReplSession(sessionName: string): { ok: true; message: string
7218
7212
  return { ok: true, message: `Stopped ${sessionName}.` };
7219
7213
  }
7220
7214
 
7221
- function prepareTextForStudioReplSend(sessionName: string, source: string): string {
7222
- const normalized = String(source || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
7215
+ type StudioReplControlFiles = {
7216
+ dir: string;
7217
+ sourceFile: string;
7218
+ doneFile: string;
7219
+ };
7220
+
7221
+ type StudioReplPreparedSubmission = {
7222
+ runtime: StudioReplRuntime | "unknown";
7223
+ usedControlFile: boolean;
7224
+ submissionText: string;
7225
+ controlFiles?: StudioReplControlFiles;
7226
+ };
7227
+
7228
+ type StudioReplSendSuccess = {
7229
+ ok: true;
7230
+ message: string;
7231
+ runtime: StudioReplRuntime | "unknown";
7232
+ usedControlFile: boolean;
7233
+ submissionText: string;
7234
+ controlFiles?: StudioReplControlFiles;
7235
+ };
7236
+
7237
+ type StudioReplSendFailure = { ok: false; message: string };
7238
+
7239
+ function sleep(ms: number): Promise<void> {
7240
+ return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
7241
+ }
7242
+
7243
+ function clampStudioReplSendTimeout(timeoutMs: number | undefined): number {
7244
+ if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs)) return STUDIO_REPL_SEND_DEFAULT_TIMEOUT_MS;
7245
+ return Math.max(1_000, Math.min(STUDIO_REPL_SEND_MAX_TIMEOUT_MS, Math.round(timeoutMs)));
7246
+ }
7247
+
7248
+ function shellQuote(value: string): string {
7249
+ return `'${String(value || "").replace(/'/g, `'"'"'`)}'`;
7250
+ }
7251
+
7252
+ function getStudioReplControlFiles(sessionName: string, runtime: StudioReplRuntime | "unknown"): StudioReplControlFiles {
7253
+ const safeSession = sessionName.replace(/[^-_.A-Za-z0-9]+/g, "_");
7254
+ const safeRuntime = String(runtime || "repl").replace(/[^-_.A-Za-z0-9]+/g, "_");
7255
+ const dir = join(STUDIO_REPL_CONTROL_ROOT, safeSession, randomUUID().replace(/-/g, ""));
7256
+ const extension = runtime === "julia"
7257
+ ? "jl"
7258
+ : runtime === "r"
7259
+ ? "R"
7260
+ : runtime === "ghci"
7261
+ ? "ghci"
7262
+ : runtime === "clojure"
7263
+ ? "clj"
7264
+ : "py";
7265
+ return {
7266
+ dir,
7267
+ sourceFile: join(dir, `studio-repl-${safeRuntime}.${extension}`),
7268
+ doneFile: join(dir, "done.flag"),
7269
+ };
7270
+ }
7271
+
7272
+ function buildStudioPythonControlSource(runtime: "python" | "ipython", code: string, doneFile: string): string {
7273
+ if (runtime === "ipython") {
7274
+ return [
7275
+ "from pathlib import Path as __pi_studio_path",
7276
+ "import traceback as __pi_studio_traceback",
7277
+ "try:",
7278
+ " __pi_studio_ip = get_ipython()",
7279
+ " if __pi_studio_ip is None:",
7280
+ " raise RuntimeError('Expected IPython session, but get_ipython() returned None.')",
7281
+ ` __pi_studio_result = __pi_studio_ip.run_cell(${JSON.stringify(code)}, store_history=False)`,
7282
+ " if getattr(__pi_studio_result, 'error_in_exec', None) is None and getattr(__pi_studio_result, 'result', None) is not None:",
7283
+ " print(repr(__pi_studio_result.result))",
7284
+ "except Exception:",
7285
+ " __pi_studio_traceback.print_exc()",
7286
+ "finally:",
7287
+ ` __pi_studio_path(${JSON.stringify(doneFile)}).write_text('done\\n', encoding='utf-8')`,
7288
+ ].join("\n");
7289
+ }
7290
+
7291
+ return [
7292
+ "from pathlib import Path as __pi_studio_path",
7293
+ "import traceback as __pi_studio_traceback",
7294
+ `__pi_studio_code = ${JSON.stringify(code)}`,
7295
+ "try:",
7296
+ " try:",
7297
+ " __pi_studio_expr = compile(__pi_studio_code, '<pi-studio-repl>', 'eval')",
7298
+ " except SyntaxError:",
7299
+ " exec(compile(__pi_studio_code, '<pi-studio-repl>', 'exec'), globals())",
7300
+ " else:",
7301
+ " __pi_studio_value = eval(__pi_studio_expr, globals())",
7302
+ " if __pi_studio_value is not None:",
7303
+ " print(repr(__pi_studio_value))",
7304
+ "except Exception:",
7305
+ " __pi_studio_traceback.print_exc()",
7306
+ "finally:",
7307
+ ` __pi_studio_path(${JSON.stringify(doneFile)}).write_text('done\\n', encoding='utf-8')`,
7308
+ ].join("\n");
7309
+ }
7310
+
7311
+ function buildStudioJuliaControlSource(code: string, doneFile: string): string {
7312
+ return [
7313
+ "try",
7314
+ ` local __pi_studio_result = Base.include_string(Main, ${JSON.stringify(code)}, "pi-studio-repl")`,
7315
+ " if !isnothing(__pi_studio_result)",
7316
+ " println(repr(__pi_studio_result))",
7317
+ " end",
7318
+ "catch e",
7319
+ " Base.display_error(stderr, e, catch_backtrace())",
7320
+ "finally",
7321
+ ` write(${JSON.stringify(doneFile)}, "done\\n")`,
7322
+ "end",
7323
+ ].join("\n");
7324
+ }
7325
+
7326
+ function buildStudioRControlSource(code: string, doneFile: string): string {
7327
+ return [
7328
+ "local({",
7329
+ ` .__pi_studio_done_file <- ${JSON.stringify(doneFile)}`,
7330
+ ` .__pi_studio_code <- ${JSON.stringify(code)}`,
7331
+ " tryCatch({",
7332
+ " .__pi_studio_exprs <- parse(text = .__pi_studio_code, keep.source = FALSE)",
7333
+ " .__pi_studio_value <- NULL",
7334
+ " .__pi_studio_visible <- FALSE",
7335
+ " for (.__pi_studio_expr in .__pi_studio_exprs) {",
7336
+ " .__pi_studio_result <- withVisible(eval(.__pi_studio_expr, envir = .GlobalEnv))",
7337
+ " .__pi_studio_value <- .__pi_studio_result$value",
7338
+ " .__pi_studio_visible <- isTRUE(.__pi_studio_result$visible)",
7339
+ " }",
7340
+ " if (.__pi_studio_visible) print(.__pi_studio_value)",
7341
+ " }, error = function(e) {",
7342
+ " .__pi_studio_call <- conditionCall(e)",
7343
+ " if (is.null(.__pi_studio_call)) {",
7344
+ " message(\"Error: \", conditionMessage(e))",
7345
+ " } else {",
7346
+ " message(\"Error in \", paste(deparse(.__pi_studio_call), collapse = \" \"), \": \", conditionMessage(e))",
7347
+ " }",
7348
+ " }, finally = {",
7349
+ " writeLines(\"done\", .__pi_studio_done_file)",
7350
+ " })",
7351
+ "})",
7352
+ ].join("\n");
7353
+ }
7354
+
7355
+ function buildStudioClojureControlSource(code: string, doneFile: string): string {
7356
+ return [
7357
+ "(let [code " + JSON.stringify(code) + "]",
7358
+ " (try",
7359
+ " (let [rdr (clojure.lang.LineNumberingPushbackReader. (java.io.StringReader. code))]",
7360
+ " (loop [last-val nil has-val false]",
7361
+ " (let [form (read rdr false :pi-studio/eof)]",
7362
+ " (if (= form :pi-studio/eof)",
7363
+ " (when (and has-val (some? last-val)) (prn last-val))",
7364
+ " (recur (eval form) true)))))",
7365
+ " (catch Throwable t",
7366
+ " (#'clojure.main/repl-caught t))",
7367
+ " (finally",
7368
+ ` (spit ${JSON.stringify(doneFile)} "done\\n"))))`,
7369
+ ].join("\n");
7370
+ }
7371
+
7372
+ function buildStudioReplControlSource(runtime: StudioReplRuntime, code: string, doneFile: string): string | null {
7373
+ if (runtime === "python" || runtime === "ipython") return buildStudioPythonControlSource(runtime, code, doneFile);
7374
+ if (runtime === "julia") return buildStudioJuliaControlSource(code, doneFile);
7375
+ if (runtime === "r") return buildStudioRControlSource(code, doneFile);
7376
+ if (runtime === "ghci") return `${code.replace(/\r/g, "").trimEnd()}\n:! touch ${shellQuote(doneFile)}\n`;
7377
+ if (runtime === "clojure") return buildStudioClojureControlSource(code, doneFile);
7378
+ return null;
7379
+ }
7380
+
7381
+ function buildStudioReplSubmissionLine(runtime: StudioReplRuntime, sourceFile: string): string {
7382
+ const quotedPath = JSON.stringify(sourceFile);
7383
+ if (runtime === "julia") return `include(${quotedPath})`;
7384
+ if (runtime === "r") return `source(${quotedPath}, local=.GlobalEnv)`;
7385
+ if (runtime === "ghci") return `:script ${quotedPath}`;
7386
+ if (runtime === "clojure") return `(do (load-file ${quotedPath}) :pi-studio/silent)`;
7387
+ return `exec(open(${quotedPath}, encoding="utf-8").read(), globals())`;
7388
+ }
7389
+
7390
+ function buildStudioReplPreviewComment(runtime: StudioReplRuntime, code: string): string | undefined {
7391
+ const normalized = code.replace(/\r/g, "").trimEnd();
7392
+ const lineCount = normalized ? normalized.split("\n").length : 0;
7393
+ if (lineCount <= 1) return undefined;
7394
+ const prefix = runtime === "ghci" ? "--" : runtime === "clojure" ? ";;" : "#";
7395
+ return `${prefix} Studio sent ${lineCount}-line snippet`;
7396
+ }
7397
+
7398
+ function buildStudioReplDisplayLabel(runtime: StudioReplRuntime, code: string): string {
7399
+ const normalized = code.replace(/\r/g, "").trim();
7400
+ const singleLine = normalized && !normalized.includes("\n") ? normalized.replace(/\s+/g, " ") : "";
7401
+ if (singleLine && singleLine.length <= 140) return singleLine;
7402
+ return buildStudioReplPreviewComment(runtime, code) || "# Studio sent code";
7403
+ }
7404
+
7405
+ function rememberStudioReplControlSubmission(sourceFile: string, label: string): void {
7406
+ studioReplControlSubmissionLabels.set(sourceFile, label);
7407
+ while (studioReplControlSubmissionLabels.size > 300) {
7408
+ const oldest = studioReplControlSubmissionLabels.keys().next().value;
7409
+ if (!oldest) break;
7410
+ studioReplControlSubmissionLabels.delete(oldest);
7411
+ }
7412
+ }
7413
+
7414
+ function prepareStudioReplSubmission(sessionName: string, source: string): StudioReplPreparedSubmission {
7415
+ const normalizedSource = String(source || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
7223
7416
  const runtime = inferStudioReplSessionRuntime(sessionName).runtime;
7224
- if ((runtime === "python" || runtime === "ipython") && normalized.includes("\n")) {
7225
- // The standard Python prompt needs a final blank line to close pasted suites
7226
- // such as `for`/`if`/`def` blocks. Without it the prompt can remain in
7227
- // continuation mode, making the next send look like an unexpected indent.
7228
- return `${normalized.replace(/\n+$/, "")}\n\n`;
7417
+ if (runtime !== "unknown" && runtime !== "shell") {
7418
+ const controlFiles = getStudioReplControlFiles(sessionName, runtime);
7419
+ const controlSource = buildStudioReplControlSource(runtime, normalizedSource, controlFiles.doneFile);
7420
+ if (controlSource) {
7421
+ mkdirSync(controlFiles.dir, { recursive: true });
7422
+ try {
7423
+ unlinkSync(controlFiles.doneFile);
7424
+ } catch {
7425
+ // Ignore stale done file cleanup failures.
7426
+ }
7427
+ writeFileSync(controlFiles.sourceFile, controlSource, "utf-8");
7428
+ const submissionLine = buildStudioReplSubmissionLine(runtime, controlFiles.sourceFile);
7429
+ rememberStudioReplControlSubmission(controlFiles.sourceFile, buildStudioReplDisplayLabel(runtime, normalizedSource));
7430
+ return {
7431
+ runtime,
7432
+ usedControlFile: true,
7433
+ controlFiles,
7434
+ submissionText: submissionLine,
7435
+ };
7436
+ }
7229
7437
  }
7230
- return normalized.endsWith("\n") ? normalized : `${normalized}\n`;
7438
+
7439
+ return {
7440
+ runtime,
7441
+ usedControlFile: false,
7442
+ submissionText: normalizedSource.replace(/\n+$/, ""),
7443
+ };
7231
7444
  }
7232
7445
 
7233
- function sendTextToStudioReplSession(sessionName: string, text: string): { ok: true; message: string } | { ok: false; message: string } {
7446
+ function pasteTextToStudioReplPane(sessionName: string, text: string): { ok: true } | { ok: false; message: string } {
7447
+ const bufferName = `pi-studio-repl-${randomUUID().replace(/-/g, "")}`;
7448
+ const target = getStudioReplPaneTarget(sessionName);
7449
+ const loadResult = runStudioTmux(["load-buffer", "-b", bufferName, "-"], { input: text, timeout: 5_000 });
7450
+ if (!loadResult.ok) return { ok: false, message: loadResult.message || "Failed to load text into tmux buffer." };
7451
+ try {
7452
+ const pasteResult = runStudioTmux(["paste-buffer", "-d", "-b", bufferName, "-t", target], { timeout: 5_000 });
7453
+ if (!pasteResult.ok) return { ok: false, message: pasteResult.message || "Failed to paste text into REPL session." };
7454
+ const enterResult = runStudioTmux(["send-keys", "-t", target, "C-m"], { timeout: 5_000 });
7455
+ if (!enterResult.ok) return { ok: false, message: enterResult.message || "Failed to send Enter to REPL session." };
7456
+ return { ok: true };
7457
+ } finally {
7458
+ runStudioTmux(["delete-buffer", "-b", bufferName], { timeout: 2_000 });
7459
+ }
7460
+ }
7461
+
7462
+ function sendTextToStudioReplSession(sessionName: string, text: string): StudioReplSendSuccess | StudioReplSendFailure {
7234
7463
  if (!/^[-_.A-Za-z0-9]+$/.test(sessionName)) return { ok: false, message: "Invalid REPL session name." };
7235
7464
  const source = String(text || "");
7236
7465
  if (!source.trim()) return { ok: false, message: "Editor text is empty." };
7237
7466
  if (source.length > STUDIO_REPL_SEND_MAX_CHARS) {
7238
7467
  return { ok: false, message: `REPL input is too large (${source.length} chars; max ${STUDIO_REPL_SEND_MAX_CHARS}).` };
7239
7468
  }
7240
- const bufferName = `pi-studio-repl-${randomUUID().replace(/-/g, "")}`;
7241
- const input = prepareTextForStudioReplSend(sessionName, source);
7242
- const loadResult = runStudioTmux(["load-buffer", "-b", bufferName, "-"], { input, timeout: 5_000 });
7243
- if (!loadResult.ok) return { ok: false, message: loadResult.message || "Failed to load text into tmux buffer." };
7244
- const pasteResult = runStudioTmux(["paste-buffer", "-d", "-b", bufferName, "-t", getStudioReplPaneTarget(sessionName)], { timeout: 5_000 });
7245
- if (!pasteResult.ok) return { ok: false, message: pasteResult.message || "Failed to paste text into REPL session." };
7246
- return { ok: true, message: `Sent ${source.length} chars to ${sessionName}.` };
7469
+ const prepared = prepareStudioReplSubmission(sessionName, source);
7470
+ const pasted = pasteTextToStudioReplPane(sessionName, prepared.submissionText);
7471
+ if (!pasted.ok) return { ok: false, message: pasted.message };
7472
+ return {
7473
+ ok: true,
7474
+ message: "Sent to REPL.",
7475
+ runtime: prepared.runtime,
7476
+ usedControlFile: prepared.usedControlFile,
7477
+ submissionText: prepared.submissionText,
7478
+ controlFiles: prepared.controlFiles,
7479
+ };
7480
+ }
7481
+
7482
+ function extractStudioReplTranscriptDelta(before: string, after: string): string {
7483
+ const previous = String(before || "");
7484
+ const current = String(after || "");
7485
+ if (!current) return "";
7486
+ if (!previous) return current.trim();
7487
+ const directIndex = current.indexOf(previous);
7488
+ if (directIndex >= 0) return current.slice(directIndex + previous.length).trim();
7489
+ const previousLines = previous.split("\n");
7490
+ for (let count = Math.min(previousLines.length, 40); count >= 1; count -= 1) {
7491
+ const suffix = previousLines.slice(previousLines.length - count).join("\n");
7492
+ if (!suffix.trim()) continue;
7493
+ const suffixIndex = current.indexOf(suffix);
7494
+ if (suffixIndex >= 0) return current.slice(suffixIndex + suffix.length).trim();
7495
+ }
7496
+ return current.trim();
7497
+ }
7498
+
7499
+ async function waitForStudioReplDoneFile(doneFile: string | undefined, timeoutMs: number): Promise<boolean> {
7500
+ if (!doneFile) return false;
7501
+ const deadline = Date.now() + clampStudioReplSendTimeout(timeoutMs);
7502
+ while (Date.now() < deadline) {
7503
+ if (existsSync(doneFile)) return true;
7504
+ await sleep(100);
7505
+ }
7506
+ return existsSync(doneFile);
7247
7507
  }
7248
7508
 
7249
7509
  function interruptStudioReplSession(sessionName: string): { ok: true; message: string } | { ok: false; message: string } {
@@ -7708,6 +7968,7 @@ ${cssVarsBlock}
7708
7968
  <label class="file-label" title="Load a local file into editor text.">Load file content<input id="fileInput" type="file" accept=".md,.markdown,.mdx,.qmd,.js,.mjs,.cjs,.jsx,.ts,.mts,.cts,.tsx,.py,.pyw,.sh,.bash,.zsh,.json,.jsonc,.json5,.rs,.c,.h,.cpp,.cxx,.cc,.hpp,.hxx,.jl,.f90,.f95,.f03,.f,.for,.r,.R,.m,.tex,.latex,.diff,.patch,.java,.go,.rb,.swift,.html,.htm,.css,.xml,.yaml,.yml,.toml,.lua,.txt,.rst,.adoc" /></label>
7709
7969
  <button id="loadGitDiffBtn" type="button" title="Load the current git diff from the Studio context into the editor.">Load git diff</button>
7710
7970
  <button id="getEditorBtn" type="button" title="Load the current terminal editor draft into Studio.">Load from pi editor</button>
7971
+ <button id="zenModeBtn" class="zen-mode-btn" type="button" title="Hide secondary Studio controls.">⊙ Zen</button>
7711
7972
  </div>
7712
7973
  </header>
7713
7974
 
@@ -7745,8 +8006,8 @@ ${cssVarsBlock}
7745
8006
  <button id="queueSteerBtn" type="button" title="Queue steering is available while Run editor text is active." disabled>Queue steering</button>
7746
8007
  <button id="sendReplBtn" type="button" hidden title="Send the current selection, or the full editor text, to the active REPL session shown in the right pane.">Send to REPL</button>
7747
8008
  <select id="replSendModeSelect" hidden aria-label="REPL send mode" title="Choose how Send to REPL interprets the editor text.">
7748
- <option value="scratch" selected>Scratch send</option>
7749
- <option value="literate">Literate send</option>
8009
+ <option value="raw" selected>Send mode: Raw</option>
8010
+ <option value="literate">Send mode: Literate</option>
7750
8011
  </select>
7751
8012
  <button id="copyDraftBtn" type="button" title="Copy the current editor text to the clipboard.">Copy text</button>
7752
8013
  <button id="openCompanionBtn" type="button" title="Open a detached copy of the current editor text in a new editor-only Studio tab.">Open new editor</button>
@@ -8032,6 +8293,164 @@ export default function (pi: ExtensionAPI) {
8032
8293
  let compactInProgress = false;
8033
8294
  let compactRequestId: string | null = null;
8034
8295
 
8296
+ const selectStudioReplSessionForTool = (params: { sessionName?: string; target?: string }): { session: StudioReplSessionInfo | null; error?: string; sessions: StudioReplSessionInfo[] } => {
8297
+ const state = listStudioReplSessions();
8298
+ const sessions = state.sessions;
8299
+ if (!state.tmuxAvailable) return { session: null, error: "tmux is not available.", sessions };
8300
+ if (typeof params.sessionName === "string" && params.sessionName.trim()) {
8301
+ const requested = params.sessionName.trim();
8302
+ const session = sessions.find((candidate) => candidate.sessionName === requested) ?? null;
8303
+ return session
8304
+ ? { session, sessions }
8305
+ : { session: null, error: `No Studio-visible REPL session named ${requested}.`, sessions };
8306
+ }
8307
+ const target = normalizeStudioReplRuntime(params.target);
8308
+ if (target) {
8309
+ const active = studioReplActiveSessionName
8310
+ ? sessions.find((candidate) => candidate.sessionName === studioReplActiveSessionName && candidate.runtime === target)
8311
+ : null;
8312
+ const session = active ?? sessions.find((candidate) => candidate.runtime === target) ?? null;
8313
+ return session
8314
+ ? { session, sessions }
8315
+ : { session: null, error: `No running Studio-visible ${STUDIO_REPL_RUNTIME_LABELS[target]} REPL session.`, sessions };
8316
+ }
8317
+ if (studioReplActiveSessionName) {
8318
+ const active = sessions.find((candidate) => candidate.sessionName === studioReplActiveSessionName);
8319
+ if (active) return { session: active, sessions };
8320
+ }
8321
+ return sessions[0]
8322
+ ? { session: sessions[0], sessions }
8323
+ : { session: null, error: "No Studio-visible REPL sessions are running. Open Studio REPL view or start a session first.", sessions };
8324
+ };
8325
+
8326
+ const broadcastStudioReplToolSend = (payload: Record<string, unknown>) => {
8327
+ if (!serverState) return;
8328
+ const serialized = JSON.stringify({ type: "repl_tool_send", ...payload });
8329
+ for (const client of serverState.clients) {
8330
+ if (client.readyState !== WebSocket.OPEN) continue;
8331
+ try {
8332
+ client.send(serialized);
8333
+ } catch {
8334
+ // Ignore transport errors; close handler will clean up.
8335
+ }
8336
+ }
8337
+ };
8338
+
8339
+ pi.registerTool({
8340
+ name: "studio_repl_status",
8341
+ label: "Studio REPL status",
8342
+ description: "Inspect Studio-visible tmux REPL sessions and the active Studio REPL session.",
8343
+ promptSnippet: "Inspect the active Studio REPL session and other Studio-visible REPL sessions.",
8344
+ promptGuidelines: [
8345
+ "Use studio_repl_status before claiming whether a Studio REPL session is active if you are unsure.",
8346
+ "Use studio_repl_send, not raw tmux shell commands, when the user asks you to run code in the active Studio REPL.",
8347
+ ],
8348
+ parameters: STUDIO_REPL_STATUS_TOOL_PARAMS,
8349
+ async execute(_toolCallId, params) {
8350
+ const selected = selectStudioReplSessionForTool({ sessionName: params.sessionName, target: params.target });
8351
+ const lines = [
8352
+ `Active Studio REPL: ${studioReplActiveSessionName || "none"}`,
8353
+ `tmux sessions visible to Studio: ${selected.sessions.length}`,
8354
+ ];
8355
+ if (selected.error) lines.push(`Selection: ${selected.error}`);
8356
+ if (selected.session) {
8357
+ lines.push(`Selected: ${selected.session.sessionName} (${selected.session.runtime}, ${selected.session.source})`);
8358
+ }
8359
+ for (const session of selected.sessions) {
8360
+ lines.push(`- ${session.sessionName} | runtime=${session.runtime} | source=${session.source} | target=${session.target}`);
8361
+ }
8362
+ return {
8363
+ content: [{ type: "text", text: lines.join("\n") }],
8364
+ details: {
8365
+ activeSessionName: studioReplActiveSessionName,
8366
+ selectedSession: selected.session,
8367
+ sessions: selected.sessions,
8368
+ } as Record<string, unknown>,
8369
+ };
8370
+ },
8371
+ });
8372
+
8373
+ pi.registerTool({
8374
+ name: "studio_repl_send",
8375
+ label: "Send to Studio REPL",
8376
+ description: "Execute code in the active or selected Studio tmux-backed REPL session using Studio's safe runtime-specific submission protocol.",
8377
+ promptSnippet: "Execute code in the active Studio REPL session safely, including multiline Python/R/Julia/GHCi/Clojure snippets.",
8378
+ promptGuidelines: [
8379
+ "Use studio_repl_send when the user asks to run code in the active Studio REPL.",
8380
+ "Do not improvise tmux paste-buffer commands for Studio REPL code; studio_repl_send handles multiline quoting and runtime-specific submission.",
8381
+ "If several REPL sessions of the same runtime are running, use studio_repl_status first or pass the exact sessionName when known.",
8382
+ ],
8383
+ parameters: STUDIO_REPL_SEND_TOOL_PARAMS,
8384
+ executionMode: "sequential",
8385
+ async execute(toolCallId, params) {
8386
+ const selected = selectStudioReplSessionForTool({ sessionName: params.sessionName, target: params.target });
8387
+ if (!selected.session) {
8388
+ return {
8389
+ content: [{ type: "text", text: selected.error || "No Studio REPL session selected." }],
8390
+ details: { ok: false, error: selected.error || "No Studio REPL session selected.", sessions: selected.sessions } as Record<string, unknown>,
8391
+ };
8392
+ }
8393
+
8394
+ const before = captureStudioReplSession(selected.session.sessionName);
8395
+ const beforeTranscript = before.ok ? before.transcript : "";
8396
+ const sent = sendTextToStudioReplSession(selected.session.sessionName, params.code);
8397
+ if (!sent.ok) {
8398
+ return {
8399
+ content: [{ type: "text", text: sent.message }],
8400
+ details: { ok: false, error: sent.message, session: selected.session, sessions: selected.sessions } as Record<string, unknown>,
8401
+ };
8402
+ }
8403
+ studioReplActiveSessionName = selected.session.sessionName;
8404
+
8405
+ const timeoutMs = clampStudioReplSendTimeout(params.timeoutMs);
8406
+ let completed = false;
8407
+ if (sent.controlFiles?.doneFile) {
8408
+ completed = await waitForStudioReplDoneFile(sent.controlFiles.doneFile, timeoutMs);
8409
+ } else {
8410
+ await sleep(Math.min(750, timeoutMs));
8411
+ }
8412
+ const after = captureStudioReplSession(selected.session.sessionName);
8413
+ const afterTranscript = after.ok ? after.transcript : "";
8414
+ const output = extractStudioReplTranscriptDelta(beforeTranscript, afterTranscript);
8415
+ const statusLine = sent.controlFiles?.doneFile
8416
+ ? (completed ? "Completed." : `Timed out after ${timeoutMs} ms waiting for completion marker.`)
8417
+ : "Submitted.";
8418
+ const text = [
8419
+ `${statusLine} ${sent.message}`,
8420
+ output ? "" : undefined,
8421
+ output || undefined,
8422
+ ].filter(Boolean).join("\n");
8423
+ broadcastStudioReplToolSend({
8424
+ toolCallId,
8425
+ sessionName: selected.session.sessionName,
8426
+ runtime: sent.runtime === "unknown" ? selected.session.runtime : sent.runtime,
8427
+ code: params.code,
8428
+ label: "Pi",
8429
+ output,
8430
+ completed,
8431
+ timedOut: Boolean(sent.controlFiles?.doneFile && !completed),
8432
+ transcript: afterTranscript,
8433
+ capturedAt: Date.now(),
8434
+ });
8435
+ return {
8436
+ content: [{ type: "text", text }],
8437
+ details: {
8438
+ ok: true,
8439
+ completed,
8440
+ timedOut: Boolean(sent.controlFiles?.doneFile && !completed),
8441
+ timeoutMs,
8442
+ session: selected.session,
8443
+ sessions: selected.sessions,
8444
+ runtime: sent.runtime,
8445
+ usedControlFile: sent.usedControlFile,
8446
+ submissionText: sent.submissionText,
8447
+ controlFiles: sent.controlFiles,
8448
+ output,
8449
+ } as Record<string, unknown>,
8450
+ };
8451
+ },
8452
+ });
8453
+
8035
8454
  const isStudioDirectRunChainActive = () => Boolean(studioDirectRunChain);
8036
8455
  const getQueuedStudioSteeringCount = () => queuedStudioDirectRequests.length;
8037
8456
  const getStudioClientCounts = (): { full: number; editorOnly: number } => {
@@ -11408,6 +11827,7 @@ export default function (pi: ExtensionAPI) {
11408
11827
  const outputPath = buildStudioResponseExportOutputPath(ctx.cwd, "pdf");
11409
11828
 
11410
11829
  try {
11830
+ ctx.ui.notify("Exporting last response Studio PDF…", "info");
11411
11831
  const { pdf, warning } = await renderStudioPdfWithPandoc(
11412
11832
  response.markdown,
11413
11833
  isLatex,
@@ -11465,6 +11885,7 @@ export default function (pi: ExtensionAPI) {
11465
11885
  const outputPath = buildStudioPdfOutputPath(file.resolvedPath);
11466
11886
 
11467
11887
  try {
11888
+ ctx.ui.notify(`Exporting Studio PDF: ${outputPath}`, "info");
11468
11889
  const { pdf, warning } = await renderStudioPdfWithPandoc(
11469
11890
  file.text,
11470
11891
  isLatex,