pi-studio 0.8.4 → 0.9.0

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
@@ -35,6 +35,7 @@ type StudioSourceKind = "file" | "last-response" | "blank";
35
35
  type TerminalActivityPhase = "idle" | "running" | "tool" | "responding";
36
36
  type StudioPromptMode = "response" | "run" | "effective";
37
37
  type StudioPromptTriggerKind = "run" | "steer";
38
+ type StudioReplRuntime = "shell" | "python" | "ipython" | "julia" | "r" | "ghci" | "clojure";
38
39
 
39
40
  const STUDIO_CSS_URL = new URL("./client/studio.css", import.meta.url);
40
41
  const STUDIO_ANNOTATION_HELPERS_URL = new URL("./client/studio-annotation-helpers.js", import.meta.url);
@@ -111,6 +112,14 @@ interface StudioContextUsageSnapshot {
111
112
  percent: number | null;
112
113
  }
113
114
 
115
+ interface StudioReplSessionInfo {
116
+ sessionName: string;
117
+ target: string;
118
+ runtime: StudioReplRuntime | "unknown";
119
+ label: string;
120
+ source: "studio" | "pi-repl" | "tmux";
121
+ }
122
+
114
123
  interface PreparedStudioPdfExport {
115
124
  pdf: Buffer;
116
125
  filename: string;
@@ -267,6 +276,41 @@ interface SendRunRequestMessage {
267
276
  text: string;
268
277
  }
269
278
 
279
+ interface ReplListRequestMessage {
280
+ type: "repl_list_request";
281
+ }
282
+
283
+ interface ReplCaptureRequestMessage {
284
+ type: "repl_capture_request";
285
+ sessionName?: string;
286
+ }
287
+
288
+ interface ReplStartRequestMessage {
289
+ type: "repl_start_request";
290
+ requestId: string;
291
+ runtime: StudioReplRuntime;
292
+ newSession?: boolean;
293
+ }
294
+
295
+ interface ReplStopRequestMessage {
296
+ type: "repl_stop_request";
297
+ requestId: string;
298
+ sessionName: string;
299
+ }
300
+
301
+ interface ReplSendRequestMessage {
302
+ type: "repl_send_request";
303
+ requestId: string;
304
+ sessionName: string;
305
+ text: string;
306
+ }
307
+
308
+ interface ReplInterruptRequestMessage {
309
+ type: "repl_interrupt_request";
310
+ requestId: string;
311
+ sessionName: string;
312
+ }
313
+
270
314
  interface CompactRequestMessage {
271
315
  type: "compact_request";
272
316
  requestId: string;
@@ -331,6 +375,12 @@ type IncomingStudioMessage =
331
375
  | CritiqueRequestMessage
332
376
  | AnnotationRequestMessage
333
377
  | SendRunRequestMessage
378
+ | ReplListRequestMessage
379
+ | ReplCaptureRequestMessage
380
+ | ReplStartRequestMessage
381
+ | ReplStopRequestMessage
382
+ | ReplSendRequestMessage
383
+ | ReplInterruptRequestMessage
334
384
  | CompactRequestMessage
335
385
  | SaveAsRequestMessage
336
386
  | SaveOverRequestMessage
@@ -359,6 +409,17 @@ const STUDIO_TRACE_IMAGE_MAX_BASE64_CHARS = 2_500_000;
359
409
  const STUDIO_TRACE_SNAPSHOT_MAX_IMAGES = 12;
360
410
  const STUDIO_TRACE_SNAPSHOT_MAX_IMAGE_BASE64_CHARS = 6_000_000;
361
411
  const STUDIO_TRACE_IMAGE_SAFE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
412
+ const STUDIO_REPL_CAPTURE_LINES = 800;
413
+ const STUDIO_REPL_SEND_MAX_CHARS = 200_000;
414
+ const STUDIO_REPL_RUNTIME_LABELS: Record<StudioReplRuntime, string> = {
415
+ shell: "Shell",
416
+ python: "Python",
417
+ ipython: "IPython",
418
+ julia: "Julia",
419
+ r: "R",
420
+ ghci: "GHCi",
421
+ clojure: "Clojure",
422
+ };
362
423
  const MAX_STUDIO_TRACE_SNAPSHOTS = RESPONSE_HISTORY_LIMIT;
363
424
  const TRANSIENT_STUDIO_DOCUMENT_TTL_MS = 30 * 60 * 1000;
364
425
  const MAX_TRANSIENT_STUDIO_DOCUMENTS = 16;
@@ -6411,6 +6472,54 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
6411
6472
  };
6412
6473
  }
