pi-studio 0.8.4 → 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";
@@ -35,6 +36,7 @@ type StudioSourceKind = "file" | "last-response" | "blank";
35
36
  type TerminalActivityPhase = "idle" | "running" | "tool" | "responding";
36
37
  type StudioPromptMode = "response" | "run" | "effective";
37
38
  type StudioPromptTriggerKind = "run" | "steer";
39
+ type StudioReplRuntime = "shell" | "python" | "ipython" | "julia" | "r" | "ghci" | "clojure";
38
40
 
39
41
  const STUDIO_CSS_URL = new URL("./client/studio.css", import.meta.url);
40
42
  const STUDIO_ANNOTATION_HELPERS_URL = new URL("./client/studio-annotation-helpers.js", import.meta.url);
@@ -111,6 +113,14 @@ interface StudioContextUsageSnapshot {
111
113
  percent: number | null;
112
114
  }
113
115
 
116
+ interface StudioReplSessionInfo {
117
+ sessionName: string;
118
+ target: string;
119
+ runtime: StudioReplRuntime | "unknown";
120
+ label: string;
121
+ source: "studio" | "pi-repl" | "tmux";
122
+ }
123
+
114
124
  interface PreparedStudioPdfExport {
115
125
  pdf: Buffer;
116
126
  filename: string;
@@ -267,6 +277,41 @@ interface SendRunRequestMessage {
267
277
  text: string;
268
278
  }
269
279
 
280
+ interface ReplListRequestMessage {
281
+ type: "repl_list_request";
282
+ }
283
+
284
+ interface ReplCaptureRequestMessage {
285
+ type: "repl_capture_request";
286
+ sessionName?: string;
287
+ }
288
+
289
+ interface ReplStartRequestMessage {
290
+ type: "repl_start_request";
291
+ requestId: string;
292
+ runtime: StudioReplRuntime;
293
+ newSession?: boolean;
294
+ }
295
+
296
+ interface ReplStopRequestMessage {
297
+ type: "repl_stop_request";
298
+ requestId: string;
299
+ sessionName: string;
300
+ }
301
+
302
+ interface ReplSendRequestMessage {
303
+ type: "repl_send_request";
304
+ requestId: string;
305
+ sessionName: string;
306
+ text: string;
307
+ }
308
+
309
+ interface ReplInterruptRequestMessage {
310
+ type: "repl_interrupt_request";
311
+ requestId: string;
312
+ sessionName: string;
313
+ }
314
+
270
315
  interface CompactRequestMessage {
271
316
  type: "compact_request";
272
317
  requestId: string;
@@ -331,6 +376,12 @@ type IncomingStudioMessage =
331
376
  | CritiqueRequestMessage
332
377
  | AnnotationRequestMessage
333
378
  | SendRunRequestMessage
379
+ | ReplListRequestMessage
380
+ | ReplCaptureRequestMessage
381
+ | ReplStartRequestMessage
382
+ | ReplStopRequestMessage
383
+ | ReplSendRequestMessage
384
+ | ReplInterruptRequestMessage
334
385
  | CompactRequestMessage
335
386
  | SaveAsRequestMessage
336
387
  | SaveOverRequestMessage
@@ -359,6 +410,30 @@ const STUDIO_TRACE_IMAGE_MAX_BASE64_CHARS = 2_500_000;
359
410
  const STUDIO_TRACE_SNAPSHOT_MAX_IMAGES = 12;
360
411
  const STUDIO_TRACE_SNAPSHOT_MAX_IMAGE_BASE64_CHARS = 6_000_000;
361
412
  const STUDIO_TRACE_IMAGE_SAFE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
413
+ const STUDIO_REPL_CAPTURE_LINES = 800;
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_REPL_RUNTIME_LABELS: Record<StudioReplRuntime, string> = {
419
+ shell: "Shell",
420
+ python: "Python",
421
+ ipython: "IPython",
422
+ julia: "Julia",
423
+ r: "R",
424
+ ghci: "GHCi",
425
+ clojure: "Clojure",
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
+ });
362
437
  const MAX_STUDIO_TRACE_SNAPSHOTS = RESPONSE_HISTORY_LIMIT;
363
438
  const TRANSIENT_STUDIO_DOCUMENT_TTL_MS = 30 * 60 * 1000;
364
439
  const MAX_TRANSIENT_STUDIO_DOCUMENTS = 16;
@@ -374,6 +449,7 @@ const STUDIO_PERSISTENT_STATE_PATH = join(STUDIO_PERSISTENT_STATE_DIR, "local-st
374
449
  let studioPersistentStateCache: StudioPersistentState | null = null;
375
450
  let studioPersistentStateQueue: Promise<void> = Promise.resolve();
376
451
  let transientStudioDocuments: Map<string, { document: InitialStudioDocument; createdAt: number }> = new Map();
452
+ const studioReplControlSubmissionLabels = new Map<string, string>();
377
453
 
378
454
  function createEmptyStudioPersistentState(): StudioPersistentState {
379
455
  return {
@@ -6411,6 +6487,54 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
6411
6487
  };
6412
6488
  }
6413
6489
 
6490
+ if (msg.type === "repl_list_request") {
6491
+ return { type: "repl_list_request" };
6492
+ }
6493
+
6494
+ if (msg.type === "repl_capture_request") {
6495
+ return {
6496
+ type: "repl_capture_request",
6497
+ sessionName: typeof msg.sessionName === "string" ? msg.sessionName : undefined,
6498
+ };
6499
+ }
6500
+
6501
+ if (msg.type === "repl_start_request" && typeof msg.requestId === "string") {
6502
+ const runtime = normalizeStudioReplRuntime(msg.runtime);
6503
+ if (runtime) {
6504
+ return {
6505
+ type: "repl_start_request",
6506
+ requestId: msg.requestId,
6507
+ runtime,
6508
+ newSession: Boolean(msg.newSession),
6509
+ };
6510
+ }
6511
+ }
6512
+
6513
+ if (msg.type === "repl_stop_request" && typeof msg.requestId === "string" && typeof msg.sessionName === "string") {
6514
+ return {
6515
+ type: "repl_stop_request",
6516
+ requestId: msg.requestId,
6517
+ sessionName: msg.sessionName,
6518
+ };
6519
+ }
6520
+
6521
+ if (msg.type === "repl_send_request" && typeof msg.requestId === "string" && typeof msg.sessionName === "string" && typeof msg.text === "string") {
6522
+ return {
6523
+ type: "repl_send_request",
6524
+ requestId: msg.requestId,
6525
+ sessionName: msg.sessionName,
6526
+ text: msg.text,
6527
+ };
6528
+ }
6529
+
6530
+ if (msg.type === "repl_interrupt_request" && typeof msg.requestId === "string" && typeof msg.sessionName === "string") {
6531
+ return {
6532
+ type: "repl_interrupt_request",
6533
+ requestId: msg.requestId,
6534
+ sessionName: msg.sessionName,
6535
+ };
6536
+ }
6537
+
6414
6538
  if (
6415
6539
  msg.type === "compact_request" &&
6416
6540
  typeof msg.requestId === "string" &&
@@ -6922,7 +7046,7 @@ function summarizeStudioTraceToolArgs(toolName: string, args: unknown): string |
6922
7046
  if (normalizedTool === "read" || normalizedTool === "write" || normalizedTool === "edit") {
6923
7047
  return trimSummary(typeof payload.path === "string" ? payload.path : "");
6924
7048
  }
6925
- if (normalizedTool === "repl_send") {
7049
+ if (normalizedTool === "repl_send" || normalizedTool === "studio_repl_send") {
6926
7050
  return trimSummary(typeof payload.code === "string" ? payload.code : "");
6927
7051
  }
6928
7052
  try {
@@ -6932,6 +7056,501 @@ function summarizeStudioTraceToolArgs(toolName: string, args: unknown): string |
6932
7056
  }
6933
7057
  }
6934
7058
 
7059
+ function isStudioReplRuntime(value: unknown): value is StudioReplRuntime {
7060
+ return value === "shell"
7061
+ || value === "python"
7062
+ || value === "ipython"
7063
+ || value === "julia"
7064
+ || value === "r"
7065
+ || value === "ghci"
7066
+ || value === "clojure";
7067
+ }
7068
+
7069
+ function normalizeStudioReplRuntime(value: unknown): StudioReplRuntime | null {
7070
+ const normalized = String(value || "").trim().toLowerCase();
7071
+ if (normalized === "r") return "r";
7072
+ return isStudioReplRuntime(normalized) ? normalized : null;
7073
+ }
7074
+
7075
+ function getStudioReplRuntimeCommand(runtime: StudioReplRuntime): string {
7076
+ if (runtime === "shell") return String(process.env.SHELL || "bash").trim() || "bash";
7077
+ if (runtime === "python") return "python3";
7078
+ if (runtime === "ipython") return "ipython";
7079
+ if (runtime === "julia") return "julia";
7080
+ if (runtime === "r") return "R";
7081
+ if (runtime === "ghci") return "ghci";
7082
+ return "clojure";
7083
+ }
7084
+
7085
+ function getStudioReplSessionName(runtime: StudioReplRuntime): string {
7086
+ return `pi-studio-repl-${runtime}`;
7087
+ }
7088
+
7089
+ function getNewStudioReplSessionName(runtime: StudioReplRuntime): string {
7090
+ const suffix = `${Date.now().toString(36)}-${randomUUID().slice(0, 6)}`;
7091
+ return `pi-studio-repl-${runtime}-${suffix}`;
7092
+ }
7093
+
7094
+ function getStudioReplPaneTarget(sessionName: string): string {
7095
+ return `${sessionName}:0.0`;
7096
+ }
7097
+
7098
+ function inferStudioReplSessionRuntime(sessionName: string): { runtime: StudioReplRuntime | "unknown"; source: StudioReplSessionInfo["source"] } {
7099
+ const studioMatch = sessionName.match(/^pi-studio-repl-([a-z0-9-]+)$/i);
7100
+ if (studioMatch) {
7101
+ const raw = (studioMatch[1] || "").toLowerCase();
7102
+ const runtime = (["clojure", "python", "ipython", "julia", "shell", "ghci", "r"] as StudioReplRuntime[])
7103
+ .find((candidate) => raw === candidate || raw.startsWith(`${candidate}-`));
7104
+ return { runtime: runtime ?? "unknown", source: "studio" };
7105
+ }
7106
+ const piReplMatch = sessionName.match(/^pi-repl-([a-z0-9-]+)$/i);
7107
+ if (piReplMatch) {
7108
+ const raw = piReplMatch[1]?.toLowerCase() || "";
7109
+ const runtime = raw === "python" ? "python" : normalizeStudioReplRuntime(raw);
7110
+ return { runtime: runtime ?? "unknown", source: "pi-repl" };
7111
+ }
7112
+ return { runtime: "unknown", source: "tmux" };
7113
+ }
7114
+
7115
+ function shouldShowStudioReplTmuxSession(sessionName: string): boolean {
7116
+ return /^pi-studio-repl-/i.test(sessionName) || /^pi-repl-/i.test(sessionName);
7117
+ }
7118
+
7119
+ function formatStudioReplSessionLabel(sessionName: string, runtime: StudioReplRuntime | "unknown", source: StudioReplSessionInfo["source"]): string {
7120
+ const runtimeLabel = runtime === "unknown" ? "REPL" : STUDIO_REPL_RUNTIME_LABELS[runtime];
7121
+ if (source === "pi-repl") return `${runtimeLabel} (${sessionName})`;
7122
+ if (source === "studio") return `${runtimeLabel} (${sessionName})`;
7123
+ return sessionName;
7124
+ }
7125
+
7126
+ function isTmuxAvailable(): boolean {
7127
+ const result = spawnSync("tmux", ["-V"], { encoding: "utf8", timeout: 3_000 });
7128
+ return result.status === 0;
7129
+ }
7130
+
7131
+ function runStudioTmux(args: string[], options?: { cwd?: string; input?: string; timeout?: number }): { ok: true; stdout: string; stderr: string } | { ok: false; message: string; stdout: string; stderr: string } {
7132
+ const result = spawnSync("tmux", args, {
7133
+ cwd: options?.cwd,
7134
+ input: options?.input,
7135
+ encoding: "utf8",
7136
+ timeout: options?.timeout ?? 5_000,
7137
+ maxBuffer: 10 * 1024 * 1024,
7138
+ });
7139
+ const stdout = typeof result.stdout === "string" ? result.stdout : "";
7140
+ const stderr = typeof result.stderr === "string" ? result.stderr : "";
7141
+ if (result.error) {
7142
+ const message = result.error.message || String(result.error);
7143
+ return { ok: false, message, stdout, stderr };
7144
+ }
7145
+ if (result.status !== 0) {
7146
+ return { ok: false, message: (stderr || stdout || `tmux exited with code ${result.status}`).trim(), stdout, stderr };
7147
+ }
7148
+ return { ok: true, stdout, stderr };
7149
+ }
7150
+
7151
+ function listStudioReplSessions(): { tmuxAvailable: boolean; sessions: StudioReplSessionInfo[]; error?: string } {
7152
+ if (!isTmuxAvailable()) return { tmuxAvailable: false, sessions: [], error: "tmux is not available." };
7153
+ const result = runStudioTmux(["list-sessions", "-F", "#{session_name}"], { timeout: 3_000 });
7154
+ if (!result.ok) {
7155
+ const message = result.message.toLowerCase().includes("no server running") ? "No tmux sessions are running." : result.message;
7156
+ return { tmuxAvailable: true, sessions: [], error: message };
7157
+ }
7158
+ const sessions = result.stdout
7159
+ .split(/\r?\n/)
7160
+ .map((line) => line.trim())
7161
+ .filter(Boolean)
7162
+ .filter(shouldShowStudioReplTmuxSession)
7163
+ .map((sessionName) => {
7164
+ const inferred = inferStudioReplSessionRuntime(sessionName);
7165
+ return {
7166
+ sessionName,
7167
+ target: getStudioReplPaneTarget(sessionName),
7168
+ runtime: inferred.runtime,
7169
+ label: formatStudioReplSessionLabel(sessionName, inferred.runtime, inferred.source),
7170
+ source: inferred.source,
7171
+ };
7172
+ });
7173
+ return { tmuxAvailable: true, sessions };
7174
+ }
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
+
7193
+ function captureStudioReplSession(sessionName: string): { ok: true; transcript: string; session: StudioReplSessionInfo } | { ok: false; message: string } {
7194
+ if (!/^[-_.A-Za-z0-9]+$/.test(sessionName)) return { ok: false, message: "Invalid REPL session name." };
7195
+ const inferred = inferStudioReplSessionRuntime(sessionName);
7196
+ const session: StudioReplSessionInfo = {
7197
+ sessionName,
7198
+ target: getStudioReplPaneTarget(sessionName),
7199
+ runtime: inferred.runtime,
7200
+ label: formatStudioReplSessionLabel(sessionName, inferred.runtime, inferred.source),
7201
+ source: inferred.source,
7202
+ };
7203
+ const result = runStudioTmux(["capture-pane", "-J", "-p", "-t", session.target, "-S", `-${STUDIO_REPL_CAPTURE_LINES}`], { timeout: 3_000 });
7204
+ if (!result.ok) return { ok: false, message: result.message };
7205
+ return { ok: true, transcript: sanitizeStudioReplTranscript(result.stdout), session };
7206
+ }
7207
+
7208
+ function startStudioReplSession(runtime: StudioReplRuntime, cwd: string, options?: { newSession?: boolean }): { ok: true; session: StudioReplSessionInfo; message: string } | { ok: false; message: string } {
7209
+ if (!isTmuxAvailable()) return { ok: false, message: "tmux is not available. Install tmux to use Studio REPL sessions." };
7210
+ const sessionName = options?.newSession ? getNewStudioReplSessionName(runtime) : getStudioReplSessionName(runtime);
7211
+ const existing = runStudioTmux(["has-session", "-t", sessionName], { timeout: 3_000 });
7212
+ if (existing.ok) {
7213
+ const inferred = inferStudioReplSessionRuntime(sessionName);
7214
+ return {
7215
+ ok: true,
7216
+ session: {
7217
+ sessionName,
7218
+ target: getStudioReplPaneTarget(sessionName),
7219
+ runtime: inferred.runtime,
7220
+ label: formatStudioReplSessionLabel(sessionName, inferred.runtime, inferred.source),
7221
+ source: inferred.source,
7222
+ },
7223
+ message: `${STUDIO_REPL_RUNTIME_LABELS[runtime]} REPL is already running.`,
7224
+ };
7225
+ }
7226
+ const command = getStudioReplRuntimeCommand(runtime);
7227
+ const result = runStudioTmux(["new-session", "-d", "-s", sessionName, "-c", cwd || process.cwd(), command], { timeout: 5_000 });
7228
+ if (!result.ok) return { ok: false, message: result.message || `Failed to start ${STUDIO_REPL_RUNTIME_LABELS[runtime]} REPL.` };
7229
+ return {
7230
+ ok: true,
7231
+ session: {
7232
+ sessionName,
7233
+ target: getStudioReplPaneTarget(sessionName),
7234
+ runtime,
7235
+ label: formatStudioReplSessionLabel(sessionName, runtime, "studio"),
7236
+ source: "studio",
7237
+ },
7238
+ message: `Started ${options?.newSession ? "new " : ""}${STUDIO_REPL_RUNTIME_LABELS[runtime]} REPL.`,
7239
+ };
7240
+ }
7241
+
7242
+ function stopStudioReplSession(sessionName: string): { ok: true; message: string } | { ok: false; message: string } {
7243
+ if (!/^[-_.A-Za-z0-9]+$/.test(sessionName)) return { ok: false, message: "Invalid REPL session name." };
7244
+ const inferred = inferStudioReplSessionRuntime(sessionName);
7245
+ if (inferred.source !== "studio") {
7246
+ return { ok: false, message: "Studio can only stop Studio-owned REPL sessions. Use tmux or pi-repl to stop external sessions." };
7247
+ }
7248
+ const result = runStudioTmux(["kill-session", "-t", sessionName], { timeout: 5_000 });
7249
+ if (!result.ok) return { ok: false, message: result.message || "Failed to stop REPL session." };
7250
+ return { ok: true, message: `Stopped ${sessionName}.` };
7251
+ }
7252
+
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");
7454
+ const runtime = inferStudioReplSessionRuntime(sessionName).runtime;
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
+ }
7475
+ }
7476
+
7477
+ return {
7478
+ runtime,
7479
+ usedControlFile: false,
7480
+ submissionText: normalizedSource.replace(/\n+$/, ""),
7481
+ };
7482
+ }
7483
+
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 {
7501
+ if (!/^[-_.A-Za-z0-9]+$/.test(sessionName)) return { ok: false, message: "Invalid REPL session name." };
7502
+ const source = String(text || "");
7503
+ if (!source.trim()) return { ok: false, message: "Editor text is empty." };
7504
+ if (source.length > STUDIO_REPL_SEND_MAX_CHARS) {
7505
+ return { ok: false, message: `REPL input is too large (${source.length} chars; max ${STUDIO_REPL_SEND_MAX_CHARS}).` };
7506
+ }
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);
7545
+ }
7546
+
7547
+ function interruptStudioReplSession(sessionName: string): { ok: true; message: string } | { ok: false; message: string } {
7548
+ if (!/^[-_.A-Za-z0-9]+$/.test(sessionName)) return { ok: false, message: "Invalid REPL session name." };
7549
+ const result = runStudioTmux(["send-keys", "-t", getStudioReplPaneTarget(sessionName), "C-c"], { timeout: 5_000 });
7550
+ if (!result.ok) return { ok: false, message: result.message || "Failed to interrupt REPL session." };
7551
+ return { ok: true, message: `Interrupted ${sessionName}.` };
7552
+ }
7553
+
6935
7554
  function isAllowedOrigin(_origin: string | undefined, _port: number): boolean {
6936
7555
  // For local-only studio, token auth is the primary guard. In practice,
6937
7556
  // browser origin headers can vary (or be omitted) across wrappers/browsers,
@@ -7422,6 +8041,11 @@ ${cssVarsBlock}
7422
8041
  <div class="source-actions-row">
7423
8042
  <button id="sendRunBtn" type="button" title="Run editor text. While a direct run is active, this button becomes Stop. Cmd/Ctrl+Enter queues steering from the current editor text. Stop the active request with Esc.">Run editor text</button>
7424
8043
  <button id="queueSteerBtn" type="button" title="Queue steering is available while Run editor text is active." disabled>Queue steering</button>
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>
8045
+ <select id="replSendModeSelect" hidden aria-label="REPL send mode" title="Choose how Send to REPL interprets the editor text.">
8046
+ <option value="raw" selected>Send mode: Raw</option>
8047
+ <option value="literate">Send mode: Literate</option>
8048
+ </select>
7425
8049
  <button id="copyDraftBtn" type="button" title="Copy the current editor text to the clipboard.">Copy text</button>
7426
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>
7427
8051
  <button id="sendEditorBtn" type="button">Send to pi editor</button>
@@ -7564,6 +8188,7 @@ ${cssVarsBlock}
7564
8188
  <option value="preview" selected>Response (Preview)</option>
7565
8189
  <option value="editor-preview">Editor (Preview)</option>
7566
8190
  <option value="trace">Working</option>
8191
+ <option value="repl">REPL</option>
7567
8192
  </select>
7568
8193
  </div>
7569
8194
  <div class="section-header-actions">
@@ -7701,9 +8326,168 @@ export default function (pi: ExtensionAPI) {
7701
8326
  contextWindow: null,
7702
8327
  percent: null,
7703
8328
  };
8329
+ let studioReplActiveSessionName: string | null = null;
7704
8330
  let compactInProgress = false;
7705
8331
  let compactRequestId: string | null = null;
7706
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
+
7707
8491
  const isStudioDirectRunChainActive = () => Boolean(studioDirectRunChain);
7708
8492
  const getQueuedStudioSteeringCount = () => queuedStudioDirectRequests.length;
7709
8493
  const getStudioClientCounts = (): { full: number; editorOnly: number } => {
@@ -8136,6 +8920,57 @@ export default function (pi: ExtensionAPI) {
8136
8920
  }
8137
8921
  };
8138
8922
 
8923
+ const sendReplStateToClient = (client: WebSocket, extra?: Record<string, unknown>) => {
8924
+ const state = listStudioReplSessions();
8925
+ if (studioReplActiveSessionName && !state.sessions.some((session) => session.sessionName === studioReplActiveSessionName)) {
8926
+ studioReplActiveSessionName = state.sessions[0]?.sessionName ?? null;
8927
+ } else if (!studioReplActiveSessionName && state.sessions.length > 0) {
8928
+ studioReplActiveSessionName = state.sessions[0].sessionName;
8929
+ }
8930
+ sendToClient(client, {
8931
+ type: "repl_state",
8932
+ tmuxAvailable: state.tmuxAvailable,
8933
+ sessions: state.sessions,
8934
+ activeSessionName: studioReplActiveSessionName,
8935
+ error: state.error ?? null,
8936
+ ...extra,
8937
+ });
8938
+ };
8939
+
8940
+ const sendReplCaptureToClient = (client: WebSocket, sessionName?: string | null, extra?: Record<string, unknown>) => {
8941
+ const targetSession = (typeof sessionName === "string" && sessionName.trim())
8942
+ ? sessionName.trim()
8943
+ : studioReplActiveSessionName;
8944
+ if (!targetSession) {
8945
+ sendReplStateToClient(client, {
8946
+ transcript: "",
8947
+ capturedAt: Date.now(),
8948
+ ...extra,
8949
+ });
8950
+ return;
8951
+ }
8952
+ const captured = captureStudioReplSession(targetSession);
8953
+ if (!captured.ok) {
8954
+ sendReplStateToClient(client, {
8955
+ activeSessionName: targetSession,
8956
+ transcript: "",
8957
+ captureError: captured.message,
8958
+ capturedAt: Date.now(),
8959
+ ...extra,
8960
+ });
8961
+ return;
8962
+ }
8963
+ studioReplActiveSessionName = captured.session.sessionName;
8964
+ sendToClient(client, {
8965
+ type: "repl_capture",
8966
+ session: captured.session,
8967
+ activeSessionName: captured.session.sessionName,
8968
+ transcript: captured.transcript,
8969
+ capturedAt: Date.now(),
8970
+ ...extra,
8971
+ });
8972
+ };
8973
+
8139
8974
  const emitDebugEvent = (event: string, details?: Record<string, unknown>) => {
8140
8975
  broadcast({
8141
8976
  type: "debug_event",
@@ -8960,6 +9795,82 @@ export default function (pi: ExtensionAPI) {
8960
9795
  return;
8961
9796
  }
8962
9797
 
9798
+ if (msg.type === "repl_list_request") {
9799
+ sendReplStateToClient(client);
9800
+ return;
9801
+ }
9802
+
9803
+ if (msg.type === "repl_capture_request") {
9804
+ sendReplCaptureToClient(client, msg.sessionName ?? null);
9805
+ return;
9806
+ }
9807
+
9808
+ if (msg.type === "repl_start_request") {
9809
+ if (!isValidRequestId(msg.requestId)) {
9810
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
9811
+ return;
9812
+ }
9813
+ const started = startStudioReplSession(msg.runtime, studioCwd, { newSession: msg.newSession });
9814
+ if (!started.ok) {
9815
+ sendReplStateToClient(client, { requestId: msg.requestId, replError: started.message });
9816
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: started.message });
9817
+ return;
9818
+ }
9819
+ studioReplActiveSessionName = started.session.sessionName;
9820
+ sendReplStateToClient(client, { requestId: msg.requestId, replMessage: started.message });
9821
+ sendReplCaptureToClient(client, started.session.sessionName, { requestId: msg.requestId, replMessage: started.message });
9822
+ return;
9823
+ }
9824
+
9825
+ if (msg.type === "repl_stop_request") {
9826
+ if (!isValidRequestId(msg.requestId)) {
9827
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
9828
+ return;
9829
+ }
9830
+ const stopped = stopStudioReplSession(msg.sessionName);
9831
+ if (!stopped.ok) {
9832
+ sendReplStateToClient(client, { requestId: msg.requestId, replError: stopped.message });
9833
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: stopped.message });
9834
+ return;
9835
+ }
9836
+ if (studioReplActiveSessionName === msg.sessionName) studioReplActiveSessionName = null;
9837
+ sendReplStateToClient(client, { requestId: msg.requestId, replMessage: stopped.message, transcript: "", capturedAt: Date.now() });
9838
+ return;
9839
+ }
9840
+
9841
+ if (msg.type === "repl_send_request") {
9842
+ if (!isValidRequestId(msg.requestId)) {
9843
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
9844
+ return;
9845
+ }
9846
+ const sent = sendTextToStudioReplSession(msg.sessionName, msg.text);
9847
+ if (!sent.ok) {
9848
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: sent.message });
9849
+ sendReplCaptureToClient(client, msg.sessionName, { requestId: msg.requestId, replError: sent.message });
9850
+ return;
9851
+ }
9852
+ studioReplActiveSessionName = msg.sessionName;
9853
+ sendToClient(client, { type: "repl_send_ack", requestId: msg.requestId, sessionName: msg.sessionName, message: sent.message });
9854
+ setTimeout(() => sendReplCaptureToClient(client, msg.sessionName, { requestId: msg.requestId }), 150);
9855
+ return;
9856
+ }
9857
+
9858
+ if (msg.type === "repl_interrupt_request") {
9859
+ if (!isValidRequestId(msg.requestId)) {
9860
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
9861
+ return;
9862
+ }
9863
+ const interrupted = interruptStudioReplSession(msg.sessionName);
9864
+ if (!interrupted.ok) {
9865
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: interrupted.message });
9866
+ sendReplCaptureToClient(client, msg.sessionName, { requestId: msg.requestId, replError: interrupted.message });
9867
+ return;
9868
+ }
9869
+ studioReplActiveSessionName = msg.sessionName;
9870
+ sendReplCaptureToClient(client, msg.sessionName, { requestId: msg.requestId, replMessage: interrupted.message });
9871
+ return;
9872
+ }
9873
+
8963
9874
  if (msg.type === "compact_request") {
8964
9875
  if (!isValidRequestId(msg.requestId)) {
8965
9876
  sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });