pi-studio 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,9 @@ 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");
414
418
  const STUDIO_REPL_RUNTIME_LABELS: Record<StudioReplRuntime, string> = {
415
419
  shell: "Shell",
416
420
  python: "Python",
@@ -420,6 +424,16 @@ const STUDIO_REPL_RUNTIME_LABELS: Record<StudioReplRuntime, string> = {
420
424
  ghci: "GHCi",
421
425
  clojure: "Clojure",
422
426
  };
427
+ const STUDIO_REPL_SEND_TOOL_PARAMS = Type.Object({
428
+ code: Type.String({ description: "Code to execute in the active or selected Studio REPL session." }),
429
+ 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." })),
430
+ target: Type.Optional(Type.String({ description: "Optional runtime target: shell, python, ipython, julia, r, ghci, or clojure. Used when sessionName is omitted." })),
431
+ 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 })),
432
+ });
433
+ const STUDIO_REPL_STATUS_TOOL_PARAMS = Type.Object({
434
+ sessionName: Type.Optional(Type.String({ description: "Exact Studio/pi-repl tmux session name to inspect." })),
435
+ 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." })),
436
+ });
423
437
  const MAX_STUDIO_TRACE_SNAPSHOTS = RESPONSE_HISTORY_LIMIT;
424
438
  const TRANSIENT_STUDIO_DOCUMENT_TTL_MS = 30 * 60 * 1000;
425
439
  const MAX_TRANSIENT_STUDIO_DOCUMENTS = 16;