6413
6474
 
6475
+ if (msg.type === "repl_list_request") {
6476
+ return { type: "repl_list_request" };
6477
+ }
6478
+
6479
+ if (msg.type === "repl_capture_request") {
6480
+ return {
6481
+ type: "repl_capture_request",
6482
+ sessionName: typeof msg.sessionName === "string" ? msg.sessionName : undefined,
6483
+ };
6484
+ }
6485
+
6486
+ if (msg.type === "repl_start_request" && typeof msg.requestId === "string") {
6487
+ const runtime = normalizeStudioReplRuntime(msg.runtime);
6488
+ if (runtime) {
6489
+ return {
6490
+ type: "repl_start_request",
6491
+ requestId: msg.requestId,
6492
+ runtime,
6493
+ newSession: Boolean(msg.newSession),
6494
+ };
6495
+ }
6496
+ }
6497
+
6498
+ if (msg.type === "repl_stop_request" && typeof msg.requestId === "string" && typeof msg.sessionName === "string") {
6499
+ return {
6500
+ type: "repl_stop_request",
6501
+ requestId: msg.requestId,
6502
+ sessionName: msg.sessionName,
6503
+ };
6504
+ }
6505
+
6506
+ if (msg.type === "repl_send_request" && typeof msg.requestId === "string" && typeof msg.sessionName === "string" && typeof msg.text === "string") {
6507
+ return {
6508
+ type: "repl_send_request",
6509
+ requestId: msg.requestId,
6510
+ sessionName: msg.sessionName,
6511
+ text: msg.text,
6512
+ };
6513
+ }
6514
+
6515
+ if (msg.type === "repl_interrupt_request" && typeof msg.requestId === "string" && typeof msg.sessionName === "string") {
6516
+ return {
6517
+ type: "repl_interrupt_request",
6518
+ requestId: msg.requestId,
6519
+ sessionName: msg.sessionName,
6520
+ };
6521
+ }
6522
+
6414
6523
  if (
6415
6524
  msg.type === "compact_request" &&
6416
6525
  typeof msg.requestId === "string" &&
@@ -6932,6 +7041,218 @@ function summarizeStudioTraceToolArgs(toolName: string, args: unknown): string |
6932
7041
  }
6933
7042
  }
6934
7043
 
