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