@@ -435,6 +449,7 @@ const STUDIO_PERSISTENT_STATE_PATH = join(STUDIO_PERSISTENT_STATE_DIR, "local-st
435
449
  let studioPersistentStateCache: StudioPersistentState | null = null;
436
450
  let studioPersistentStateQueue: Promise<void> = Promise.resolve();
437
451
  let transientStudioDocuments: Map<string, { document: InitialStudioDocument; createdAt: number }> = new Map();
452
+ const studioReplControlSubmissionLabels = new Map<string, string>();
438
453
 
439
454
  function createEmptyStudioPersistentState(): StudioPersistentState {
440
455
  return {
@@ -7031,7 +7046,7 @@ function summarizeStudioTraceToolArgs(toolName: string, args: unknown): string |
7031
7046
  if (normalizedTool === "read" || normalizedTool === "write" || normalizedTool === "edit") {
7032
7047
  return trimSummary(typeof payload.path === "string" ? payload.path : "");
7033
7048
  }
7034
- if (normalizedTool === "repl_send") {
7049
+ if (normalizedTool === "repl_send" || normalizedTool === "studio_repl_send") {
7035
7050
  return trimSummary(typeof payload.code === "string" ? payload.code : "");
7036
7051
  }
7037
7052
  try {
@@ -7158,6 +7173,23 @@ function listStudioReplSessions(): { tmuxAvailable: boolean; sessions: StudioRep
7158
7173
  return { tmuxAvailable: true, sessions };
7159
7174
  }
7160
7175
 
7176
+ function getStudioReplPromptPrefix(line: string): string {
7177
+ const source = String(line || "");
7178
+ const match = source.match(/^(\s*(?:(?:In \[\d+\]:)|(?:\.\.\.)|(?:>>>)|(?:julia>)|(?:ghci>)|(?:Prelude>)|(?:\*?[A-Za-z0-9_.:]+>)|(?:[^\s>]+=>)|(?:>)|(?:\+))\s*)/);
7179
+ return match ? (match[1] || "") : "";
7180
+ }
7181
+
7182
+ function sanitizeStudioReplTranscript(transcript: string): string {
7183
+ let value = String(transcript || "");
7184
+ for (const [sourceFile, label] of studioReplControlSubmissionLabels) {
7185
+ if (!value.includes(sourceFile)) continue;
7186
+ const escaped = sourceFile.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7187
+ const linePattern = new RegExp(`^.*${escaped}.*$`, "gm");
7188
+ value = value.replace(linePattern, (line) => `${getStudioReplPromptPrefix(line)}${label}`.trimEnd());
7189
+ }
7190
+ return value.replace(/[\t ]+$/gm, "").trimEnd();
7191
+ }
7192
+
7161
7193
  function captureStudioReplSession(sessionName: string): { ok: true; transcript: string; session: StudioReplSessionInfo } | { ok: false; message: string } {
7162
7194
  if (!/^[-_.A-Za-z0-9]+$/.test(sessionName)) return { ok: false, message: "Invalid REPL session name." };
7163
7195
  const inferred = inferStudioReplSessionRuntime(sessionName);
@@ -7168,9 +7200,9 @@ function captureStudioReplSession(sessionName: string): { ok: true; transcript:
7168
7200
  label: formatStudioReplSessionLabel(sessionName, inferred.runtime, inferred.source),
7169
7201
  source: inferred.source,
7170
7202
  };
7171
- const result = runStudioTmux(["capture-pane", "-p", "-t", session.target, "-S", `-${STUDIO_REPL_CAPTURE_LINES}`], { timeout: 3_000 });
7203
+ const result = runStudioTmux(["capture-pane", "-J", "-p", "-t", session.target, "-S", `-${STUDIO_REPL_CAPTURE_LINES}`], { timeout: 3_000 });
7172
7204
  if (!result.ok) return { ok: false, message: result.message };
7173
- return { ok: true, transcript: result.stdout.replace(/[\t ]+$/gm, "").trimEnd(), session };
7205
+ return { ok: true, transcript: sanitizeStudioReplTranscript(result.stdout), session };
7174
7206
  }
7175
7207
 
7176
7208
  function startStudioReplSession(runtime: StudioReplRuntime, cwd: string, options?: { newSession?: boolean }): { ok: true; session: StudioReplSessionInfo; message: string } | { ok: false; message: string } {
@@ -7218,32 +7250,298 @@ function stopStudioReplSession(sessionName: string): { ok: true; message: string
7218
7250
  return { ok: true, message: `Stopped ${sessionName}.` };
7219
7251
  }
7220
7252
 
7221
- function prepareTextForStudioReplSend(sessionName: string, source: string): string {
7222
- const normalized = String(source || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
7253
+ type StudioReplControlFiles = {
7254
+ dir: string;
7255
+ sourceFile: string;
7256
+ doneFile: string;
7257
+ };
7258
+
7259
+ type StudioReplPreparedSubmission = {
7260
+ runtime: StudioReplRuntime | "unknown";
7261
+ usedControlFile: boolean;
7262
+ submissionText: string;
7263
+ controlFiles?: StudioReplControlFiles;
7264
+ };
7265
+
7266
+ type StudioReplSendSuccess = {
7267
+ ok: true;
7268
+ message: string;
7269
+ runtime: StudioReplRuntime | "unknown";
7270
+ usedControlFile: boolean;
7271
+ submissionText: string;
7272
+ controlFiles?: StudioReplControlFiles;
7273
+ };
7274
+
7275
+ type StudioReplSendFailure = { ok: false; message: string };
7276
+
7277
+ function sleep(ms: number): Promise<void> {
7278
+ return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
7279
+ }
7280
+
7281
+ function clampStudioReplSendTimeout(timeoutMs: number | undefined): number {
7282
+ if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs)) return STUDIO_REPL_SEND_DEFAULT_TIMEOUT_MS;
7283
+ return Math.max(1_000, Math.min(STUDIO_REPL_SEND_MAX_TIMEOUT_MS, Math.round(timeoutMs)));
7284
+ }
7285
+
7286
+ function shellQuote(value: string): string {
7287
+ return `'${String(value || "").replace(/'/g, `'"'"'`)}'`;
7288
+ }
7289
+
7290
+ function getStudioReplControlFiles(sessionName: string, runtime: StudioReplRuntime | "unknown"): StudioReplControlFiles {
7291
+ const safeSession = sessionName.replace(/[^-_.A-Za-z0-9]+/g, "_");
7292
+ const safeRuntime = String(runtime || "repl").replace(/[^-_.A-Za-z0-9]+/g, "_");
7293
+ const dir = join(STUDIO_REPL_CONTROL_ROOT, safeSession, randomUUID().replace(/-/g, ""));
7294
+ const extension = runtime === "julia"
7295
+ ? "jl"
7296
+ : runtime === "r"
7297
+ ? "R"
7298
+ : runtime === "ghci"
7299
+ ? "ghci"
7300
+ : runtime === "clojure"
7301
+ ? "clj"
7302
+ : "py";
7303
+ return {
7304
+ dir,
7305
+ sourceFile: join(dir, `studio-repl-${safeRuntime}.${extension}`),
7306
+ doneFile: join(dir, "done.flag"),
7307
+ };
7308
+ }
7309
+
7310
+ function buildStudioPythonControlSource(runtime: "python" | "ipython", code: string, doneFile: string): string {
7311
+ if (runtime === "ipython") {
7312
+ return [
7313
+ "from pathlib import Path as __pi_studio_path",
7314
+ "import traceback as __pi_studio_traceback",
7315
+ "try:",
7316
+ " __pi_studio_ip = get_ipython()",
7317
+ " if __pi_studio_ip is None:",
7318
+ " raise RuntimeError('Expected IPython session, but get_ipython() returned None.')",
7319
+ ` __pi_studio_result = __pi_studio_ip.run_cell(${JSON.stringify(code)}, store_history=False)`,
7320
+ " if getattr(__pi_studio_result, 'error_in_exec', None) is None and getattr(__pi_studio_result, 'result', None) is not None:",
7321
+ " print(repr(__pi_studio_result.result))",
7322
+ "except Exception:",
7323
+ " __pi_studio_traceback.print_exc()",
7324
+ "finally:",
7325
+ ` __pi_studio_path(${JSON.stringify(doneFile)}).write_text('done\\n', encoding='utf-8')`,
7326
+ ].join("\n");
7327
+ }
7328
+
7329
+ return [
7330
+ "from pathlib import Path as __pi_studio_path",
7331
+ "import traceback as __pi_studio_traceback",
7332
+ `__pi_studio_code = ${JSON.stringify(code)}`,
7333
+ "try:",
7334
+ " try:",
7335
+ " __pi_studio_expr = compile(__pi_studio_code, '<pi-studio-repl>', 'eval')",
7336
+ " except SyntaxError:",
7337
+ " exec(compile(__pi_studio_code, '<pi-studio-repl>', 'exec'), globals())",
7338
+ " else:",
7339
+ " __pi_studio_value = eval(__pi_studio_expr, globals())",
7340
+ " if __pi_studio_value is not None:",
7341
+ " print(repr(__pi_studio_value))",
7342
+ "except Exception:",
7343
+ " __pi_studio_traceback.print_exc()",
7344
+ "finally:",
7345
+ ` __pi_studio_path(${JSON.stringify(doneFile)}).write_text('done\\n', encoding='utf-8')`,
7346
+ ].join("\n");
7347
+ }
7348
+
7349
+ function buildStudioJuliaControlSource(code: string, doneFile: string): string {
7350
+ return [
7351
+ "try",
7352
+ ` local __pi_studio_result = Base.include_string(Main, ${JSON.stringify(code)}, "pi-studio-repl")`,
7353
+ " if !isnothing(__pi_studio_result)",
7354
+ " println(repr(__pi_studio_result))",
7355
+ " end",
7356
+ "catch e",
7357
+ " Base.display_error(stderr, e, catch_backtrace())",
7358
+ "finally",
7359
+ ` write(${JSON.stringify(doneFile)}, "done\\n")`,
7360
+ "end",
7361
+ ].join("\n");
7362
+ }
7363
+
7364
+ function buildStudioRControlSource(code: string, doneFile: string): string {
7365
+ return [
7366
+ "local({",
7367
+ ` .__pi_studio_done_file <- ${JSON.stringify(doneFile)}`,
7368
+ ` .__pi_studio_code <- ${JSON.stringify(code)}`,
7369
+ " tryCatch({",
7370
+ " .__pi_studio_exprs <- parse(text = .__pi_studio_code, keep.source = FALSE)",
7371
+ " .__pi_studio_value <- NULL",
7372
+ " .__pi_studio_visible <- FALSE",
7373
+ " for (.__pi_studio_expr in .__pi_studio_exprs) {",
7374
+ " .__pi_studio_result <- withVisible(eval(.__pi_studio_expr, envir = .GlobalEnv))",
7375
+ " .__pi_studio_value <- .__pi_studio_result$value",
7376
+ " .__pi_studio_visible <- isTRUE(.__pi_studio_result$visible)",
7377
+ " }",
7378
+ " if (.__pi_studio_visible) print(.__pi_studio_value)",
7379
+ " }, error = function(e) {",
7380
+ " .__pi_studio_call <- conditionCall(e)",
7381
+ " if (is.null(.__pi_studio_call)) {",
7382
+ " message(\"Error: \", conditionMessage(e))",
7383
+ " } else {",
7384
+ " message(\"Error in \", paste(deparse(.__pi_studio_call), collapse = \" \"), \": \", conditionMessage(e))",
7385
+ " }",
7386
+ " }, finally = {",
7387
+ " writeLines(\"done\", .__pi_studio_done_file)",
7388
+ " })",
7389
+ "})",
7390
+ ].join("\n");
7391
+ }
7392
+
7393
+ function buildStudioClojureControlSource(code: string, doneFile: string): string {
7394
+ return [
7395
+ "(let [code " + JSON.stringify(code) + "]",
7396
+ " (try",
7397
+ " (let [rdr (clojure.lang.LineNumberingPushbackReader. (java.io.StringReader. code))]",
7398
+ " (loop [last-val nil has-val false]",
7399
+ " (let [form (read rdr false :pi-studio/eof)]",
7400
+ " (if (= form :pi-studio/eof)",
7401
+ " (when (and has-val (some? last-val)) (prn last-val))",
7402
+ " (recur (eval form) true)))))",
7403
+ " (catch Throwable t",
7404
+ " (#'clojure.main/repl-caught t))",
7405
+ " (finally",
7406
+ ` (spit ${JSON.stringify(doneFile)} "done\\n"))))`,
7407
+ ].join("\n");
7408
+ }
7409
+
7410
+ function buildStudioReplControlSource(runtime: StudioReplRuntime, code: string, doneFile: string): string | null {
7411
+ if (runtime === "python" || runtime === "ipython") return buildStudioPythonControlSource(runtime, code, doneFile);
7412
+ if (runtime === "julia") return buildStudioJuliaControlSource(code, doneFile);
7413
+ if (runtime === "r") return buildStudioRControlSource(code, doneFile);
7414
+ if (runtime === "ghci") return `${code.replace(/\r/g, "").trimEnd()}\n:! touch ${shellQuote(doneFile)}\n`;
7415
+ if (runtime === "clojure") return buildStudioClojureControlSource(code, doneFile);
7416
+ return null;
7417
+ }
7418
+
7419
+ function buildStudioReplSubmissionLine(runtime: StudioReplRuntime, sourceFile: string): string {
7420
+ const quotedPath = JSON.stringify(sourceFile);
7421
+ if (runtime === "julia") return `include(${quotedPath})`;
7422
+ if (runtime === "r") return `source(${quotedPath}, local=.GlobalEnv)`;
7423
+ if (runtime === "ghci") return `:script ${quotedPath}`;
7424
+ if (runtime === "clojure") return `(do (load-file ${quotedPath}) :pi-studio/silent)`;
7425
+ return `exec(open(${quotedPath}, encoding="utf-8").read(), globals())`;
7426
+ }
7427
+
7428
+ function buildStudioReplPreviewComment(runtime: StudioReplRuntime, code: string): string | undefined {
7429
+ const normalized = code.replace(/\r/g, "").trimEnd();
7430
+ const lineCount = normalized ? normalized.split("\n").length : 0;
7431
+ if (lineCount <= 1) return undefined;
7432
+ const prefix = runtime === "ghci" ? "--" : runtime === "clojure" ? ";;" : "#";
7433
+ return `${prefix} Studio sent ${lineCount}-line snippet`;
7434
+ }
7435
+
7436
+ function buildStudioReplDisplayLabel(runtime: StudioReplRuntime, code: string): string {
7437
+ const normalized = code.replace(/\r/g, "").trim();
7438
+ const singleLine = normalized && !normalized.includes("\n") ? normalized.replace(/\s+/g, " ") : "";
7439
+ if (singleLine && singleLine.length <= 140) return singleLine;
7440
+ return buildStudioReplPreviewComment(runtime, code) || "# Studio sent code";
7441
+ }
7442
+
7443
+ function rememberStudioReplControlSubmission(sourceFile: string, label: string): void {
7444
+ studioReplControlSubmissionLabels.set(sourceFile, label);
7445
+ while (studioReplControlSubmissionLabels.size > 300) {
7446
+ const oldest = studioReplControlSubmissionLabels.keys().next().value;
7447
+ if (!oldest) break;
7448
+ studioReplControlSubmissionLabels.delete(oldest);
7449
+ }
7450
+ }
7451
+
7452
+ function prepareStudioReplSubmission(sessionName: string, source: string): StudioReplPreparedSubmission {
7453
+ const normalizedSource = String(source || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
7223
7454
  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`;
7455
+ if (runtime !== "unknown" && runtime !== "shell") {
7456
+ const controlFiles = getStudioReplControlFiles(sessionName, runtime);
7457
+ const controlSource = buildStudioReplControlSource(runtime, normalizedSource, controlFiles.doneFile);
7458
+ if (controlSource) {
7459
+ mkdirSync(controlFiles.dir, { recursive: true });
7460
+ try {
7461
+ unlinkSync(controlFiles.doneFile);
7462
+ } catch {
7463
+ // Ignore stale done file cleanup failures.
7464
+ }
7465
+ writeFileSync(controlFiles.sourceFile, controlSource, "utf-8");
7466
+ const submissionLine = buildStudioReplSubmissionLine(runtime, controlFiles.sourceFile);
7467
+ rememberStudioReplControlSubmission(controlFiles.sourceFile, buildStudioReplDisplayLabel(runtime, normalizedSource));
7468
+ return {
7469
+ runtime,
7470
+ usedControlFile: true,
7471
+ controlFiles,
7472
+ submissionText: submissionLine,
7473
+ };
7474
+ }
7229
7475
  }
7230
- return normalized.endsWith("\n") ? normalized : `${normalized}\n`;
7476
+
7477
+ return {
7478
+ runtime,
7479
+ usedControlFile: false,
7480
+ submissionText: normalizedSource.replace(/\n+$/, ""),
7481
+ };
7231
7482
  }
7232
7483
 
7233
- function sendTextToStudioReplSession(sessionName: string, text: string): { ok: true; message: string } | { ok: false; message: string } {
7484
+ function pasteTextToStudioReplPane(sessionName: string, text: string): { ok: true } | { ok: false; message: string } {
7485
+ const bufferName = `pi-studio-repl-${randomUUID().replace(/-/g, "")}`;
7486
+ const target = getStudioReplPaneTarget(sessionName);
7487
+ const loadResult = runStudioTmux(["load-buffer", "-b", bufferName, "-"], { input: text, timeout: 5_000 });
7488
+ if (!loadResult.ok) return { ok: false, message: loadResult.message || "Failed to load text into tmux buffer." };
7489
+ try {
7490
+ const pasteResult = runStudioTmux(["paste-buffer", "-d", "-b", bufferName, "-t", target], { timeout: 5_000 });
7491
+ if (!pasteResult.ok) return { ok: false, message: pasteResult.message || "Failed to paste text into REPL session." };
7492
+ const enterResult = runStudioTmux(["send-keys", "-t", target, "C-m"], { timeout: 5_000 });
7493
+ if (!enterResult.ok) return { ok: false, message: enterResult.message || "Failed to send Enter to REPL session." };
7494
+ return { ok: true };
7495
+ } finally {
7496
+ runStudioTmux(["delete-buffer", "-b", bufferName], { timeout: 2_000 });
7497
+ }
7498
+ }
7499
+
7500
+ function sendTextToStudioReplSession(sessionName: string, text: string): StudioReplSendSuccess | StudioReplSendFailure {
7234
7501
  if (!/^[-_.A-Za-z0-9]+$/.test(sessionName)) return { ok: false, message: "Invalid REPL session name." };
7235
7502
  const source = String(text || "");
7236
7503
  if (!source.trim()) return { ok: false, message: "Editor text is empty." };
7237
7504
  if (source.length > STUDIO_REPL_SEND_MAX_CHARS) {
7238
7505
  return { ok: false, message: `REPL input is too large (${source.length} chars; max ${STUDIO_REPL_SEND_MAX_CHARS}).` };
7239
7506
  }
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}.` };
7507
+ const prepared = prepareStudioReplSubmission(sessionName, source);
7508
+ const pasted = pasteTextToStudioReplPane(sessionName, prepared.submissionText);
7509
+ if (!pasted.ok) return { ok: false, message: pasted.message };
7510
+ return {
7511
+ ok: true,
7512
+ message: "Sent to REPL.",
7513
+ runtime: prepared.runtime,
7514
+ usedControlFile: prepared.usedControlFile,
7515
+ submissionText: prepared.submissionText,
7516
+ controlFiles: prepared.controlFiles,
7517
+ };
7518
+ }
7519
+
7520
+ function extractStudioReplTranscriptDelta(before: string, after: string): string {
7521
+ const previous = String(before || "");
7522
+ const current = String(after || "");
7523
+ if (!current) return "";
7524
+ if (!previous) return current.trim();
7525
+ const directIndex = current.indexOf(previous);
7526
+ if (directIndex >= 0) return current.slice(directIndex + previous.length).trim();
7527
+ const previousLines = previous.split("\n");
7528
+ for (let count = Math.min(previousLines.length, 40); count >= 1; count -= 1) {
7529
+ const suffix = previousLines.slice(previousLines.length - count).join("\n");
7530
+ if (!suffix.trim()) continue;
7531
+ const suffixIndex = current.indexOf(suffix);
7532
+ if (suffixIndex >= 0) return current.slice(suffixIndex + suffix.length).trim();
7533
+ }
7534
+ return current.trim();
7535
+ }
7536
+
7537
+ async function waitForStudioReplDoneFile(doneFile: string | undefined, timeoutMs: number): Promise<boolean> {
7538
+ if (!doneFile) return false;
7539
+ const deadline = Date.now() + clampStudioReplSendTimeout(timeoutMs);
7540
+ while (Date.now() < deadline) {
7541
+ if (existsSync(doneFile)) return true;
7542
+ await sleep(100);
7543
+ }
7544
+ return existsSync(doneFile);
7247
7545
  }
7248
7546
 
7249
7547
  function interruptStudioReplSession(sessionName: string): { ok: true; message: string } | { ok: false; message: string } {
@@ -7745,8 +8043,8 @@ ${cssVarsBlock}
7745
8043
  <button id="queueSteerBtn" type="button" title="Queue steering is available while Run editor text is active." disabled>Queue steering</button>
7746
8044
  <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
8045
  <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>
8046
+ <option value="raw" selected>Send mode: Raw</option>
8047
+ <option value="literate">Send mode: Literate</option>
7750
8048
  </select>
7751
8049
  <button id="copyDraftBtn" type="button" title="Copy the current editor text to the clipboard.">Copy text</button>
7752
8050
  <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 +8330,164 @@ export default function (pi: ExtensionAPI) {
8032
8330
  let compactInProgress = false;
8033
8331
  let compactRequestId: string | null = null;
8034
8332
 
8333
+ const selectStudioReplSessionForTool = (params: { sessionName?: string; target?: string }): { session: StudioReplSessionInfo | null; error?: string; sessions: StudioReplSessionInfo[] } => {
8334
+ const state = listStudioReplSessions();
8335
+ const sessions = state.sessions;
8336
+ if (!state.tmuxAvailable) return { session: null, error: "tmux is not available.", sessions };
8337
+ if (typeof params.sessionName === "string" && params.sessionName.trim()) {
8338
+ const requested = params.sessionName.trim();
8339
+ const session = sessions.find((candidate) => candidate.sessionName === requested) ?? null;
8340
+ return session
8341
+ ? { session, sessions }
8342
+ : { session: null, error: `No Studio-visible REPL session named ${requested}.`, sessions };
8343
+ }
8344
+ const target = normalizeStudioReplRuntime(params.target);
8345
+ if (target) {
8346
+ const active = studioReplActiveSessionName
8347
+ ? sessions.find((candidate) => candidate.sessionName === studioReplActiveSessionName && candidate.runtime === target)
8348
+ : null;
8349
+ const session = active ?? sessions.find((candidate) => candidate.runtime === target) ?? null;
8350
+ return session
8351
+ ? { session, sessions }
8352
+ : { session: null, error: `No running Studio-visible ${STUDIO_REPL_RUNTIME_LABELS[target]} REPL session.`, sessions };
8353
+ }
8354
+ if (studioReplActiveSessionName) {
8355
+ const active = sessions.find((candidate) => candidate.sessionName === studioReplActiveSessionName);
8356
+ if (active) return { session: active, sessions };
8357
+ }
8358
+ return sessions[0]
8359
+ ? { session: sessions[0], sessions }
8360
+ : { session: null, error: "No Studio-visible REPL sessions are running. Open Studio REPL view or start a session first.", sessions };
8361
+ };
8362
+
8363
+ const broadcastStudioReplToolSend = (payload: Record<string, unknown>) => {
8364
+ if (!serverState) return;
8365
+ const serialized = JSON.stringify({ type: "repl_tool_send", ...payload });
8366
+ for (const client of serverState.clients) {
8367
+ if (client.readyState !== WebSocket.OPEN) continue;
8368
+ try {
8369
+ client.send(serialized);
8370
+ } catch {
8371
+ // Ignore transport errors; close handler will clean up.
8372
+ }
8373
+ }
8374
+ };
8375
+
8376
+ pi.registerTool({
8377
+ name: "studio_repl_status",
8378
+ label: "Studio REPL status",
8379
+ description: "Inspect Studio-visible tmux REPL sessions and the active Studio REPL session.",
8380
+ promptSnippet: "Inspect the active Studio REPL session and other Studio-visible REPL sessions.",
8381
+ promptGuidelines: [
8382
+ "Use studio_repl_status before claiming whether a Studio REPL session is active if you are unsure.",
8383
+ "Use studio_repl_send, not raw tmux shell commands, when the user asks you to run code in the active Studio REPL.",
8384
+ ],
8385
+ parameters: STUDIO_REPL_STATUS_TOOL_PARAMS,
8386
+ async execute(_toolCallId, params) {
8387
+ const selected = selectStudioReplSessionForTool({ sessionName: params.sessionName, target: params.target });
8388
+ const lines = [
8389
+ `Active Studio REPL: ${studioReplActiveSessionName || "none"}`,
8390
+ `tmux sessions visible to Studio: ${selected.sessions.length}`,
8391
+ ];
8392
+ if (selected.error) lines.push(`Selection: ${selected.error}`);
8393
+ if (selected.session) {
8394
+ lines.push(`Selected: ${selected.session.sessionName} (${selected.session.runtime}, ${selected.session.source})`);
8395
+ }
8396
+ for (const session of selected.sessions) {
8397
+ lines.push(`- ${session.sessionName} | runtime=${session.runtime} | source=${session.source} | target=${session.target}`);
8398
+ }
8399
+ return {
8400
+ content: [{ type: "text", text: lines.join("\n") }],
8401
+ details: {
8402
+ activeSessionName: studioReplActiveSessionName,
8403
+ selectedSession: selected.session,
8404
+ sessions: selected.sessions,
8405
+ } as Record<string, unknown>,
8406
+ };
8407
+ },
8408
+ });
8409
+
8410
+ pi.registerTool({
8411
+ name: "studio_repl_send",
8412
+ label: "Send to Studio REPL",
8413
+ description: "Execute code in the active or selected Studio tmux-backed REPL session using Studio's safe runtime-specific submission protocol.",
8414
+ promptSnippet: "Execute code in the active Studio REPL session safely, including multiline Python/R/Julia/GHCi/Clojure snippets.",
8415
+ promptGuidelines: [
8416
+ "Use studio_repl_send when the user asks to run code in the active Studio REPL.",
8417
+ "Do not improvise tmux paste-buffer commands for Studio REPL code; studio_repl_send handles multiline quoting and runtime-specific submission.",
8418
+ "If several REPL sessions of the same runtime are running, use studio_repl_status first or pass the exact sessionName when known.",
8419
+ ],
8420
+ parameters: STUDIO_REPL_SEND_TOOL_PARAMS,
8421
+ executionMode: "sequential",
8422
+ async execute(toolCallId, params) {
8423
+ const selected = selectStudioReplSessionForTool({ sessionName: params.sessionName, target: params.target });
8424
+ if (!selected.session) {
8425
+ return {
8426
+ content: [{ type: "text", text: selected.error || "No Studio REPL session selected." }],
8427
+ details: { ok: false, error: selected.error || "No Studio REPL session selected.", sessions: selected.sessions } as Record<string, unknown>,
8428
+ };
8429
+ }
8430
+
8431
+ const before = captureStudioReplSession(selected.session.sessionName);
8432
+ const beforeTranscript = before.ok ? before.transcript : "";
8433
+ const sent = sendTextToStudioReplSession(selected.session.sessionName, params.code);
8434
+ if (!sent.ok) {
8435
+ return {
8436
+ content: [{ type: "text", text: sent.message }],
8437
+ details: { ok: false, error: sent.message, session: selected.session, sessions: selected.sessions } as Record<string, unknown>,
8438
+ };
8439
+ }
8440
+ studioReplActiveSessionName = selected.session.sessionName;
8441
+
8442
+ const timeoutMs = clampStudioReplSendTimeout(params.timeoutMs);
8443
+ let completed = false;
8444
+ if (sent.controlFiles?.doneFile) {
8445
+ completed = await waitForStudioReplDoneFile(sent.controlFiles.doneFile, timeoutMs);
8446
+ } else {
8447
+ await sleep(Math.min(750, timeoutMs));
8448
+ }
8449
+ const after = captureStudioReplSession(selected.session.sessionName);
8450
+ const afterTranscript = after.ok ? after.transcript : "";
8451
+ const output = extractStudioReplTranscriptDelta(beforeTranscript, afterTranscript);
8452
+ const statusLine = sent.controlFiles?.doneFile
8453
+ ? (completed ? "Completed." : `Timed out after ${timeoutMs} ms waiting for completion marker.`)
8454
+ : "Submitted.";
8455
+ const text = [
8456
+ `${statusLine} ${sent.message}`,
8457
+ output ? "" : undefined,
8458
+ output || undefined,
8459
+ ].filter(Boolean).join("\n");
8460
+ broadcastStudioReplToolSend({
8461
+ toolCallId,
8462
+ sessionName: selected.session.sessionName,
8463
+ runtime: sent.runtime === "unknown" ? selected.session.runtime : sent.runtime,
8464
+ code: params.code,
8465
+ label: "Pi",
8466
+ output,
8467
+ completed,
8468
+ timedOut: Boolean(sent.controlFiles?.doneFile && !completed),
8469
+ transcript: afterTranscript,
8470
+ capturedAt: Date.now(),
8471
+ });
8472
+ return {
8473
+ content: [{ type: "text", text }],
8474
+ details: {
8475
+ ok: true,
8476
+ completed,
8477
+ timedOut: Boolean(sent.controlFiles?.doneFile && !completed),
8478
+ timeoutMs,
8479
+ session: selected.session,
8480
+ sessions: selected.sessions,
8481
+ runtime: sent.runtime,
8482
+ usedControlFile: sent.usedControlFile,
8483
+ submissionText: sent.submissionText,
8484
+ controlFiles: sent.controlFiles,
8485
+ output,
8486
+ } as Record<string, unknown>,
8487
+ };
8488
+ },
8489
+ });
8490
+
8035
8491
  const isStudioDirectRunChainActive = () => Boolean(studioDirectRunChain);
8036
8492
  const getQueuedStudioSteeringCount = () => queuedStudioDirectRequests.length;
8037
8493
  const getStudioClientCounts = (): { full: number; editorOnly: number } => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, prompt/response history, live previews, and tmux-backed REPL/literate REPL workflows",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -43,12 +43,13 @@
43
43
  "@earendil-works/pi-coding-agent": "*"
44
44
  },
45
45
  "dependencies": {
46
+ "@sinclair/typebox": "^0.34.49",
46
47
  "ws": "^8.18.0"
47
48
  },
48
49
  "devDependencies": {
50
+ "@earendil-works/pi-coding-agent": "^0.74.0",
49
51
  "@types/node": "^24.3.0",
50
52
  "@types/ws": "^8.18.1",
51
- "typescript": "^5.7.3",
52
- "@earendil-works/pi-coding-agent": "^0.74.0"
53
+ "typescript": "^5.7.3"
53
54
  }
54
55
  }