7044
+ function isStudioReplRuntime(value: unknown): value is StudioReplRuntime {
7045
+ return value === "shell"
7046
+ || value === "python"
7047
+ || value === "ipython"
7048
+ || value === "julia"
7049
+ || value === "r"
7050
+ || value === "ghci"
7051
+ || value === "clojure";
7052
+ }
7053
+
7054
+ function normalizeStudioReplRuntime(value: unknown): StudioReplRuntime | null {
7055
+ const normalized = String(value || "").trim().toLowerCase();
7056
+ if (normalized === "r") return "r";
7057
+ return isStudioReplRuntime(normalized) ? normalized : null;
7058
+ }
7059
+
7060
+ function getStudioReplRuntimeCommand(runtime: StudioReplRuntime): string {
7061
+ if (runtime === "shell") return String(process.env.SHELL || "bash").trim() || "bash";
7062
+ if (runtime === "python") return "python3";
7063
+ if (runtime === "ipython") return "ipython";
7064
+ if (runtime === "julia") return "julia";
7065
+ if (runtime === "r") return "R";
7066
+ if (runtime === "ghci") return "ghci";
7067
+ return "clojure";
7068
+ }
7069
+
7070
+ function getStudioReplSessionName(runtime: StudioReplRuntime): string {
7071
+ return `pi-studio-repl-${runtime}`;
7072
+ }
7073
+
7074
+ function getNewStudioReplSessionName(runtime: StudioReplRuntime): string {
7075
+ const suffix = `${Date.now().toString(36)}-${randomUUID().slice(0, 6)}`;
7076
+ return `pi-studio-repl-${runtime}-${suffix}`;
7077
+ }
7078
+
7079
+ function getStudioReplPaneTarget(sessionName: string): string {
7080
+ return `${sessionName}:0.0`;
7081
+ }
7082
+
7083
+ function inferStudioReplSessionRuntime(sessionName: string): { runtime: StudioReplRuntime | "unknown"; source: StudioReplSessionInfo["source"] } {
7084
+ const studioMatch = sessionName.match(/^pi-studio-repl-([a-z0-9-]+)$/i);
7085
+ if (studioMatch) {
7086
+ const raw = (studioMatch[1] || "").toLowerCase();
7087
+ const runtime = (["clojure", "python", "ipython", "julia", "shell", "ghci", "r"] as StudioReplRuntime[])
7088
+ .find((candidate) => raw === candidate || raw.startsWith(`${candidate}-`));
7089
+ return { runtime: runtime ?? "unknown", source: "studio" };
7090
+ }
7091
+ const piReplMatch = sessionName.match(/^pi-repl-([a-z0-9-]+)$/i);
7092
+ if (piReplMatch) {
7093
+ const raw = piReplMatch[1]?.toLowerCase() || "";
7094
+ const runtime = raw === "python" ? "python" : normalizeStudioReplRuntime(raw);
7095
+ return { runtime: runtime ?? "unknown", source: "pi-repl" };
7096
+ }
7097
+ return { runtime: "unknown", source: "tmux" };
7098
+ }
7099
+
7100
+ function shouldShowStudioReplTmuxSession(sessionName: string): boolean {
7101
+ return /^pi-studio-repl-/i.test(sessionName) || /^pi-repl-/i.test(sessionName);
7102
+ }
7103
+
7104
+ function formatStudioReplSessionLabel(sessionName: string, runtime: StudioReplRuntime | "unknown", source: StudioReplSessionInfo["source"]): string {
7105
+ const runtimeLabel = runtime === "unknown" ? "REPL" : STUDIO_REPL_RUNTIME_LABELS[runtime];
7106
+ if (source === "pi-repl") return `${runtimeLabel} (${sessionName})`;
7107
+ if (source === "studio") return `${runtimeLabel} (${sessionName})`;
7108
+ return sessionName;
7109
+ }
7110
+
7111
+ function isTmuxAvailable(): boolean {
7112
+ const result = spawnSync("tmux", ["-V"], { encoding: "utf8", timeout: 3_000 });
7113
+ return result.status === 0;
7114
+ }
7115
+
7116
+ 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 } {
7117
+ const result = spawnSync("tmux", args, {
7118
+ cwd: options?.cwd,
7119
+ input: options?.input,
7120
+ encoding: "utf8",
7121
+ timeout: options?.timeout ?? 5_000,
7122
+ maxBuffer: 10 * 1024 * 1024,
7123
+ });
7124
+ const stdout = typeof result.stdout === "string" ? result.stdout : "";
7125
+ const stderr = typeof result.stderr === "string" ? result.stderr : "";
7126
+ if (result.error) {
7127
+ const message = result.error.message || String(result.error);
7128
+ return { ok: false, message, stdout, stderr };
7129
+ }
7130
+ if (result.status !== 0) {
7131
+ return { ok: false, message: (stderr || stdout || `tmux exited with code ${result.status}`).trim(), stdout, stderr };
7132
+ }
7133
+ return { ok: true, stdout, stderr };
7134
+ }
7135
+
7136
+ function listStudioReplSessions(): { tmuxAvailable: boolean; sessions: StudioReplSessionInfo[]; error?: string } {
7137
+ if (!isTmuxAvailable()) return { tmuxAvailable: false, sessions: [], error: "tmux is not available." };
7138
+ const result = runStudioTmux(["list-sessions", "-F", "#{session_name}"], { timeout: 3_000 });
7139
+ if (!result.ok) {
7140
+ const message = result.message.toLowerCase().includes("no server running") ? "No tmux sessions are running." : result.message;
7141
+ return { tmuxAvailable: true, sessions: [], error: message };
7142
+ }
7143
+ const sessions = result.stdout
7144
+ .split(/\r?\n/)
7145
+ .map((line) => line.trim())
7146
+ .filter(Boolean)
7147
+ .filter(shouldShowStudioReplTmuxSession)
7148
+ .map((sessionName) => {
7149
+ const inferred = inferStudioReplSessionRuntime(sessionName);
7150
+ return {
7151
+ sessionName,
7152
+ target: getStudioReplPaneTarget(sessionName),
7153
+ runtime: inferred.runtime,
7154
+ label: formatStudioReplSessionLabel(sessionName, inferred.runtime, inferred.source),
7155
+ source: inferred.source,
7156
+ };
7157
+ });
7158
+ return { tmuxAvailable: true, sessions };
7159
+ }
7160
+
7161
+ function captureStudioReplSession(sessionName: string): { ok: true; transcript: string; session: StudioReplSessionInfo } | { ok: false; message: string } {
7162
+ if (!/^[-_.A-Za-z0-9]+$/.test(sessionName)) return { ok: false, message: "Invalid REPL session name." };
7163
+ const inferred = inferStudioReplSessionRuntime(sessionName);
7164
+ const session: StudioReplSessionInfo = {
7165
+ sessionName,
7166
+ target: getStudioReplPaneTarget(sessionName),
7167
+ runtime: inferred.runtime,
7168
+ label: formatStudioReplSessionLabel(sessionName, inferred.runtime, inferred.source),
7169
+ source: inferred.source,
7170
+ };
7171
+ const result = runStudioTmux(["capture-pane", "-p", "-t", session.target, "-S", `-${STUDIO_REPL_CAPTURE_LINES}`], { timeout: 3_000 });
7172
+ if (!result.ok) return { ok: false, message: result.message };
7173
+ return { ok: true, transcript: result.stdout.replace(/[\t ]+$/gm, "").trimEnd(), session };
7174
+ }
7175
+
7176
+ function startStudioReplSession(runtime: StudioReplRuntime, cwd: string, options?: { newSession?: boolean }): { ok: true; session: StudioReplSessionInfo; message: string } | { ok: false; message: string } {
7177
+ if (!isTmuxAvailable()) return { ok: false, message: "tmux is not available. Install tmux to use Studio REPL sessions." };
7178
+ const sessionName = options?.newSession ? getNewStudioReplSessionName(runtime) : getStudioReplSessionName(runtime);
7179
+ const existing = runStudioTmux(["has-session", "-t", sessionName], { timeout: 3_000 });
7180
+ if (existing.ok) {
7181
+ const inferred = inferStudioReplSessionRuntime(sessionName);
7182
+ return {
7183
+ ok: true,
7184
+ session: {
7185
+ sessionName,
7186
+ target: getStudioReplPaneTarget(sessionName),
7187
+ runtime: inferred.runtime,
7188
+ label: formatStudioReplSessionLabel(sessionName, inferred.runtime, inferred.source),
7189
+ source: inferred.source,
7190
+ },
7191
+ message: `${STUDIO_REPL_RUNTIME_LABELS[runtime]} REPL is already running.`,
7192
+ };
7193
+ }
7194
+ const command = getStudioReplRuntimeCommand(runtime);
7195
+ const result = runStudioTmux(["new-session", "-d", "-s", sessionName, "-c", cwd || process.cwd(), command], { timeout: 5_000 });
7196
+ if (!result.ok) return { ok: false, message: result.message || `Failed to start ${STUDIO_REPL_RUNTIME_LABELS[runtime]} REPL.` };
7197
+ return {
7198
+ ok: true,
7199
+ session: {
7200
+ sessionName,
7201
+ target: getStudioReplPaneTarget(sessionName),
7202
+ runtime,
7203
+ label: formatStudioReplSessionLabel(sessionName, runtime, "studio"),
7204
+ source: "studio",
7205
+ },
7206
+ message: `Started ${options?.newSession ? "new " : ""}${STUDIO_REPL_RUNTIME_LABELS[runtime]} REPL.`,
7207
+ };
7208
+ }
7209
+
7210
+ function stopStudioReplSession(sessionName: string): { ok: true; message: string } | { ok: false; message: string } {
7211
+ if (!/^[-_.A-Za-z0-9]+$/.test(sessionName)) return { ok: false, message: "Invalid REPL session name." };
7212
+ const inferred = inferStudioReplSessionRuntime(sessionName);
7213
+ if (inferred.source !== "studio") {
7214
+ return { ok: false, message: "Studio can only stop Studio-owned REPL sessions. Use tmux or pi-repl to stop external sessions." };
7215
+ }
7216
+ const result = runStudioTmux(["kill-session", "-t", sessionName], { timeout: 5_000 });
7217
+ if (!result.ok) return { ok: false, message: result.message || "Failed to stop REPL session." };
7218
+ return { ok: true, message: `Stopped ${sessionName}.` };
7219
+ }
7220
+
7221
+ function prepareTextForStudioReplSend(sessionName: string, source: string): string {
7222
+ const normalized = String(source || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
7223
+ 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`;
7229
+ }
7230
+ return normalized.endsWith("\n") ? normalized : `${normalized}\n`;
7231
+ }
7232
+
7233
+ function sendTextToStudioReplSession(sessionName: string, text: string): { ok: true; message: string } | { ok: false; message: string } {
7234
+ if (!/^[-_.A-Za-z0-9]+$/.test(sessionName)) return { ok: false, message: "Invalid REPL session name." };
7235
+ const source = String(text || "");
7236
+ if (!source.trim()) return { ok: false, message: "Editor text is empty." };
7237
+ if (source.length > STUDIO_REPL_SEND_MAX_CHARS) {
7238
+ return { ok: false, message: `REPL input is too large (${source.length} chars; max ${STUDIO_REPL_SEND_MAX_CHARS}).` };
7239
+ }
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}.` };
7247
+ }
7248
+
7249
+ function interruptStudioReplSession(sessionName: string): { ok: true; message: string } | { ok: false; message: string } {
7250
+ if (!/^[-_.A-Za-z0-9]+$/.test(sessionName)) return { ok: false, message: "Invalid REPL session name." };
7251
+ const result = runStudioTmux(["send-keys", "-t", getStudioReplPaneTarget(sessionName), "C-c"], { timeout: 5_000 });
7252
+ if (!result.ok) return { ok: false, message: result.message || "Failed to interrupt REPL session." };
7253
+ return { ok: true, message: `Interrupted ${sessionName}.` };
7254
+ }
7255
+
6935
7256
  function isAllowedOrigin(_origin: string | undefined, _port: number): boolean {
6936
7257
  // For local-only studio, token auth is the primary guard. In practice,
6937
7258
  // browser origin headers can vary (or be omitted) across wrappers/browsers,
@@ -7422,6 +7743,11 @@ ${cssVarsBlock}
7422
7743
  <div class="source-actions-row">
7423
7744
  <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
7745
  <button id="queueSteerBtn" type="button" title="Queue steering is available while Run editor text is active." disabled>Queue steering</button>
7746
+ <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
+ <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>
7750
+ </select>
7425
7751
  <button id="copyDraftBtn" type="button" title="Copy the current editor text to the clipboard.">Copy text</button>
7426
7752
  <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
7753
  <button id="sendEditorBtn" type="button">Send to pi editor</button>
@@ -7564,6 +7890,7 @@ ${cssVarsBlock}
7564
7890
  <option value="preview" selected>Response (Preview)</option>
7565
7891
  <option value="editor-preview">Editor (Preview)</option>
7566
7892
  <option value="trace">Working</option>
7893
+ <option value="repl">REPL</option>
7567
7894
  </select>
7568
7895
  </div>
7569
7896
  <div class="section-header-actions">
@@ -7701,6 +8028,7 @@ export default function (pi: ExtensionAPI) {
7701
8028
  contextWindow: null,
7702
8029
  percent: null,
7703
8030
  };
8031
+ let studioReplActiveSessionName: string | null = null;
7704
8032
  let compactInProgress = false;
7705
8033
  let compactRequestId: string | null = null;
7706
8034
 
@@ -8136,6 +8464,57 @@ export default function (pi: ExtensionAPI) {
8136
8464
  }
8137
8465
  };
