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/CHANGELOG.md +33 -0
- package/README.md +3 -1
- package/client/studio-client.js +1583 -34
- package/client/studio.css +364 -0
- package/index.ts +913 -2
- package/package.json +5 -4
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." });
|