8138
8466
 
8467
+ const sendReplStateToClient = (client: WebSocket, extra?: Record<string, unknown>) => {
8468
+ const state = listStudioReplSessions();
8469
+ if (studioReplActiveSessionName && !state.sessions.some((session) => session.sessionName === studioReplActiveSessionName)) {
8470
+ studioReplActiveSessionName = state.sessions[0]?.sessionName ?? null;
8471
+ } else if (!studioReplActiveSessionName && state.sessions.length > 0) {
8472
+ studioReplActiveSessionName = state.sessions[0].sessionName;
8473
+ }
8474
+ sendToClient(client, {
8475
+ type: "repl_state",
8476
+ tmuxAvailable: state.tmuxAvailable,
8477
+ sessions: state.sessions,
8478
+ activeSessionName: studioReplActiveSessionName,
8479
+ error: state.error ?? null,
8480
+ ...extra,
8481
+ });
8482
+ };
8483
+
8484
+ const sendReplCaptureToClient = (client: WebSocket, sessionName?: string | null, extra?: Record<string, unknown>) => {
8485
+ const targetSession = (typeof sessionName === "string" && sessionName.trim())
8486
+ ? sessionName.trim()
8487
+ : studioReplActiveSessionName;
8488
+ if (!targetSession) {
8489
+ sendReplStateToClient(client, {
8490
+ transcript: "",
8491
+ capturedAt: Date.now(),
8492
+ ...extra,
8493
+ });
8494
+ return;
8495
+ }
8496
+ const captured = captureStudioReplSession(targetSession);
8497
+ if (!captured.ok) {
8498
+ sendReplStateToClient(client, {
8499
+ activeSessionName: targetSession,
8500
+ transcript: "",
8501
+ captureError: captured.message,
8502
+ capturedAt: Date.now(),
8503
+ ...extra,
8504
+ });
8505
+ return;
8506
+ }
8507
+ studioReplActiveSessionName = captured.session.sessionName;
8508
+ sendToClient(client, {
8509
+ type: "repl_capture",
8510
+ session: captured.session,
8511
+ activeSessionName: captured.session.sessionName,
8512
+ transcript: captured.transcript,
8513
+ capturedAt: Date.now(),
8514
+ ...extra,
8515
+ });
8516
+ };
8517
+
8139
8518
  const emitDebugEvent = (event: string, details?: Record<string, unknown>) => {
8140
8519
  broadcast({
8141
8520
  type: "debug_event",
@@ -8960,6 +9339,82 @@ export default function (pi: ExtensionAPI) {
8960
9339
  return;
8961
9340
  }
8962
9341
 
9342
+ if (msg.type === "repl_list_request") {
9343
+ sendReplStateToClient(client);
9344
+ return;
9345
+ }
9346
+
9347
+ if (msg.type === "repl_capture_request") {
9348
+ sendReplCaptureToClient(client, msg.sessionName ?? null);
9349
+ return;
9350
+ }
9351
+
9352
+ if (msg.type === "repl_start_request") {
9353
+ if (!isValidRequestId(msg.requestId)) {
9354
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
9355
+ return;
9356
+ }
9357
+ const started = startStudioReplSession(msg.runtime, studioCwd, { newSession: msg.newSession });
9358
+ if (!started.ok) {
9359
+ sendReplStateToClient(client, { requestId: msg.requestId, replError: started.message });
9360
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: started.message });
9361
+ return;
9362
+ }
9363
+ studioReplActiveSessionName = started.session.sessionName;
9364
+ sendReplStateToClient(client, { requestId: msg.requestId, replMessage: started.message });
9365
+ sendReplCaptureToClient(client, started.session.sessionName, { requestId: msg.requestId, replMessage: started.message });
9366
+ return;
9367
+ }
9368
+
9369
+ if (msg.type === "repl_stop_request") {
9370
+ if (!isValidRequestId(msg.requestId)) {
9371
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
9372
+ return;
9373
+ }
9374
+ const stopped = stopStudioReplSession(msg.sessionName);
9375
+ if (!stopped.ok) {
9376
+ sendReplStateToClient(client, { requestId: msg.requestId, replError: stopped.message });
9377
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: stopped.message });
9378
+ return;
9379
+ }
9380
+ if (studioReplActiveSessionName === msg.sessionName) studioReplActiveSessionName = null;
9381
+ sendReplStateToClient(client, { requestId: msg.requestId, replMessage: stopped.message, transcript: "", capturedAt: Date.now() });
9382
+ return;
9383
+ }
9384
+
9385
+ if (msg.type === "repl_send_request") {
9386
+ if (!isValidRequestId(msg.requestId)) {
9387
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
9388
+ return;
9389
+ }
9390
+ const sent = sendTextToStudioReplSession(msg.sessionName, msg.text);
9391
+ if (!sent.ok) {
9392
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: sent.message });
9393
+ sendReplCaptureToClient(client, msg.sessionName, { requestId: msg.requestId, replError: sent.message });
9394
+ return;
9395
+ }
9396
+ studioReplActiveSessionName = msg.sessionName;
9397
+ sendToClient(client, { type: "repl_send_ack", requestId: msg.requestId, sessionName: msg.sessionName, message: sent.message });
9398
+ setTimeout(() => sendReplCaptureToClient(client, msg.sessionName, { requestId: msg.requestId }), 150);
9399
+ return;
9400
+ }
9401
+
9402
+ if (msg.type === "repl_interrupt_request") {
9403
+ if (!isValidRequestId(msg.requestId)) {
9404
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
9405
+ return;
9406
+ }
9407
+ const interrupted = interruptStudioReplSession(msg.sessionName);
9408
+ if (!interrupted.ok) {
9409
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: interrupted.message });
9410
+ sendReplCaptureToClient(client, msg.sessionName, { requestId: msg.requestId, replError: interrupted.message });
9411
+ return;
9412
+ }
9413
+ studioReplActiveSessionName = msg.sessionName;
9414
+ sendReplCaptureToClient(client, msg.sessionName, { requestId: msg.requestId, replMessage: interrupted.message });
9415
+ return;
9416
+ }
9417
+
8963
9418
  if (msg.type === "compact_request") {
8964
9419
  if (!isValidRequestId(msg.requestId)) {
8965
9420
  sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.8.4",
4
- "description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, prompt/response history, and live Markdown/LaTeX/code/interactive HTML preview",
3
+ "version": "0.9.0",
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",
7
7
  "repository": {