pi-studio 0.10.1 → 0.10.2
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 +22 -0
- package/README.md +13 -18
- package/client/studio-client.js +237 -67
- package/index.ts +484 -270
- package/package.json +1 -1
- package/shared/repl-gnuplot-processes.js +180 -0
- package/shared/repl-session-stop.js +285 -0
- package/shared/studio-browser-launcher.js +43 -4
- package/shared/studio-buffer-client.js +3 -1
- package/shared/studio-document-open-client.js +3 -1
- package/shared/studio-repl-selection.js +96 -0
- package/shared/studio-repl-tmux.js +216 -1
package/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { buildSessionContext, createAgentSession, createAgentSessionFromServices
|
|
|
3
3
|
import { type ModelThinkingLevel, type ThinkingLevel } from "@earendil-works/pi-ai";
|
|
4
4
|
import { completeSimple } from "@earendil-works/pi-ai/compat";
|
|
5
5
|
import { Type } from "@sinclair/typebox";
|
|
6
|
-
import { spawn, spawnSync } from "node:child_process";
|
|
6
|
+
import { execFile, spawn, spawnSync } from "node:child_process";
|
|
7
7
|
import { createHash, randomUUID, randomBytes } from "node:crypto";
|
|
8
8
|
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
@@ -46,7 +46,7 @@ import { resolveStudioPdfResourceFile } from "./shared/studio-pdf-resource.js";
|
|
|
46
46
|
import { createStudioPandocHtmlResourceFlagResolver } from "./shared/studio-pandoc-resource-flag.js";
|
|
47
47
|
import { createStudioResourceGrantRegistry } from "./shared/studio-resource-grants.js";
|
|
48
48
|
import { prepareStudioLatexForPandoc } from "./shared/studio-latex-pandoc-compat.js";
|
|
49
|
-
import { isStudioCmuxSession, openStudioUrlInBrowser } from "./shared/studio-browser-launcher.js";
|
|
49
|
+
import { isStudioCmuxSession, openStudioPathInDefaultViewer, openStudioUrlInBrowser } from "./shared/studio-browser-launcher.js";
|
|
50
50
|
import {
|
|
51
51
|
buildStudioListenAllWarning,
|
|
52
52
|
isStudioWebSocketOriginAllowed,
|
|
@@ -54,7 +54,18 @@ import {
|
|
|
54
54
|
resolveStudioNetworkBinding,
|
|
55
55
|
STUDIO_ADVERTISED_HOST,
|
|
56
56
|
} from "./shared/studio-network-binding.js";
|
|
57
|
-
import {
|
|
57
|
+
import {
|
|
58
|
+
buildStudioReplTmuxStartArgs,
|
|
59
|
+
cleanStudioReplOutput,
|
|
60
|
+
firstStudioReplPanes,
|
|
61
|
+
buildStudioReplDeadPaneCloseArgs,
|
|
62
|
+
isStudioReplReadyLine,
|
|
63
|
+
STUDIO_REPL_PANE_FORMAT,
|
|
64
|
+
STUDIO_REPL_SESSION_NAME,
|
|
65
|
+
studioReplSessionTarget,
|
|
66
|
+
} from "./shared/studio-repl-tmux.js";
|
|
67
|
+
import { stopVerifiedReplSession } from "./shared/repl-session-stop.js";
|
|
68
|
+
import { createReplSelection, sameReplSessionLifetime } from "./shared/studio-repl-selection.js";
|
|
58
69
|
import {
|
|
59
70
|
REPL_SESSION_RECORD_ID_OPTION,
|
|
60
71
|
REPL_SESSION_RECORD_VERSION,
|
|
@@ -71,7 +82,6 @@ import {
|
|
|
71
82
|
import {
|
|
72
83
|
createReplSubmissionDisplay,
|
|
73
84
|
normalizeReplSubmissionEchoMode,
|
|
74
|
-
stripReplSubmissionDisplay,
|
|
75
85
|
} from "./shared/repl-submission-display.js";
|
|
76
86
|
import {
|
|
77
87
|
cleanupPrivateReplControlFiles,
|
|
@@ -800,6 +810,20 @@ interface ReplListRequestMessage {
|
|
|
800
810
|
interface ReplCaptureRequestMessage {
|
|
801
811
|
type: "repl_capture_request";
|
|
802
812
|
sessionName?: string;
|
|
813
|
+
// A choice on the page's Session or Runtime selector; without it, a poll.
|
|
814
|
+
choice?: boolean;
|
|
815
|
+
choiceRevision?: number;
|
|
816
|
+
runtime?: StudioReplRuntime;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// A selection the page made itself, against the selection it last adopted.
|
|
820
|
+
interface ReplReconcileRequestMessage {
|
|
821
|
+
type: "repl_reconcile_request";
|
|
822
|
+
basis: number;
|
|
823
|
+
sessionName?: string;
|
|
824
|
+
tmuxSessionId?: string;
|
|
825
|
+
tmuxSessionCreatedAt?: number;
|
|
826
|
+
runtime?: StudioReplRuntime;
|
|
803
827
|
}
|
|
804
828
|
|
|
805
829
|
interface ReplStartRequestMessage {
|
|
@@ -992,6 +1016,7 @@ type IncomingStudioMessage =
|
|
|
992
1016
|
| QuizDiscussRequestMessage
|
|
993
1017
|
| ReplListRequestMessage
|
|
994
1018
|
| ReplCaptureRequestMessage
|
|
1019
|
+
| ReplReconcileRequestMessage
|
|
995
1020
|
| ReplStartRequestMessage
|
|
996
1021
|
| ReplStopRequestMessage
|
|
997
1022
|
| ReplSendRequestMessage
|
|
@@ -1066,6 +1091,8 @@ const STUDIO_REPL_CAPTURE_LINES = 800;
|
|
|
1066
1091
|
const STUDIO_REPL_SEND_MAX_CHARS = 200_000;
|
|
1067
1092
|
const STUDIO_REPL_SEND_DEFAULT_TIMEOUT_MS = 20_000;
|
|
1068
1093
|
const STUDIO_REPL_SEND_MAX_TIMEOUT_MS = 120_000;
|
|
1094
|
+
const STUDIO_REPL_START_WAIT_MS = 20_000;
|
|
1095
|
+
const STUDIO_REPL_START_POLL_MS = 250;
|
|
1069
1096
|
const STUDIO_REPL_JOURNAL_MAX_ENTRIES = 300;
|
|
1070
1097
|
const STUDIO_REPL_RUNTIME_OPTION = "@pi_repl_runtime";
|
|
1071
1098
|
const STUDIO_SUBPROCESS_OUTPUT_MAX_BYTES = 2_000_000;
|
|
@@ -8695,24 +8722,7 @@ async function handleOpenLocalPreviewResourceRequest(
|
|
|
8695
8722
|
}
|
|
8696
8723
|
|
|
8697
8724
|
function openPathInDefaultViewer(path: string): Promise<void> {
|
|
8698
|
-
|
|
8699
|
-
process.platform === "darwin"
|
|
8700
|
-
? { command: "open", args: [path] }
|
|
8701
|
-
: process.platform === "win32"
|
|
8702
|
-
? { command: "cmd", args: ["/c", "start", "", path] }
|
|
8703
|
-
: { command: "xdg-open", args: [path] };
|
|
8704
|
-
|
|
8705
|
-
return new Promise<void>((resolve, reject) => {
|
|
8706
|
-
const child = spawn(openCommand.command, openCommand.args, {
|
|
8707
|
-
stdio: "ignore",
|
|
8708
|
-
detached: true,
|
|
8709
|
-
});
|
|
8710
|
-
child.once("error", reject);
|
|
8711
|
-
child.once("spawn", () => {
|
|
8712
|
-
child.unref();
|
|
8713
|
-
resolve();
|
|
8714
|
-
});
|
|
8715
|
-
});
|
|
8725
|
+
return openStudioPathInDefaultViewer(path);
|
|
8716
8726
|
}
|
|
8717
8727
|
|
|
8718
8728
|
async function handleOpenStudioFileBrowserDirectoryRequest(
|
|
@@ -10336,6 +10346,20 @@ function parseIncomingMessage(data: RawData, captureEnvelope?: (message: Record<
|
|
|
10336
10346
|
return {
|
|
10337
10347
|
type: "repl_capture_request",
|
|
10338
10348
|
sessionName: typeof msg.sessionName === "string" ? msg.sessionName : undefined,
|
|
10349
|
+
choice: msg.choice === true,
|
|
10350
|
+
choiceRevision: typeof msg.choiceRevision === "number" && Number.isSafeInteger(msg.choiceRevision) ? msg.choiceRevision : undefined,
|
|
10351
|
+
runtime: normalizeStudioReplRuntime(msg.runtime) || undefined,
|
|
10352
|
+
};
|
|
10353
|
+
}
|
|
10354
|
+
|
|
10355
|
+
if (msg.type === "repl_reconcile_request" && typeof msg.basis === "number" && Number.isSafeInteger(msg.basis)) {
|
|
10356
|
+
return {
|
|
10357
|
+
type: "repl_reconcile_request",
|
|
10358
|
+
basis: msg.basis,
|
|
10359
|
+
sessionName: typeof msg.sessionName === "string" ? msg.sessionName : undefined,
|
|
10360
|
+
tmuxSessionId: typeof msg.tmuxSessionId === "string" ? msg.tmuxSessionId : undefined,
|
|
10361
|
+
tmuxSessionCreatedAt: typeof msg.tmuxSessionCreatedAt === "number" && Number.isSafeInteger(msg.tmuxSessionCreatedAt) ? msg.tmuxSessionCreatedAt : undefined,
|
|
10362
|
+
runtime: normalizeStudioReplRuntime(msg.runtime) || undefined,
|
|
10339
10363
|
};
|
|
10340
10364
|
}
|
|
10341
10365
|
|
|
@@ -11233,10 +11257,6 @@ function getNewStudioReplSessionName(runtime: StudioReplRuntime, command?: strin
|
|
|
11233
11257
|
return `pi-studio-repl-${runtime}${getStudioReplCommandSessionSuffix(runtime, command)}-${suffix}`;
|
|
11234
11258
|
}
|
|
11235
11259
|
|
|
11236
|
-
function getStudioReplPaneTarget(sessionName: string): string {
|
|
11237
|
-
return `${sessionName}:0.0`;
|
|
11238
|
-
}
|
|
11239
|
-
|
|
11240
11260
|
function inferStudioReplSessionRuntime(sessionName: string): { runtime: StudioReplRuntime | "unknown"; source: StudioReplSessionInfo["source"] } {
|
|
11241
11261
|
const studioMatch = sessionName.match(/^pi-studio-repl-([a-z0-9-]+)$/i);
|
|
11242
11262
|
if (studioMatch) {
|
|
@@ -11270,8 +11290,9 @@ function isTmuxAvailable(): boolean {
|
|
|
11270
11290
|
return result.status === 0;
|
|
11271
11291
|
}
|
|
11272
11292
|
|
|
11293
|
+
// -u: without a UTF-8 locale, tmux prints the tabs that separate format fields as "_".
|
|
11273
11294
|
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 } {
|
|
11274
|
-
const result = spawnSync("tmux", args, {
|
|
11295
|
+
const result = spawnSync("tmux", ["-u", ...args], {
|
|
11275
11296
|
cwd: options?.cwd,
|
|
11276
11297
|
input: options?.input,
|
|
11277
11298
|
encoding: "utf8",
|
|
@@ -11290,15 +11311,15 @@ function runStudioTmux(args: string[], options?: { cwd?: string; input?: string;
|
|
|
11290
11311
|
return { ok: true, stdout, stderr };
|
|
11291
11312
|
}
|
|
11292
11313
|
|
|
11293
|
-
function readStudioReplTmuxOption(
|
|
11294
|
-
const result = runStudioTmux(["show-options", "-v", "-t",
|
|
11314
|
+
function readStudioReplTmuxOption(session: string, optionName: string): string | undefined {
|
|
11315
|
+
const result = runStudioTmux(["show-options", "-v", "-t", studioReplSessionTarget(session), optionName], { timeout: 3_000 });
|
|
11295
11316
|
if (!result.ok) return undefined;
|
|
11296
11317
|
const value = result.stdout.trim();
|
|
11297
11318
|
return value || undefined;
|
|
11298
11319
|
}
|
|
11299
11320
|
|
|
11300
|
-
function setStudioReplTmuxOptionIfAbsent(
|
|
11301
|
-
return runStudioTmux(["set-option", "-qo", "-t",
|
|
11321
|
+
function setStudioReplTmuxOptionIfAbsent(session: string, optionName: string, value: string): boolean {
|
|
11322
|
+
return runStudioTmux(["set-option", "-qo", "-t", studioReplSessionTarget(session), optionName, value], { timeout: 3_000 }).ok;
|
|
11302
11323
|
}
|
|
11303
11324
|
|
|
11304
11325
|
function attachStudioReplSessionRecord(
|
|
@@ -11306,24 +11327,25 @@ function attachStudioReplSessionRecord(
|
|
|
11306
11327
|
recordIdHint?: string,
|
|
11307
11328
|
versionHint?: string,
|
|
11308
11329
|
): StudioReplSessionInfo {
|
|
11309
|
-
|
|
11310
|
-
let
|
|
11330
|
+
const tmuxSession = session.tmuxSessionId || session.sessionName;
|
|
11331
|
+
let recordId = recordIdHint || readStudioReplTmuxOption(tmuxSession, REPL_SESSION_RECORD_ID_OPTION);
|
|
11332
|
+
let version = versionHint || readStudioReplTmuxOption(tmuxSession, REPL_SESSION_RECORD_VERSION_OPTION);
|
|
11311
11333
|
if (recordId && !isValidReplSessionRecordId(recordId)) {
|
|
11312
11334
|
return { ...session, recordWarning: "Invalid shared REPL record metadata; Studio left it untouched." };
|
|
11313
11335
|
}
|
|
11314
11336
|
if (!recordId) {
|
|
11315
11337
|
const candidate = createReplSessionRecordId();
|
|
11316
|
-
if (!setStudioReplTmuxOptionIfAbsent(
|
|
11338
|
+
if (!setStudioReplTmuxOptionIfAbsent(tmuxSession, REPL_SESSION_RECORD_ID_OPTION, candidate)) {
|
|
11317
11339
|
return { ...session, recordWarning: "Studio could not attach shared record metadata to this tmux session." };
|
|
11318
11340
|
}
|
|
11319
|
-
recordId = readStudioReplTmuxOption(
|
|
11341
|
+
recordId = readStudioReplTmuxOption(tmuxSession, REPL_SESSION_RECORD_ID_OPTION);
|
|
11320
11342
|
}
|
|
11321
11343
|
if (!recordId || !isValidReplSessionRecordId(recordId)) {
|
|
11322
11344
|
return { ...session, recordWarning: "Studio could not read valid shared record metadata from this tmux session." };
|
|
11323
11345
|
}
|
|
11324
11346
|
if (!version) {
|
|
11325
|
-
setStudioReplTmuxOptionIfAbsent(
|
|
11326
|
-
version = readStudioReplTmuxOption(
|
|
11347
|
+
setStudioReplTmuxOptionIfAbsent(tmuxSession, REPL_SESSION_RECORD_VERSION_OPTION, String(REPL_SESSION_RECORD_VERSION));
|
|
11348
|
+
version = readStudioReplTmuxOption(tmuxSession, REPL_SESSION_RECORD_VERSION_OPTION);
|
|
11327
11349
|
}
|
|
11328
11350
|
const recordPath = getReplSessionRecordPath(recordId);
|
|
11329
11351
|
if (version !== String(REPL_SESSION_RECORD_VERSION)) {
|
|
@@ -11352,126 +11374,186 @@ function attachStudioReplSessionRecord(
|
|
|
11352
11374
|
}
|
|
11353
11375
|
}
|
|
11354
11376
|
|
|
11355
|
-
|
|
11356
|
-
|
|
11357
|
-
|
|
11358
|
-
|
|
11359
|
-
|
|
11360
|
-
recordIdHint = "",
|
|
11361
|
-
versionHint = "",
|
|
11362
|
-
): StudioReplSessionInfo {
|
|
11363
|
-
const inferred = inferStudioReplSessionRuntime(sessionName);
|
|
11364
|
-
const metadataRuntime = normalizeStudioReplRuntime(runtimeMetadata);
|
|
11377
|
+
type StudioReplPane = ReturnType<typeof firstStudioReplPanes>[number];
|
|
11378
|
+
|
|
11379
|
+
function makeStudioReplSessionInfo(pane: StudioReplPane): StudioReplSessionInfo {
|
|
11380
|
+
const inferred = inferStudioReplSessionRuntime(pane.sessionName);
|
|
11381
|
+
const metadataRuntime = normalizeStudioReplRuntime(pane.runtime);
|
|
11365
11382
|
const runtime = metadataRuntime || inferred.runtime;
|
|
11366
11383
|
return attachStudioReplSessionRecord({
|
|
11367
|
-
sessionName,
|
|
11368
|
-
|
|
11369
|
-
|
|
11370
|
-
|
|
11384
|
+
sessionName: pane.sessionName,
|
|
11385
|
+
// The first pane's ID, pinned for a whole send (Sol: not an assumed 0.0).
|
|
11386
|
+
target: pane.paneId,
|
|
11387
|
+
tmuxSessionId: pane.sessionId,
|
|
11388
|
+
tmuxSessionCreatedAt: Math.max(0, Math.floor(Number(pane.createdAt) || 0)),
|
|
11371
11389
|
runtime,
|
|
11372
|
-
label: formatStudioReplSessionLabel(sessionName, runtime, inferred.source),
|
|
11390
|
+
label: formatStudioReplSessionLabel(pane.sessionName, runtime, inferred.source),
|
|
11373
11391
|
source: inferred.source,
|
|
11374
|
-
},
|
|
11392
|
+
}, pane.recordId, pane.version);
|
|
11393
|
+
}
|
|
11394
|
+
|
|
11395
|
+
// A session by exact name or tmux ID ($N).
|
|
11396
|
+
function inspectStudioReplTmuxSession(session: string): StudioReplSessionInfo | null {
|
|
11397
|
+
const result = runStudioTmux(["list-panes", "-s", "-t", studioReplSessionTarget(session), "-F", STUDIO_REPL_PANE_FORMAT], { timeout: 3_000 });
|
|
11398
|
+
if (!result.ok) return null;
|
|
11399
|
+
const [pane] = firstStudioReplPanes(result.stdout);
|
|
11400
|
+
return pane ? makeStudioReplSessionInfo(pane) : null;
|
|
11375
11401
|
}
|
|
11376
11402
|
|
|
11377
11403
|
function inspectStudioReplSession(sessionName: string): StudioReplSessionInfo | null {
|
|
11378
11404
|
if (!/^[-_.A-Za-z0-9]+$/.test(sessionName)) return null;
|
|
11379
|
-
const
|
|
11380
|
-
|
|
11381
|
-
"-p",
|
|
11382
|
-
"-t",
|
|
11383
|
-
getStudioReplPaneTarget(sessionName),
|
|
11384
|
-
"#{session_name}\t#{session_id}\t#{session_created}\t#{@pi_repl_runtime}\t#{@pi_repl_record_id}\t#{@pi_repl_record_version}",
|
|
11385
|
-
], { timeout: 3_000 });
|
|
11386
|
-
if (!result.ok) return null;
|
|
11387
|
-
const [resolvedName, tmuxSessionId, createdAt, runtime, recordId, version] = result.stdout.trim().split("\t");
|
|
11388
|
-
return makeStudioReplSessionInfo(resolvedName || sessionName, tmuxSessionId, createdAt, runtime, recordId, version);
|
|
11405
|
+
const session = inspectStudioReplTmuxSession(sessionName);
|
|
11406
|
+
return session?.sessionName === sessionName ? session : null;
|
|
11389
11407
|
}
|
|
11390
11408
|
|
|
11391
11409
|
function listStudioReplSessions(): { tmuxAvailable: boolean; sessions: StudioReplSessionInfo[]; error?: string } {
|
|
11392
11410
|
if (!isTmuxAvailable()) return { tmuxAvailable: false, sessions: [], error: "tmux is not available." };
|
|
11393
|
-
const result = runStudioTmux([
|
|
11394
|
-
"list-sessions",
|
|
11395
|
-
"-F",
|
|
11396
|
-
"#{session_name}\t#{session_id}\t#{session_created}\t#{@pi_repl_runtime}\t#{@pi_repl_record_id}\t#{@pi_repl_record_version}",
|
|
11397
|
-
], { timeout: 3_000 });
|
|
11411
|
+
const result = runStudioTmux(["list-panes", "-a", "-F", STUDIO_REPL_PANE_FORMAT], { timeout: 3_000 });
|
|
11398
11412
|
if (!result.ok) {
|
|
11399
11413
|
const message = result.message.toLowerCase().includes("no server running") ? "No tmux sessions are running." : result.message;
|
|
11400
11414
|
return { tmuxAvailable: true, sessions: [], error: message };
|
|
11401
11415
|
}
|
|
11402
|
-
const sessions = result.stdout
|
|
11403
|
-
.
|
|
11404
|
-
.map((
|
|
11405
|
-
.filter(Boolean)
|
|
11406
|
-
.map((line) => line.split("\t"))
|
|
11407
|
-
.filter(([sessionName]) => Boolean(sessionName && shouldShowStudioReplTmuxSession(sessionName)))
|
|
11408
|
-
.map(([sessionName = "", tmuxSessionId = "", createdAt = "0", runtime = "", recordId = "", version = ""]) => (
|
|
11409
|
-
makeStudioReplSessionInfo(sessionName, tmuxSessionId, createdAt, runtime, recordId, version)
|
|
11410
|
-
));
|
|
11416
|
+
const sessions = firstStudioReplPanes(result.stdout)
|
|
11417
|
+
.filter((pane) => shouldShowStudioReplTmuxSession(pane.sessionName))
|
|
11418
|
+
.map((pane) => makeStudioReplSessionInfo(pane));
|
|
11411
11419
|
return { tmuxAvailable: true, sessions };
|
|
11412
11420
|
}
|
|
11413
11421
|
|
|
11414
|
-
function captureStudioReplSession(sessionName: string): { ok: true; transcript: string; session: StudioReplSessionInfo } | { ok: false; message: string } {
|
|
11422
|
+
function captureStudioReplSession(sessionName: string, pinned?: StudioReplSessionInfo): { ok: true; transcript: string; session: StudioReplSessionInfo } | { ok: false; message: string } {
|
|
11415
11423
|
if (!/^[-_.A-Za-z0-9]+$/.test(sessionName)) return { ok: false, message: "Invalid REPL session name." };
|
|
11416
11424
|
const session = inspectStudioReplSession(sessionName);
|
|
11417
11425
|
if (!session) return { ok: false, message: `No tmux REPL session named ${sessionName}.` };
|
|
11418
|
-
|
|
11426
|
+
// A send reads the pane it pasted into, even if a lower-numbered window has appeared since.
|
|
11427
|
+
const target = pinned?.target || session.target;
|
|
11428
|
+
if (pinned) {
|
|
11429
|
+
// Ask the session for its panes: a pane in a linked window belongs to more than one session (Sol).
|
|
11430
|
+
const panes = runStudioTmux(["list-panes", "-s", "-t", session.tmuxSessionId || studioReplSessionTarget(sessionName), "-F", "#{pane_id}"], { timeout: 3_000 });
|
|
11431
|
+
if (!panes.ok || !panes.stdout.split("\n").includes(target)) return { ok: false, message: `The ${sessionName} pane Studio sent to has closed.` };
|
|
11432
|
+
}
|
|
11433
|
+
const result = runStudioTmux(["capture-pane", "-J", "-p", "-t", target, "-S", `-${STUDIO_REPL_CAPTURE_LINES}`], { timeout: 3_000 });
|
|
11419
11434
|
if (!result.ok) return { ok: false, message: result.message };
|
|
11420
11435
|
return { ok: true, transcript: String(result.stdout || "").replace(/[\t ]+$/gm, "").trimEnd(), session };
|
|
11421
11436
|
}
|
|
11422
11437
|
|
|
11423
|
-
|
|
11438
|
+
// The pane's cursor row, where a runtime shows its prompt; dead once its command has
|
|
11439
|
+
// exited, gone once the pane has closed or left the session.
|
|
11440
|
+
function readStudioReplCursorRow(sessionId: string, paneId: string): { gone?: boolean; dead?: boolean; pid?: string; line?: string } {
|
|
11441
|
+
const panes = runStudioTmux(["list-panes", "-s", "-t", sessionId, "-F", "#{pane_id}\t#{pane_dead}\t#{cursor_y}\t#{pane_pid}"], { timeout: 3_000 });
|
|
11442
|
+
if (!panes.ok) return { gone: true };
|
|
11443
|
+
const fields = panes.stdout.split("\n").map((line) => line.split("\t")).find(([id]) => id === paneId);
|
|
11444
|
+
if (!fields) return { gone: true };
|
|
11445
|
+
const [, dead = "", row = "", pid = ""] = fields;
|
|
11446
|
+
if (dead === "1") return { dead: true, pid };
|
|
11447
|
+
if (!/^\d+$/.test(row)) return {};
|
|
11448
|
+
const line = runStudioTmux(["capture-pane", "-p", "-t", paneId, "-S", row, "-E", row], { timeout: 3_000 });
|
|
11449
|
+
return { line: line.ok ? line.stdout.replace(/\r?\n$/, "").trimEnd() : undefined };
|
|
11450
|
+
}
|
|
11451
|
+
|
|
11452
|
+
async function startStudioReplSession(runtime: StudioReplRuntime, cwd: string, options?: { newSession?: boolean; command?: string }): Promise<{ ok: true; session: StudioReplSessionInfo; message: string } | { ok: false; message: string }> {
|
|
11424
11453
|
if (!isTmuxAvailable()) return { ok: false, message: "tmux is not available. Install tmux to use Studio REPL sessions." };
|
|
11425
11454
|
const commandOverride = normalizeStudioReplCommandOverride(runtime, options?.command);
|
|
11426
11455
|
const sessionName = options?.newSession ? getNewStudioReplSessionName(runtime, commandOverride) : getStudioReplSessionName(runtime, commandOverride);
|
|
11427
|
-
const
|
|
11428
|
-
|
|
11456
|
+
const label = STUDIO_REPL_RUNTIME_LABELS[runtime];
|
|
11457
|
+
const running = () => runStudioTmux(["has-session", "-t", studioReplSessionTarget(sessionName)], { timeout: 3_000 }).ok;
|
|
11458
|
+
const reuse = (): { ok: true; session: StudioReplSessionInfo; message: string } | { ok: false; message: string } => {
|
|
11429
11459
|
const session = inspectStudioReplSession(sessionName);
|
|
11430
11460
|
if (!session) return { ok: false, message: `Could not inspect existing REPL session ${sessionName}.` };
|
|
11431
|
-
return {
|
|
11432
|
-
ok: true,
|
|
11433
|
-
session,
|
|
11434
|
-
message: `${STUDIO_REPL_RUNTIME_LABELS[runtime]} REPL is already running.`,
|
|
11435
|
-
};
|
|
11436
|
-
}
|
|
11437
|
-
const command = getStudioReplRuntimeCommand(runtime, commandOverride);
|
|
11438
|
-
const result = runStudioTmux(buildStudioReplTmuxStartArgs(sessionName, cwd || process.cwd(), command), { timeout: 5_000 });
|
|
11439
|
-
if (!result.ok) return { ok: false, message: result.message || `Failed to start ${STUDIO_REPL_RUNTIME_LABELS[runtime]} REPL.` };
|
|
11440
|
-
runStudioTmux(["set-option", "-q", "-t", sessionName, STUDIO_REPL_RUNTIME_OPTION, runtime], { timeout: 3_000 });
|
|
11441
|
-
const session = inspectStudioReplSession(sessionName);
|
|
11442
|
-
if (!session) {
|
|
11443
|
-
runStudioTmux(["kill-session", "-t", sessionName], { timeout: 3_000 });
|
|
11444
|
-
return { ok: false, message: `Started ${sessionName}, but could not initialize its shared session metadata.` };
|
|
11445
|
-
}
|
|
11446
|
-
return {
|
|
11447
|
-
ok: true,
|
|
11448
|
-
session,
|
|
11449
|
-
message: `Started ${options?.newSession ? "new " : ""}${STUDIO_REPL_RUNTIME_LABELS[runtime]} REPL${commandOverride ? ` with custom command: ${commandOverride}` : ""}.`,
|
|
11461
|
+
return { ok: true, session, message: `${label} REPL is already running.` };
|
|
11450
11462
|
};
|
|
11463
|
+
if (running()) return reuse();
|
|
11464
|
+
const command = getStudioReplRuntimeCommand(runtime, commandOverride);
|
|
11465
|
+
const created = runStudioTmux(buildStudioReplTmuxStartArgs(sessionName, cwd || process.cwd(), command), { timeout: 5_000 });
|
|
11466
|
+
if (!created.ok) {
|
|
11467
|
+
// Another tab may have started the same session first; share it.
|
|
11468
|
+
if (running()) return reuse();
|
|
11469
|
+
return { ok: false, message: created.message || `Failed to start ${label} REPL.` };
|
|
11470
|
+
}
|
|
11471
|
+
// From here on, address the session Studio created, and the pane its runtime
|
|
11472
|
+
// started in, by their IDs, not by name or position.
|
|
11473
|
+
const [sessionId = "", paneId = ""] = created.stdout.trim().split("\t");
|
|
11474
|
+
if (!/^\$\d+$/.test(sessionId) || !/^%\d+$/.test(paneId)) return { ok: false, message: `Started ${sessionName}, but tmux didn't report its ID. Check it before running code.` };
|
|
11475
|
+
runStudioTmux(["set-option", "-q", "-t", sessionId, STUDIO_REPL_RUNTIME_OPTION, runtime], { timeout: 3_000 });
|
|
11476
|
+
const started = `Started ${options?.newSession ? "new " : ""}${label} REPL${commandOverride ? ` with custom command: ${commandOverride}` : ""}`;
|
|
11477
|
+
const exited = `${label} exited while starting. Check the command: ${command}.`;
|
|
11478
|
+
// Wait for the runtime's normal prompt, only reading the pane: no probe, Enter or Ctrl-C (Sol).
|
|
11479
|
+
const deadline = Date.now() + STUDIO_REPL_START_WAIT_MS;
|
|
11480
|
+
let ready = false;
|
|
11481
|
+
while (true) {
|
|
11482
|
+
const row = readStudioReplCursorRow(sessionId, paneId);
|
|
11483
|
+
if (row.gone) return { ok: false, message: exited };
|
|
11484
|
+
if (row.dead) {
|
|
11485
|
+
// Close only that pane, and only if it is still the one that exited; tmux
|
|
11486
|
+
// ends the session with its last pane, and anything opened since keeps running (Sol).
|
|
11487
|
+
const close = buildStudioReplDeadPaneCloseArgs(sessionId, paneId, row.pid ?? "");
|
|
11488
|
+
if (close) runStudioTmux(close, { timeout: 3_000 });
|
|
11489
|
+
return { ok: false, message: exited };
|
|
11490
|
+
}
|
|
11491
|
+
ready = runtime === "shell" || (row.line !== undefined && isStudioReplReadyLine(runtime, row.line));
|
|
11492
|
+
if (ready || Date.now() >= deadline) break;
|
|
11493
|
+
await sleep(Math.min(STUDIO_REPL_START_POLL_MS, Math.max(0, deadline - Date.now())));
|
|
11494
|
+
}
|
|
11495
|
+
// Inspect once, which also attaches the shared record.
|
|
11496
|
+
const session = inspectStudioReplTmuxSession(sessionId);
|
|
11497
|
+
if (!session) return { ok: false, message: exited };
|
|
11498
|
+
// Studio sends to a session's first pane. If that isn't the pane it watched,
|
|
11499
|
+
// the prompt it saw says nothing about where code would go (Sol).
|
|
11500
|
+
if (session.target !== paneId) return { ok: true, session, message: `${started}, but another pane now comes first in its session. Check it before running code.` };
|
|
11501
|
+
return { ok: true, session, message: ready ? `${started}.` : `${started}, but its prompt hasn't appeared yet. Check the pane before running code.` };
|
|
11502
|
+
}
|
|
11503
|
+
|
|
11504
|
+
function runStudioTmuxAsync(args: string[], timeout = 3_000): Promise<{ code: number; stdout: string; stderr: string }> {
|
|
11505
|
+
return new Promise((resolveTmux) => {
|
|
11506
|
+
execFile("tmux", ["-u", ...args], { encoding: "utf8", timeout, maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
11507
|
+
const exitCode = (error as { code?: unknown } | null)?.code;
|
|
11508
|
+
const code = !error ? 0 : typeof exitCode === "number" ? exitCode : 1;
|
|
11509
|
+
resolveTmux({ code, stdout: String(stdout || ""), stderr: String(stderr || (error && code === 1 ? error.message : "")) });
|
|
11510
|
+
});
|
|
11511
|
+
});
|
|
11451
11512
|
}
|
|
11452
11513
|
|
|
11453
|
-
function stopStudioReplSession(sessionName: string): { ok: true; message: string } | { ok: false; message: string } {
|
|
11514
|
+
async function stopStudioReplSession(sessionName: string): Promise<{ ok: true; message: string; session: StudioReplSessionInfo } | { ok: false; message: string }> {
|
|
11454
11515
|
if (!/^[-_.A-Za-z0-9]+$/.test(sessionName)) return { ok: false, message: "Invalid REPL session name." };
|
|
11455
11516
|
const inferred = inferStudioReplSessionRuntime(sessionName);
|
|
11456
|
-
if (inferred.source !== "studio") {
|
|
11517
|
+
if (inferred.source !== "studio" || !STUDIO_REPL_SESSION_NAME.test(sessionName)) {
|
|
11457
11518
|
return { ok: false, message: "Studio can only stop Studio-owned REPL sessions. Use tmux or pi-repl to stop external sessions." };
|
|
11458
11519
|
}
|
|
11459
|
-
const
|
|
11460
|
-
if (!
|
|
11461
|
-
|
|
11520
|
+
const session = inspectStudioReplSession(sessionName);
|
|
11521
|
+
if (!session) return { ok: false, message: `No tmux REPL session named ${sessionName}.` };
|
|
11522
|
+
if (session.runtime === "shell" || inferred.runtime === "shell") {
|
|
11523
|
+
// Like closing a terminal: the shell's ordinary jobs end, and nohup or
|
|
11524
|
+
// disowned jobs keep running. Close only the session Studio inspected.
|
|
11525
|
+
const closed = runStudioTmux([
|
|
11526
|
+
"if-shell", "-F", "-t", session.tmuxSessionId,
|
|
11527
|
+
`#{==:#{session_created},${session.tmuxSessionCreatedAt}}`,
|
|
11528
|
+
`kill-session -t '${session.tmuxSessionId}'`,
|
|
11529
|
+
"display-message -p PI_STUDIO_STOP_CHANGED",
|
|
11530
|
+
], { timeout: 5_000 });
|
|
11531
|
+
if (!closed.ok || closed.stdout.includes("PI_STUDIO_STOP_CHANGED")) return { ok: false, message: `Couldn't close ${sessionName}: ${closed.ok ? "it changed before Studio could close it." : closed.message}` };
|
|
11532
|
+
return { ok: true, message: `Closed ${sessionName}.`, session };
|
|
11533
|
+
}
|
|
11534
|
+
try {
|
|
11535
|
+
await stopVerifiedReplSession({ tmux: (args: string[]) => runStudioTmuxAsync(args), sessionName, sessionNamePattern: STUDIO_REPL_SESSION_NAME });
|
|
11536
|
+
return { ok: true, message: `Stopped ${sessionName}.`, session };
|
|
11537
|
+
} catch (error) {
|
|
11538
|
+
// After tmux has closed the session, the shared stop wraps its reason as the cause.
|
|
11539
|
+
const cause = error instanceof Error && error.cause instanceof Error ? error.cause : null;
|
|
11540
|
+
if (cause) return { ok: false, message: `Stopped ${sessionName}'s tmux session, but couldn't confirm its processes exited: ${cause.message}` };
|
|
11541
|
+
return { ok: false, message: `Couldn't stop ${sessionName}: ${error instanceof Error ? error.message : String(error)}` };
|
|
11542
|
+
}
|
|
11462
11543
|
}
|
|
11463
11544
|
|
|
11464
11545
|
type StudioReplControlFiles = {
|
|
11465
11546
|
dir: string;
|
|
11466
11547
|
sourceFile: string;
|
|
11467
11548
|
doneFile: string;
|
|
11549
|
+
// GHCi's guard and driver scripts, removed along with the source.
|
|
11550
|
+
helpers?: Array<{ dir: string; sourceFile: string; doneFile: string }>;
|
|
11468
11551
|
};
|
|
11469
11552
|
|
|
11470
11553
|
type StudioReplPreparedSubmission = {
|
|
11471
11554
|
runtime: StudioReplRuntime | "unknown";
|
|
11472
11555
|
usedControlFile: boolean;
|
|
11473
11556
|
submissionText: string;
|
|
11474
|
-
completionLine?: string;
|
|
11475
11557
|
controlFiles?: StudioReplControlFiles;
|
|
11476
11558
|
display?: ReplSubmissionDisplay;
|
|
11477
11559
|
};
|
|
@@ -11482,7 +11564,6 @@ type StudioReplSendSuccess = {
|
|
|
11482
11564
|
runtime: StudioReplRuntime | "unknown";
|
|
11483
11565
|
usedControlFile: boolean;
|
|
11484
11566
|
submissionText: string;
|
|
11485
|
-
completionLine?: string;
|
|
11486
11567
|
controlFiles?: StudioReplControlFiles;
|
|
11487
11568
|
display?: ReplSubmissionDisplay;
|
|
11488
11569
|
};
|
|
@@ -11494,7 +11575,6 @@ type StudioReplSendFailure = {
|
|
|
11494
11575
|
runtime?: StudioReplRuntime | "unknown";
|
|
11495
11576
|
usedControlFile?: boolean;
|
|
11496
11577
|
submissionText?: string;
|
|
11497
|
-
completionLine?: string;
|
|
11498
11578
|
controlFiles?: StudioReplControlFiles;
|
|
11499
11579
|
display?: ReplSubmissionDisplay;
|
|
11500
11580
|
};
|
|
@@ -11526,9 +11606,15 @@ function buildStudioPythonDisplayStatements(display: ReplSubmissionDisplay, inde
|
|
|
11526
11606
|
return display.prefixLines.map((line) => `${indent}__pi_studio_builtins.print(${JSON.stringify(line)})`);
|
|
11527
11607
|
}
|
|
11528
11608
|
|
|
11609
|
+
function juliaStringLiteral(value: string): string {
|
|
11610
|
+
// JSON escaping alone leaves Julia's $ interpolation active: "i=$i" would be
|
|
11611
|
+
// read while building the wrapper, before the code runs.
|
|
11612
|
+
return JSON.stringify(value).replace(/\$/g, "\\$");
|
|
11613
|
+
}
|
|
11614
|
+
|
|
11529
11615
|
function buildStudioJuliaDisplayStatements(display: ReplSubmissionDisplay, indent = ""): string[] {
|
|
11530
11616
|
if (!display.enabled) return [];
|
|
11531
|
-
return display.prefixLines.map((line) => `${indent}Base.println(${
|
|
11617
|
+
return display.prefixLines.map((line) => `${indent}Base.println(${juliaStringLiteral(line)})`);
|
|
11532
11618
|
}
|
|
11533
11619
|
|
|
11534
11620
|
function buildStudioRDisplayStatements(display: ReplSubmissionDisplay, indent = ""): string[] {
|
|
@@ -11589,11 +11675,11 @@ function buildStudioPythonControlSource(runtime: "python" | "ipython", code: str
|
|
|
11589
11675
|
}
|
|
11590
11676
|
|
|
11591
11677
|
function buildStudioJuliaControlSource(code: string, doneFile: string, display: ReplSubmissionDisplay): string {
|
|
11592
|
-
const completion = display.enabled ? [` Base.println(${
|
|
11678
|
+
const completion = display.enabled ? [` Base.println(${juliaStringLiteral(display.endMarker)})`] : [];
|
|
11593
11679
|
return [
|
|
11594
11680
|
...buildStudioJuliaDisplayStatements(display),
|
|
11595
11681
|
"try",
|
|
11596
|
-
` local __pi_studio_result = Base.include_string(Main, ${
|
|
11682
|
+
` local __pi_studio_result = Base.include_string(Main, ${juliaStringLiteral(code)}, "pi-studio-repl")`,
|
|
11597
11683
|
" if !isnothing(__pi_studio_result)",
|
|
11598
11684
|
" println(repr(__pi_studio_result))",
|
|
11599
11685
|
" end",
|
|
@@ -11601,7 +11687,7 @@ function buildStudioJuliaControlSource(code: string, doneFile: string, display:
|
|
|
11601
11687
|
" Base.display_error(stderr, e, catch_backtrace())",
|
|
11602
11688
|
"finally",
|
|
11603
11689
|
...completion,
|
|
11604
|
-
` write(${
|
|
11690
|
+
` write(${juliaStringLiteral(doneFile)}, "done\\n")`,
|
|
11605
11691
|
"end",
|
|
11606
11692
|
].join("\n");
|
|
11607
11693
|
}
|
|
@@ -11639,7 +11725,8 @@ function buildStudioRControlSource(code: string, doneFile: string, display: Repl
|
|
|
11639
11725
|
].join("\n");
|
|
11640
11726
|
}
|
|
11641
11727
|
|
|
11642
|
-
function buildStudioGhciControlSource(code: string, display: ReplSubmissionDisplay): string {
|
|
11728
|
+
function buildStudioGhciControlSource(code: string, doneFile: string, display: ReplSubmissionDisplay): string {
|
|
11729
|
+
if (/[\r\n]/.test(doneFile)) throw new Error("GHCi control paths cannot contain line breaks.");
|
|
11643
11730
|
const prefix = display.enabled
|
|
11644
11731
|
? display.prefixLines.map((line) => `:! command printf '%s\\n' ${shellQuote(line)}`)
|
|
11645
11732
|
: [];
|
|
@@ -11689,7 +11776,7 @@ function buildStudioReplControlSource(runtime: StudioReplRuntime, code: string,
|
|
|
11689
11776
|
if (runtime === "python" || runtime === "ipython") return buildStudioPythonControlSource(runtime, code, doneFile, display);
|
|
11690
11777
|
if (runtime === "julia") return buildStudioJuliaControlSource(code, doneFile, display);
|
|
11691
11778
|
if (runtime === "r") return buildStudioRControlSource(code, doneFile, display);
|
|
11692
|
-
if (runtime === "ghci") return buildStudioGhciControlSource(code, display);
|
|
11779
|
+
if (runtime === "ghci") return buildStudioGhciControlSource(code, doneFile, display);
|
|
11693
11780
|
if (runtime === "clojure") return buildStudioClojureControlSource(code, doneFile, display);
|
|
11694
11781
|
if (runtime === "shell") return buildStudioShellControlSource(code, display);
|
|
11695
11782
|
return null;
|
|
@@ -11697,7 +11784,7 @@ function buildStudioReplControlSource(runtime: StudioReplRuntime, code: string,
|
|
|
11697
11784
|
|
|
11698
11785
|
function buildStudioReplSubmissionLine(runtime: StudioReplRuntime, sourceFile: string, doneFile: string): string {
|
|
11699
11786
|
const quotedPath = JSON.stringify(sourceFile);
|
|
11700
|
-
if (runtime === "julia") return `include(${
|
|
11787
|
+
if (runtime === "julia") return `include(${juliaStringLiteral(sourceFile)})`;
|
|
11701
11788
|
if (runtime === "r") return `source(${quotedPath}, local=.GlobalEnv)`;
|
|
11702
11789
|
if (runtime === "ghci") return `:script ${quotedPath}`;
|
|
11703
11790
|
if (runtime === "clojure") return `(do (load-file ${quotedPath}) :pi-studio/silent)`;
|
|
@@ -11705,51 +11792,74 @@ function buildStudioReplSubmissionLine(runtime: StudioReplRuntime, sourceFile: s
|
|
|
11705
11792
|
return `exec(open(${quotedPath}, encoding="utf-8").read(), globals())`;
|
|
11706
11793
|
}
|
|
11707
11794
|
|
|
11795
|
+
function unsupportedStudioReplRuntimeMessage(sessionName: string): string {
|
|
11796
|
+
return `Studio can't send code to ${sessionName} yet: it doesn't support that runtime.`;
|
|
11797
|
+
}
|
|
11798
|
+
|
|
11799
|
+
function cleanupStudioReplControlFiles(controlFiles: StudioReplControlFiles | undefined): void {
|
|
11800
|
+
if (!controlFiles) return;
|
|
11801
|
+
cleanupPrivateReplControlFiles(controlFiles);
|
|
11802
|
+
for (const helper of controlFiles.helpers ?? []) cleanupPrivateReplControlFiles(helper);
|
|
11803
|
+
}
|
|
11804
|
+
|
|
11708
11805
|
function prepareStudioReplSubmission(
|
|
11709
|
-
sessionName: string,
|
|
11710
11806
|
source: string,
|
|
11711
11807
|
details: { submissionId: string; echoMode: ReplSubmissionEchoMode },
|
|
11712
|
-
|
|
11808
|
+
runtime: StudioReplRuntime,
|
|
11713
11809
|
): StudioReplPreparedSubmission {
|
|
11714
11810
|
const normalizedSource = String(source || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
11715
|
-
const
|
|
11716
|
-
|
|
11717
|
-
|
|
11718
|
-
|
|
11719
|
-
|
|
11720
|
-
|
|
11721
|
-
|
|
11722
|
-
|
|
11723
|
-
|
|
11724
|
-
|
|
11725
|
-
|
|
11726
|
-
|
|
11727
|
-
|
|
11728
|
-
|
|
11729
|
-
|
|
11730
|
-
|
|
11731
|
-
|
|
11732
|
-
|
|
11733
|
-
|
|
11734
|
-
|
|
11735
|
-
|
|
11736
|
-
|
|
11737
|
-
|
|
11738
|
-
|
|
11739
|
-
|
|
11740
|
-
|
|
11811
|
+
const display = createReplSubmissionDisplay({
|
|
11812
|
+
entryId: details.submissionId,
|
|
11813
|
+
origin: "pi-studio",
|
|
11814
|
+
code: normalizedSource,
|
|
11815
|
+
mode: details.echoMode,
|
|
11816
|
+
});
|
|
11817
|
+
const controlFiles: StudioReplControlFiles = createPrivateReplControlFiles({
|
|
11818
|
+
extension: getStudioReplControlExtension(runtime),
|
|
11819
|
+
buildSource: ({ doneFile }: StudioReplControlFiles) => {
|
|
11820
|
+
const source = buildStudioReplControlSource(runtime, normalizedSource, doneFile, display);
|
|
11821
|
+
if (source === null) throw new Error(`No control-file wrapper is available for ${runtime}.`);
|
|
11822
|
+
return source;
|
|
11823
|
+
},
|
|
11824
|
+
});
|
|
11825
|
+
let loader = controlFiles.sourceFile;
|
|
11826
|
+
if (runtime === "ghci") {
|
|
11827
|
+
// As in pi-repl: the completion step runs from a driver script, never as
|
|
11828
|
+
// typed input that code reading stdin could take. An unclosed :{ ends the
|
|
11829
|
+
// guard's script, and the driver still completes.
|
|
11830
|
+
try {
|
|
11831
|
+
const guard = createPrivateReplControlFiles({
|
|
11832
|
+
extension: "ghci",
|
|
11833
|
+
buildSource: () => buildStudioReplSubmissionLine("ghci", controlFiles.sourceFile, controlFiles.doneFile) + "\n",
|
|
11834
|
+
});
|
|
11835
|
+
controlFiles.helpers = [guard];
|
|
11836
|
+
const driver = createPrivateReplControlFiles({
|
|
11837
|
+
extension: "ghci",
|
|
11838
|
+
buildSource: () => [
|
|
11839
|
+
buildStudioReplSubmissionLine("ghci", guard.sourceFile, controlFiles.doneFile),
|
|
11840
|
+
buildStudioGhciCompletionLine(controlFiles.doneFile, display),
|
|
11841
|
+
"",
|
|
11842
|
+
].join("\n"),
|
|
11843
|
+
});
|
|
11844
|
+
controlFiles.helpers.push(driver);
|
|
11845
|
+
loader = driver.sourceFile;
|
|
11846
|
+
} catch (error) {
|
|
11847
|
+
cleanupStudioReplControlFiles(controlFiles);
|
|
11848
|
+
throw error;
|
|
11849
|
+
}
|
|
11741
11850
|
}
|
|
11742
|
-
|
|
11743
11851
|
return {
|
|
11744
11852
|
runtime,
|
|
11745
|
-
usedControlFile:
|
|
11746
|
-
|
|
11853
|
+
usedControlFile: true,
|
|
11854
|
+
controlFiles,
|
|
11855
|
+
display,
|
|
11856
|
+
submissionText: buildStudioReplSubmissionLine(runtime, loader, controlFiles.doneFile),
|
|
11747
11857
|
};
|
|
11748
11858
|
}
|
|
11749
11859
|
|
|
11750
|
-
function pasteTextToStudioReplPane(
|
|
11860
|
+
function pasteTextToStudioReplPane(text: string, paneTarget: string): { ok: true } | { ok: false; message: string; submissionStarted: boolean } {
|
|
11751
11861
|
const bufferName = `pi-studio-repl-${randomUUID().replace(/-/g, "")}`;
|
|
11752
|
-
const target = paneTarget
|
|
11862
|
+
const target = paneTarget;
|
|
11753
11863
|
const loadResult = runStudioTmux(["load-buffer", "-b", bufferName, "-"], { input: text, timeout: 5_000 });
|
|
11754
11864
|
if (!loadResult.ok) return { ok: false, message: loadResult.message || "Failed to load text into tmux buffer.", submissionStarted: false };
|
|
11755
11865
|
try {
|
|
@@ -11766,21 +11876,23 @@ function pasteTextToStudioReplPane(sessionName: string, text: string, paneTarget
|
|
|
11766
11876
|
function sendTextToStudioReplSession(
|
|
11767
11877
|
sessionName: string,
|
|
11768
11878
|
text: string,
|
|
11769
|
-
paneTarget
|
|
11770
|
-
|
|
11879
|
+
paneTarget: string,
|
|
11880
|
+
runtime: StudioReplRuntime | "unknown",
|
|
11771
11881
|
options: { submissionId?: string; echoMode?: string } = {},
|
|
11772
11882
|
): StudioReplSendSuccess | StudioReplSendFailure {
|
|
11773
11883
|
if (!/^[-_.A-Za-z0-9]+$/.test(sessionName)) return { ok: false, message: "Invalid REPL session name." };
|
|
11884
|
+
// No raw pasting: without its runtime's wrapper, Studio can't tell when code has finished.
|
|
11885
|
+
if (runtime === "unknown") return { ok: false, message: unsupportedStudioReplRuntimeMessage(sessionName) };
|
|
11774
11886
|
const source = String(text || "");
|
|
11775
11887
|
if (!source.trim()) return { ok: false, message: "Editor text is empty." };
|
|
11776
11888
|
if (source.length > STUDIO_REPL_SEND_MAX_CHARS) {
|
|
11777
11889
|
return { ok: false, message: `REPL input is too large (${source.length} chars; max ${STUDIO_REPL_SEND_MAX_CHARS}).` };
|
|
11778
11890
|
}
|
|
11779
|
-
const prepared = prepareStudioReplSubmission(
|
|
11891
|
+
const prepared = prepareStudioReplSubmission(source, {
|
|
11780
11892
|
submissionId: options.submissionId || `pi-studio:local:${randomUUID()}`,
|
|
11781
11893
|
echoMode: normalizeReplSubmissionEchoMode(options.echoMode, STUDIO_REPL_TOOL_ECHO_MODE) as ReplSubmissionEchoMode,
|
|
11782
|
-
},
|
|
11783
|
-
const pasted = pasteTextToStudioReplPane(
|
|
11894
|
+
}, runtime);
|
|
11895
|
+
const pasted = pasteTextToStudioReplPane(prepared.submissionText, paneTarget);
|
|
11784
11896
|
if (!pasted.ok) return {
|
|
11785
11897
|
ok: false,
|
|
11786
11898
|
message: pasted.message,
|
|
@@ -11788,7 +11900,6 @@ function sendTextToStudioReplSession(
|
|
|
11788
11900
|
runtime: prepared.runtime,
|
|
11789
11901
|
usedControlFile: prepared.usedControlFile,
|
|
11790
11902
|
submissionText: prepared.submissionText,
|
|
11791
|
-
completionLine: prepared.completionLine,
|
|
11792
11903
|
controlFiles: prepared.controlFiles,
|
|
11793
11904
|
display: prepared.display,
|
|
11794
11905
|
};
|
|
@@ -11798,7 +11909,6 @@ function sendTextToStudioReplSession(
|
|
|
11798
11909
|
runtime: prepared.runtime,
|
|
11799
11910
|
usedControlFile: prepared.usedControlFile,
|
|
11800
11911
|
submissionText: prepared.submissionText,
|
|
11801
|
-
completionLine: prepared.completionLine,
|
|
11802
11912
|
controlFiles: prepared.controlFiles,
|
|
11803
11913
|
display: prepared.display,
|
|
11804
11914
|
};
|
|
@@ -11821,50 +11931,6 @@ function extractStudioReplTranscriptDelta(before: string, after: string): string
|
|
|
11821
11931
|
return current.trim();
|
|
11822
11932
|
}
|
|
11823
11933
|
|
|
11824
|
-
function stripStudioReplSubmissionEcho(output: string): string {
|
|
11825
|
-
let value = String(output || "").replace(/^\s+/, "");
|
|
11826
|
-
// The raw tmux mirror should stay raw, but Studio/tool result output should not
|
|
11827
|
-
// expose the temp-file wrapper used to submit multiline snippets safely. The
|
|
11828
|
-
// root fragment catches both legacy long Studio paths and compact private
|
|
11829
|
-
// control paths. Match the Python wrapper by its final double close rather
|
|
11830
|
-
// than the word `globals`, which IPython may split across continuation lines.
|
|
11831
|
-
const submissionEchoPatterns = [
|
|
11832
|
-
/^.*exec\(open\([\s\S]*?(?:pi-studio-re|pi-rc-)[\s\S]*?\)\)\s*$/gm,
|
|
11833
|
-
/^.*include\([\s\S]*?(?:pi-studio-re|pi-rc-)[\s\S]*?\.jl"\)\s*$/gm,
|
|
11834
|
-
/^.*source\([\s\S]*?(?:pi-studio-re|pi-rc-)[\s\S]*?local\s*=\s*\.GlobalEnv\)\s*$/gm,
|
|
11835
|
-
/^.*:script\s+[\s\S]*?(?:pi-studio-re|pi-rc-)[\s\S]*?\.ghci"?\s*$/gm,
|
|
11836
|
-
/^.*\(do\s+\(load-file\s+[\s\S]*?(?:pi-studio-re|pi-rc-)[\s\S]*?:pi-studio\/silent\)\s*$/gm,
|
|
11837
|
-
/^.*\.\s+[\s\S]*?(?:pi-studio-re|pi-rc-)[\s\S]*?\.sh[\s\S]*?(?:done\.flag|[a-f0-9]{16}\.done).*$/gm,
|
|
11838
|
-
];
|
|
11839
|
-
for (const pattern of submissionEchoPatterns) value = value.replace(pattern, "");
|
|
11840
|
-
return value.replace(/^(?:\s*\n)+/, "").replace(/[\t ]+$/gm, "").trimEnd();
|
|
11841
|
-
}
|
|
11842
|
-
|
|
11843
|
-
function stripTrailingStudioReplPrompts(output: string): string {
|
|
11844
|
-
const lines = String(output || "").replace(/\r\n/g, "\n").split("\n");
|
|
11845
|
-
while (lines.length > 0 && /^\s*(?:>>>|\.\.\.|In \[\d+\]:|julia>|>|\+|ghci>|Prelude>|\*?[A-Za-z0-9_.:]+>|[^\s>]+=>)\s*$/.test(lines[lines.length - 1] || "")) {
|
|
11846
|
-
lines.pop();
|
|
11847
|
-
}
|
|
11848
|
-
return lines.join("\n").trimEnd();
|
|
11849
|
-
}
|
|
11850
|
-
|
|
11851
|
-
function stripStudioReplCompletionEcho(output: string, completionLine?: string): string {
|
|
11852
|
-
const value = String(output || "");
|
|
11853
|
-
if (!completionLine) return value;
|
|
11854
|
-
const completionIndex = value.lastIndexOf(completionLine);
|
|
11855
|
-
if (completionIndex < 0) return value;
|
|
11856
|
-
const lineStart = value.lastIndexOf("\n", completionIndex - 1) + 1;
|
|
11857
|
-
const newlineIndex = value.indexOf("\n", completionIndex + completionLine.length);
|
|
11858
|
-
const lineEnd = newlineIndex < 0 ? value.length : newlineIndex + 1;
|
|
11859
|
-
return value.slice(0, lineStart) + value.slice(lineEnd);
|
|
11860
|
-
}
|
|
11861
|
-
|
|
11862
|
-
function cleanStudioReplCapturedOutput(output: string, display?: ReplSubmissionDisplay, completionLine?: string): string {
|
|
11863
|
-
const completionCleaned = stripStudioReplCompletionEcho(output, completionLine);
|
|
11864
|
-
const displayCleaned = display ? stripReplSubmissionDisplay(completionCleaned, display) : completionCleaned;
|
|
11865
|
-
return stripTrailingStudioReplPrompts(stripStudioReplSubmissionEcho(displayCleaned));
|
|
11866
|
-
}
|
|
11867
|
-
|
|
11868
11934
|
function normalizeStudioReplJournalMode(mode: unknown): StudioReplJournalEntry["mode"] {
|
|
11869
11935
|
return mode === "literate" || mode === "agent" ? mode : "raw";
|
|
11870
11936
|
}
|
|
@@ -12105,7 +12171,7 @@ function retainStudioReplSubmissionUntilSettled(
|
|
|
12105
12171
|
await sleepWithoutKeepingStudioProcessAlive(100);
|
|
12106
12172
|
}
|
|
12107
12173
|
} finally {
|
|
12108
|
-
|
|
12174
|
+
cleanupStudioReplControlFiles(controlFiles);
|
|
12109
12175
|
await lease?.release().catch(() => undefined);
|
|
12110
12176
|
}
|
|
12111
12177
|
})();
|
|
@@ -12120,13 +12186,15 @@ async function releaseOrRetainStudioReplSubmission(
|
|
|
12120
12186
|
retainStudioReplSubmissionUntilSettled(session, controlFiles, lease);
|
|
12121
12187
|
return;
|
|
12122
12188
|
}
|
|
12123
|
-
|
|
12189
|
+
cleanupStudioReplControlFiles(controlFiles);
|
|
12124
12190
|
await lease?.release().catch(() => undefined);
|
|
12125
12191
|
}
|
|
12126
12192
|
|
|
12127
12193
|
function interruptStudioReplSession(sessionName: string): { ok: true; message: string } | { ok: false; message: string } {
|
|
12128
12194
|
if (!/^[-_.A-Za-z0-9]+$/.test(sessionName)) return { ok: false, message: "Invalid REPL session name." };
|
|
12129
|
-
const
|
|
12195
|
+
const session = inspectStudioReplSession(sessionName);
|
|
12196
|
+
if (!session) return { ok: false, message: `No tmux REPL session named ${sessionName}.` };
|
|
12197
|
+
const result = runStudioTmux(["send-keys", "-t", session.target, "C-c"], { timeout: 5_000 });
|
|
12130
12198
|
if (!result.ok) return { ok: false, message: result.message || "Failed to interrupt REPL session." };
|
|
12131
12199
|
return { ok: true, message: `Interrupted ${sessionName}.` };
|
|
12132
12200
|
}
|
|
@@ -13222,7 +13290,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
13222
13290
|
contextWindow: null,
|
|
13223
13291
|
percent: null,
|
|
13224
13292
|
};
|
|
13225
|
-
|
|
13293
|
+
// The selected REPL session, the count of explicit actions and `shown`.
|
|
13294
|
+
const studioReplSelection = createReplSelection<StudioReplSessionInfo>();
|
|
13226
13295
|
let compactInProgress = false;
|
|
13227
13296
|
let compactRequestId: string | null = null;
|
|
13228
13297
|
const activeCompletionSuggestions = new Map<string, AbortController>();
|
|
@@ -13283,6 +13352,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
13283
13352
|
const state = listStudioReplSessions();
|
|
13284
13353
|
const sessions = state.sessions;
|
|
13285
13354
|
if (!state.tmuxAvailable) return { session: null, error: "tmux is not available.", sessions };
|
|
13355
|
+
studioReplSelection.resolve(sessions);
|
|
13286
13356
|
if (typeof params.sessionName === "string" && params.sessionName.trim()) {
|
|
13287
13357
|
const requested = params.sessionName.trim();
|
|
13288
13358
|
const session = sessions.find((candidate) => candidate.sessionName === requested) ?? null;
|
|
@@ -13290,19 +13360,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
13290
13360
|
? { session, sessions }
|
|
13291
13361
|
: { session: null, error: `No Studio-visible REPL session named ${requested}.`, sessions };
|
|
13292
13362
|
}
|
|
13363
|
+
const selected = studioReplSelection.session;
|
|
13364
|
+
const active = selected ? sessions.find((candidate) => sameReplSessionLifetime(candidate, selected)) ?? null : null;
|
|
13293
13365
|
const target = normalizeStudioReplRuntime(params.target);
|
|
13294
13366
|
if (target) {
|
|
13295
|
-
const
|
|
13296
|
-
? sessions.find((candidate) => candidate.sessionName === studioReplActiveSessionName && candidate.runtime === target)
|
|
13297
|
-
: null;
|
|
13298
|
-
const session = active ?? sessions.find((candidate) => candidate.runtime === target) ?? null;
|
|
13367
|
+
const session = (active?.runtime === target ? active : null) ?? sessions.find((candidate) => candidate.runtime === target) ?? null;
|
|
13299
13368
|
return session
|
|
13300
13369
|
? { session, sessions }
|
|
13301
13370
|
: { session: null, error: `No running Studio-visible ${STUDIO_REPL_RUNTIME_LABELS[target]} REPL session.`, sessions };
|
|
13302
13371
|
}
|
|
13303
|
-
if (
|
|
13304
|
-
|
|
13305
|
-
|
|
13372
|
+
if (active) return { session: active, sessions };
|
|
13373
|
+
// With no session shown for the selected runtime, an unaddressed send doesn't
|
|
13374
|
+
// guess another interpreter (Sol, Astra).
|
|
13375
|
+
if (studioReplSelection.hasPreference) {
|
|
13376
|
+
return { session: null, error: "No REPL session is selected in Studio. Choose one under Session, or pass sessionName.", sessions };
|
|
13306
13377
|
}
|
|
13307
13378
|
return sessions[0]
|
|
13308
13379
|
? { session: sessions[0], sessions }
|
|
@@ -13336,7 +13407,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
13336
13407
|
const selected = selectStudioReplSessionForTool({ sessionName: params.sessionName, target: params.target });
|
|
13337
13408
|
const recordEntries = selected.session ? getStudioReplJournalEntries(selected.session.sessionName) : [];
|
|
13338
13409
|
const lines = [
|
|
13339
|
-
`Active Studio REPL: ${
|
|
13410
|
+
`Active Studio REPL: ${studioReplSelection.session?.sessionName || "none"}`,
|
|
13340
13411
|
`tmux sessions visible to Studio: ${selected.sessions.length}`,
|
|
13341
13412
|
];
|
|
13342
13413
|
if (selected.error) lines.push(`Selection: ${selected.error}`);
|
|
@@ -13353,7 +13424,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
13353
13424
|
return {
|
|
13354
13425
|
content: [{ type: "text", text: lines.join("\n") }],
|
|
13355
13426
|
details: {
|
|
13356
|
-
activeSessionName:
|
|
13427
|
+
activeSessionName: studioReplSelection.session?.sessionName ?? null,
|
|
13357
13428
|
selectedSession: selected.session,
|
|
13358
13429
|
selectedRecordEntries: recordEntries,
|
|
13359
13430
|
sessions: selected.sessions,
|
|
@@ -13383,7 +13454,30 @@ export default function (pi: ExtensionAPI) {
|
|
|
13383
13454
|
details: { ok: false, error: selected.error || "No Studio REPL session selected.", sessions: selected.sessions } as Record<string, unknown>,
|
|
13384
13455
|
};
|
|
13385
13456
|
}
|
|
13457
|
+
if (selected.session.runtime === "unknown") {
|
|
13458
|
+
const message = unsupportedStudioReplRuntimeMessage(selected.session.sessionName);
|
|
13459
|
+
return {
|
|
13460
|
+
content: [{ type: "text", text: message }],
|
|
13461
|
+
details: { ok: false, error: message, session: selected.session, sessions: selected.sessions } as Record<string, unknown>,
|
|
13462
|
+
};
|
|
13463
|
+
}
|
|
13386
13464
|
|
|
13465
|
+
// Input that can't be sent is refused before it counts as a choice (Sol).
|
|
13466
|
+
const inputError = !params.code.trim()
|
|
13467
|
+
? "Editor text is empty."
|
|
13468
|
+
: (params.code.length > STUDIO_REPL_SEND_MAX_CHARS ? `REPL input is too large (${params.code.length} chars; max ${STUDIO_REPL_SEND_MAX_CHARS}).` : "");
|
|
13469
|
+
if (inputError) {
|
|
13470
|
+
return {
|
|
13471
|
+
content: [{ type: "text", text: inputError }],
|
|
13472
|
+
details: { ok: false, error: inputError, session: selected.session, sessions: selected.sessions } as Record<string, unknown>,
|
|
13473
|
+
};
|
|
13474
|
+
}
|
|
13475
|
+
// The agent's send is an explicit choice when admitted, before it waits for the
|
|
13476
|
+
// session's lease; only a ticket still current lets its result select (Sol, Astra).
|
|
13477
|
+
const ticket = studioReplSelection.choose({ session: selected.session });
|
|
13478
|
+
// Every exit after admission tells the pages Studio's selection: the result when
|
|
13479
|
+
// it selects, otherwise a repl_state, so a failure can't leave them behind (Sol).
|
|
13480
|
+
let published = false;
|
|
13387
13481
|
const timeoutMs = clampStudioReplSendTimeout(params.timeoutMs);
|
|
13388
13482
|
let lease: Awaited<ReturnType<typeof acquireReplSessionSendLease>> | null = null;
|
|
13389
13483
|
let submittedSession: StudioReplSessionInfo | null = null;
|
|
@@ -13404,7 +13498,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
13404
13498
|
if (lease && currentSession.recordId !== selected.session.recordId) {
|
|
13405
13499
|
throw new Error(`The shared record for ${selected.session.sessionName} changed while Studio was waiting to send.`);
|
|
13406
13500
|
}
|
|
13407
|
-
const before = captureStudioReplSession(currentSession.sessionName);
|
|
13501
|
+
const before = captureStudioReplSession(currentSession.sessionName, currentSession);
|
|
13408
13502
|
if (!before.ok) throw new Error(`Could not capture ${currentSession.sessionName} before sending: ${before.message}`);
|
|
13409
13503
|
if (!isSameStudioReplSessionLifetime(currentSession, before.session)) {
|
|
13410
13504
|
throw new Error(`REPL session ${currentSession.sessionName} changed before Studio could capture it.`);
|
|
@@ -13440,7 +13534,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
13440
13534
|
};
|
|
13441
13535
|
}
|
|
13442
13536
|
submittedSession = currentSession;
|
|
13443
|
-
studioReplActiveSessionName = selected.session.sessionName;
|
|
13444
13537
|
|
|
13445
13538
|
let completed = false;
|
|
13446
13539
|
if (sent.controlFiles?.doneFile) {
|
|
@@ -13451,14 +13544,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
13451
13544
|
} else {
|
|
13452
13545
|
await sleep(Math.min(750, timeoutMs));
|
|
13453
13546
|
}
|
|
13454
|
-
const after = captureStudioReplSession(currentSession.sessionName);
|
|
13547
|
+
const after = captureStudioReplSession(currentSession.sessionName, currentSession);
|
|
13455
13548
|
if (!after.ok) throw new Error(`Could not capture ${currentSession.sessionName} after sending: ${after.message}`);
|
|
13456
13549
|
if (!isSameStudioReplSessionLifetime(currentSession, after.session)) {
|
|
13457
13550
|
throw new Error(`REPL session ${currentSession.sessionName} changed before Studio captured the result.`);
|
|
13458
13551
|
}
|
|
13459
13552
|
const afterTranscript = after.transcript;
|
|
13460
13553
|
const rawOutput = extractStudioReplTranscriptDelta(beforeTranscript, afterTranscript);
|
|
13461
|
-
const output =
|
|
13554
|
+
const output = cleanStudioReplOutput(rawOutput, {
|
|
13555
|
+
submissionLine: sent.submissionText,
|
|
13556
|
+
runtime: sent.runtime,
|
|
13557
|
+
display: sent.display,
|
|
13558
|
+
promptLine: beforeTranscript.split("\n").at(-1),
|
|
13559
|
+
completed,
|
|
13560
|
+
});
|
|
13462
13561
|
const status: StudioReplJournalEntry["status"] = sent.controlFiles?.doneFile
|
|
13463
13562
|
? (completed ? "captured" : "timeout")
|
|
13464
13563
|
: (output.trim() ? "captured" : "sent");
|
|
@@ -13477,6 +13576,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
13477
13576
|
output ? "" : undefined,
|
|
13478
13577
|
output || undefined,
|
|
13479
13578
|
].filter(Boolean).join("\n");
|
|
13579
|
+
// Its result selects only if nothing was chosen since it was admitted.
|
|
13580
|
+
const select = studioReplSelection.isCurrent(ticket);
|
|
13581
|
+
if (select) studioReplSelection.show({ session: currentSession });
|
|
13582
|
+
published = true;
|
|
13480
13583
|
broadcastStudioReplToolSend({
|
|
13481
13584
|
toolCallId,
|
|
13482
13585
|
sessionName: selected.session.sessionName,
|
|
@@ -13487,9 +13590,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
13487
13590
|
sharedSynced: journalEntry.sharedSynced === true,
|
|
13488
13591
|
completed,
|
|
13489
13592
|
timedOut: Boolean(sent.controlFiles?.doneFile && !completed),
|
|
13490
|
-
transcript: afterTranscript,
|
|
13491
|
-
capturedAt: Date.now(),
|
|
13492
13593
|
journalEntries: getStudioReplJournalEntries(selected.session.sessionName),
|
|
13594
|
+
select,
|
|
13595
|
+
...(select ? {
|
|
13596
|
+
session: currentSession,
|
|
13597
|
+
activeSessionName: currentSession.sessionName,
|
|
13598
|
+
activeRuntime: currentSession.runtime,
|
|
13599
|
+
shown: studioReplSelection.shown,
|
|
13600
|
+
transcript: afterTranscript,
|
|
13601
|
+
capturedAt: Date.now(),
|
|
13602
|
+
} : {}),
|
|
13493
13603
|
});
|
|
13494
13604
|
return {
|
|
13495
13605
|
content: [{ type: "text", text }],
|
|
@@ -13526,6 +13636,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
13526
13636
|
}
|
|
13527
13637
|
throw error;
|
|
13528
13638
|
} finally {
|
|
13639
|
+
if (!published) broadcastStudioReplSelection();
|
|
13529
13640
|
await releaseOrRetainStudioReplSubmission(lease, submittedSession, submittedControlFiles);
|
|
13530
13641
|
lease = null;
|
|
13531
13642
|
submittedControlFiles = undefined;
|
|
@@ -14328,54 +14439,56 @@ export default function (pi: ExtensionAPI) {
|
|
|
14328
14439
|
return next;
|
|
14329
14440
|
};
|
|
14330
14441
|
|
|
14442
|
+
// A REPL reply for a page that has closed, which includes every page from before
|
|
14443
|
+
// a Studio restart, leaves Studio's active REPL session alone (Sol).
|
|
14444
|
+
const isOpenReplClient = (client: WebSocket): boolean => (
|
|
14445
|
+
!studioRestarting && Boolean(serverState?.clients.has(client)) && client.readyState === WebSocket.OPEN
|
|
14446
|
+
);
|
|
14447
|
+
|
|
14448
|
+
// Every repl_state states Studio's selection as it is when sent. The page adopts it,
|
|
14449
|
+
// and its `shown` is the basis for the page's own reconciles (Sol, Astra).
|
|
14331
14450
|
const sendReplStateToClient = (client: WebSocket, extra?: Record<string, unknown>) => {
|
|
14451
|
+
if (!isOpenReplClient(client)) return;
|
|
14332
14452
|
const state = listStudioReplSessions();
|
|
14333
|
-
|
|
14334
|
-
|
|
14335
|
-
} else if (!studioReplActiveSessionName && state.sessions.length > 0) {
|
|
14336
|
-
studioReplActiveSessionName = state.sessions[0].sessionName;
|
|
14337
|
-
}
|
|
14453
|
+
studioReplSelection.resolve(state.sessions);
|
|
14454
|
+
const active = studioReplSelection.session;
|
|
14338
14455
|
sendToClient(client, {
|
|
14339
14456
|
type: "repl_state",
|
|
14340
14457
|
tmuxAvailable: state.tmuxAvailable,
|
|
14341
14458
|
sessions: state.sessions,
|
|
14342
|
-
activeSessionName:
|
|
14343
|
-
|
|
14459
|
+
activeSessionName: active?.sessionName ?? null,
|
|
14460
|
+
activeRuntime: studioReplSelection.runtime,
|
|
14461
|
+
shown: studioReplSelection.shown,
|
|
14462
|
+
journalEntries: getStudioReplJournalEntries(active?.sessionName ?? null),
|
|
14344
14463
|
error: state.error ?? null,
|
|
14345
14464
|
...extra,
|
|
14346
14465
|
});
|
|
14347
14466
|
};
|
|
14348
14467
|
|
|
14349
|
-
|
|
14350
|
-
|
|
14351
|
-
|
|
14352
|
-
|
|
14353
|
-
if (!
|
|
14354
|
-
sendReplStateToClient(client,
|
|
14355
|
-
transcript: "",
|
|
14356
|
-
capturedAt: Date.now(),
|
|
14357
|
-
journalEntries: [],
|
|
14358
|
-
...extra,
|
|
14359
|
-
});
|
|
14468
|
+
// The selected session's capture, as the selection is when sent.
|
|
14469
|
+
const sendReplSelectionToClient = (client: WebSocket, extra?: Record<string, unknown>) => {
|
|
14470
|
+
if (!isOpenReplClient(client)) return;
|
|
14471
|
+
const selected = studioReplSelection.session;
|
|
14472
|
+
if (!selected) {
|
|
14473
|
+
sendReplStateToClient(client, extra);
|
|
14360
14474
|
return;
|
|
14361
14475
|
}
|
|
14362
|
-
const captured = captureStudioReplSession(
|
|
14363
|
-
if (!captured.ok) {
|
|
14476
|
+
const captured = captureStudioReplSession(selected.sessionName);
|
|
14477
|
+
if (!captured.ok || !sameReplSessionLifetime(captured.session, selected)) {
|
|
14364
14478
|
sendReplStateToClient(client, {
|
|
14365
|
-
activeSessionName: targetSession,
|
|
14366
14479
|
transcript: "",
|
|
14367
|
-
captureError: captured.message,
|
|
14480
|
+
captureError: captured.ok ? `REPL session ${selected.sessionName} changed before Studio could capture it.` : captured.message,
|
|
14368
14481
|
capturedAt: Date.now(),
|
|
14369
|
-
journalEntries: getStudioReplJournalEntries(targetSession),
|
|
14370
14482
|
...extra,
|
|
14371
14483
|
});
|
|
14372
14484
|
return;
|
|
14373
14485
|
}
|
|
14374
|
-
studioReplActiveSessionName = captured.session.sessionName;
|
|
14375
14486
|
sendToClient(client, {
|
|
14376
14487
|
type: "repl_capture",
|
|
14377
14488
|
session: captured.session,
|
|
14378
14489
|
activeSessionName: captured.session.sessionName,
|
|
14490
|
+
activeRuntime: captured.session.runtime,
|
|
14491
|
+
shown: studioReplSelection.shown,
|
|
14379
14492
|
transcript: captured.transcript,
|
|
14380
14493
|
capturedAt: Date.now(),
|
|
14381
14494
|
journalEntries: getStudioReplJournalEntries(captured.session.sessionName),
|
|
@@ -14383,6 +14496,41 @@ export default function (pi: ExtensionAPI) {
|
|
|
14383
14496
|
});
|
|
14384
14497
|
};
|
|
14385
14498
|
|
|
14499
|
+
// A poll's reply: what one session shows now. It carries no selection (Sol, Astra).
|
|
14500
|
+
const sendReplObservationToClient = (client: WebSocket, sessionName: string) => {
|
|
14501
|
+
if (!isOpenReplClient(client)) return;
|
|
14502
|
+
const captured = captureStudioReplSession(sessionName);
|
|
14503
|
+
sendToClient(client, captured.ok ? {
|
|
14504
|
+
type: "repl_capture",
|
|
14505
|
+
observation: true,
|
|
14506
|
+
session: captured.session,
|
|
14507
|
+
transcript: captured.transcript,
|
|
14508
|
+
capturedAt: Date.now(),
|
|
14509
|
+
journalEntries: getStudioReplJournalEntries(captured.session.sessionName),
|
|
14510
|
+
} : {
|
|
14511
|
+
type: "repl_capture",
|
|
14512
|
+
observation: true,
|
|
14513
|
+
sessionName,
|
|
14514
|
+
captureError: captured.message,
|
|
14515
|
+
journalEntries: getStudioReplJournalEntries(sessionName),
|
|
14516
|
+
});
|
|
14517
|
+
};
|
|
14518
|
+
|
|
14519
|
+
// Studio's selection, to every open page.
|
|
14520
|
+
const broadcastStudioReplSelection = () => {
|
|
14521
|
+
for (const client of serverState?.clients ?? []) sendReplStateToClient(client);
|
|
14522
|
+
};
|
|
14523
|
+
|
|
14524
|
+
// A send's result selects only if nothing was chosen since the send was admitted.
|
|
14525
|
+
// Otherwise the page gets the send's record entries and keeps its choice.
|
|
14526
|
+
const sendReplSendResultToClient = (client: WebSocket, ticket: number | null, sessionName: string, extra: Record<string, unknown>) => {
|
|
14527
|
+
if (ticket !== null && studioReplSelection.isCurrent(ticket)) {
|
|
14528
|
+
sendReplSelectionToClient(client, extra);
|
|
14529
|
+
return;
|
|
14530
|
+
}
|
|
14531
|
+
sendReplStateToClient(client, { journalEntries: getStudioReplJournalEntries(sessionName), ...extra });
|
|
14532
|
+
};
|
|
14533
|
+
|
|
14386
14534
|
const emitDebugEvent = (event: string, details?: Record<string, unknown>) => {
|
|
14387
14535
|
broadcast({
|
|
14388
14536
|
type: "debug_event",
|
|
@@ -16421,7 +16569,41 @@ export default function (pi: ExtensionAPI) {
|
|
|
16421
16569
|
}
|
|
16422
16570
|
|
|
16423
16571
|
if (msg.type === "repl_capture_request") {
|
|
16424
|
-
|
|
16572
|
+
if (!msg.choice) {
|
|
16573
|
+
// A poll observes one session; with none, it asks for the list.
|
|
16574
|
+
if (msg.sessionName) sendReplObservationToClient(client, msg.sessionName);
|
|
16575
|
+
else sendReplStateToClient(client);
|
|
16576
|
+
return;
|
|
16577
|
+
}
|
|
16578
|
+
if (!isOpenReplClient(client)) return;
|
|
16579
|
+
// A choice on the page's Session or Runtime selector counts, even of a session
|
|
16580
|
+
// that has gone. Its reply carries its revision, so the page stops waiting.
|
|
16581
|
+
const answer = { choiceRevision: msg.choiceRevision };
|
|
16582
|
+
const session = msg.sessionName ? inspectStudioReplSession(msg.sessionName) : null;
|
|
16583
|
+
if (session) {
|
|
16584
|
+
studioReplSelection.choose({ session });
|
|
16585
|
+
sendReplSelectionToClient(client, answer);
|
|
16586
|
+
return;
|
|
16587
|
+
}
|
|
16588
|
+
// A session that has gone, or a runtime with none: no session, for that runtime.
|
|
16589
|
+
const replError = msg.sessionName ? `No tmux REPL session named ${msg.sessionName}.` : (msg.runtime ? "" : "Choose a runtime or a session.");
|
|
16590
|
+
if (msg.runtime) studioReplSelection.choose({ runtime: msg.runtime });
|
|
16591
|
+
sendReplStateToClient(client, { ...answer, ...(replError ? { replError } : {}) });
|
|
16592
|
+
return;
|
|
16593
|
+
}
|
|
16594
|
+
|
|
16595
|
+
if (msg.type === "repl_reconcile_request") {
|
|
16596
|
+
// The page's own selection applies only if the page saw the selection as it is
|
|
16597
|
+
// now. It doesn't count, so an action in progress keeps its ticket (Sol, Astra).
|
|
16598
|
+
if (msg.sessionName) {
|
|
16599
|
+
const session = inspectStudioReplSession(msg.sessionName);
|
|
16600
|
+
if (session && session.tmuxSessionId === msg.tmuxSessionId && session.tmuxSessionCreatedAt === msg.tmuxSessionCreatedAt) {
|
|
16601
|
+
studioReplSelection.reconcile(msg.basis, { session });
|
|
16602
|
+
}
|
|
16603
|
+
} else if (msg.runtime) {
|
|
16604
|
+
studioReplSelection.reconcile(msg.basis, { runtime: msg.runtime });
|
|
16605
|
+
}
|
|
16606
|
+
sendReplStateToClient(client);
|
|
16425
16607
|
return;
|
|
16426
16608
|
}
|
|
16427
16609
|
|
|
@@ -16430,15 +16612,30 @@ export default function (pi: ExtensionAPI) {
|
|
|
16430
16612
|
sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
|
|
16431
16613
|
return;
|
|
16432
16614
|
}
|
|
16433
|
-
|
|
16434
|
-
|
|
16435
|
-
|
|
16436
|
-
|
|
16437
|
-
|
|
16438
|
-
|
|
16439
|
-
|
|
16440
|
-
|
|
16441
|
-
|
|
16615
|
+
// Waits for the runtime's prompt; the page shows "Starting…" meanwhile. The request
|
|
16616
|
+
// counts on arrival, so of two overlapping Starts the newer one selects (Sol).
|
|
16617
|
+
const ticket = studioReplSelection.startRequested();
|
|
16618
|
+
void (async () => {
|
|
16619
|
+
const started = await startStudioReplSession(msg.runtime, studioCwd, { newSession: msg.newSession, command: msg.command });
|
|
16620
|
+
// Studio may have restarted, or the page closed, while the runtime started.
|
|
16621
|
+
if (!isOpenReplClient(client)) return;
|
|
16622
|
+
if (!started.ok) {
|
|
16623
|
+
sendReplStateToClient(client, { requestId: msg.requestId, replError: started.message });
|
|
16624
|
+
sendToClient(client, { type: "error", requestId: msg.requestId, message: started.message });
|
|
16625
|
+
return;
|
|
16626
|
+
}
|
|
16627
|
+
// A session chosen while this one started stays selected, unless the latest
|
|
16628
|
+
// choice was this session itself (Astra), or it is already selected, as when
|
|
16629
|
+
// two Starts reach the same session.
|
|
16630
|
+
const unchanged = sameReplSessionLifetime(studioReplSelection.session, started.session);
|
|
16631
|
+
if (!studioReplSelection.isCurrent(ticket) && !studioReplSelection.latestChose(started.session) && !unchanged) {
|
|
16632
|
+
sendReplStateToClient(client, { requestId: msg.requestId, replMessage: `${started.message} Select ${started.session.sessionName} under Session to use it.` });
|
|
16633
|
+
return;
|
|
16634
|
+
}
|
|
16635
|
+
studioReplSelection.show({ session: started.session });
|
|
16636
|
+
sendReplStateToClient(client, { requestId: msg.requestId, replMessage: started.message });
|
|
16637
|
+
sendReplSelectionToClient(client, { requestId: msg.requestId, replMessage: started.message });
|
|
16638
|
+
})();
|
|
16442
16639
|
return;
|
|
16443
16640
|
}
|
|
16444
16641
|
|
|
@@ -16447,14 +16644,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
16447
16644
|
sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
|
|
16448
16645
|
return;
|
|
16449
16646
|
}
|
|
16450
|
-
|
|
16451
|
-
|
|
16452
|
-
|
|
16453
|
-
|
|
16454
|
-
|
|
16455
|
-
|
|
16456
|
-
|
|
16457
|
-
|
|
16647
|
+
void (async () => {
|
|
16648
|
+
const stopped = await stopStudioReplSession(msg.sessionName);
|
|
16649
|
+
if (!isOpenReplClient(client)) return;
|
|
16650
|
+
if (!stopped.ok) {
|
|
16651
|
+
sendReplStateToClient(client, { requestId: msg.requestId, replError: stopped.message });
|
|
16652
|
+
sendToClient(client, { type: "error", requestId: msg.requestId, message: stopped.message });
|
|
16653
|
+
return;
|
|
16654
|
+
}
|
|
16655
|
+
// Stop clears the view only if the session it stopped is still selected (Astra).
|
|
16656
|
+
// Listing then selects another of its runtime, or none.
|
|
16657
|
+
const cleared = sameReplSessionLifetime(studioReplSelection.session, stopped.session);
|
|
16658
|
+
sendReplStateToClient(client, { requestId: msg.requestId, replMessage: stopped.message, ...(cleared ? { transcript: "", capturedAt: Date.now() } : {}) });
|
|
16659
|
+
})();
|
|
16458
16660
|
return;
|
|
16459
16661
|
}
|
|
16460
16662
|
|
|
@@ -16469,8 +16671,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
16469
16671
|
let lease: Awaited<ReturnType<typeof acquireReplSessionSendLease>> | null = null;
|
|
16470
16672
|
let submittedSession: StudioReplSessionInfo | null = null;
|
|
16471
16673
|
let submittedControlFiles: StudioReplControlFiles | undefined;
|
|
16674
|
+
let ticket: number | null = null;
|
|
16472
16675
|
try {
|
|
16473
16676
|
if (!session) throw new Error(`No tmux REPL session named ${msg.sessionName}.`);
|
|
16677
|
+
if (session.runtime === "unknown") throw new Error(unsupportedStudioReplRuntimeMessage(msg.sessionName));
|
|
16678
|
+
// The send is an explicit choice when admitted, before it waits for the
|
|
16679
|
+
// session's lease, even of the session already selected (Sol, Astra).
|
|
16680
|
+
if (isOpenReplClient(client)) ticket = studioReplSelection.choose({ session });
|
|
16474
16681
|
if (session.recordId && !session.recordWarning) {
|
|
16475
16682
|
lease = await acquireReplSessionSendLease(session.recordId, {
|
|
16476
16683
|
owner: `pi-studio:browser:${msg.requestId}`,
|
|
@@ -16484,7 +16691,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
16484
16691
|
if (lease && currentSession.recordId !== session.recordId) {
|
|
16485
16692
|
throw new Error(`The shared record for ${msg.sessionName} changed while Studio was waiting to send.`);
|
|
16486
16693
|
}
|
|
16487
|
-
const before = captureStudioReplSession(msg.sessionName);
|
|
16694
|
+
const before = captureStudioReplSession(msg.sessionName, currentSession);
|
|
16488
16695
|
if (!before.ok) throw new Error(`Could not capture ${msg.sessionName} before sending: ${before.message}`);
|
|
16489
16696
|
if (!isSameStudioReplSessionLifetime(currentSession, before.session)) {
|
|
16490
16697
|
throw new Error(`REPL session ${msg.sessionName} changed before Studio could capture it.`);
|
|
@@ -16514,7 +16721,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
16514
16721
|
throw new Error(sent.message);
|
|
16515
16722
|
}
|
|
16516
16723
|
submittedSession = currentSession;
|
|
16517
|
-
studioReplActiveSessionName = msg.sessionName;
|
|
16518
16724
|
sendToClient(client, {
|
|
16519
16725
|
type: "repl_send_ack",
|
|
16520
16726
|
requestId: msg.requestId,
|
|
@@ -16529,14 +16735,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
16529
16735
|
} else {
|
|
16530
16736
|
await sleep(Math.min(750, timeoutMs));
|
|
16531
16737
|
}
|
|
16532
|
-
const after = captureStudioReplSession(msg.sessionName);
|
|
16738
|
+
const after = captureStudioReplSession(msg.sessionName, currentSession);
|
|
16533
16739
|
if (!after.ok) throw new Error(`Could not capture ${msg.sessionName} after sending: ${after.message}`);
|
|
16534
16740
|
if (!isSameStudioReplSessionLifetime(currentSession, after.session)) {
|
|
16535
16741
|
throw new Error(`REPL session ${msg.sessionName} changed before Studio captured the result.`);
|
|
16536
16742
|
}
|
|
16537
16743
|
const afterTranscript = after.transcript;
|
|
16538
16744
|
const rawOutput = extractStudioReplTranscriptDelta(beforeTranscript, afterTranscript);
|
|
16539
|
-
const output =
|
|
16745
|
+
const output = cleanStudioReplOutput(rawOutput, {
|
|
16746
|
+
submissionLine: sent.submissionText,
|
|
16747
|
+
runtime: sent.runtime,
|
|
16748
|
+
display: sent.display,
|
|
16749
|
+
promptLine: beforeTranscript.split("\n").at(-1),
|
|
16750
|
+
completed,
|
|
16751
|
+
});
|
|
16540
16752
|
updateStudioReplJournalEntryOutput(
|
|
16541
16753
|
msg.requestId,
|
|
16542
16754
|
msg.sessionName,
|
|
@@ -16544,7 +16756,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
16544
16756
|
sent.controlFiles?.doneFile ? (completed ? "captured" : "timeout") : (output.trim() ? "captured" : "sent"),
|
|
16545
16757
|
currentSession,
|
|
16546
16758
|
);
|
|
16547
|
-
|
|
16759
|
+
sendReplSendResultToClient(client, ticket, msg.sessionName, { requestId: msg.requestId });
|
|
16548
16760
|
} catch (error) {
|
|
16549
16761
|
const message = error instanceof Error ? error.message : String(error);
|
|
16550
16762
|
if (journalEntry) updateStudioReplJournalEntryOutput(
|
|
@@ -16555,7 +16767,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
16555
16767
|
submittedSession || session,
|
|
16556
16768
|
);
|
|
16557
16769
|
sendToClient(client, { type: "error", requestId: msg.requestId, message });
|
|
16558
|
-
|
|
16770
|
+
sendReplSendResultToClient(client, ticket, msg.sessionName, { requestId: msg.requestId, replError: message });
|
|
16559
16771
|
} finally {
|
|
16560
16772
|
await releaseOrRetainStudioReplSubmission(lease, submittedSession, submittedControlFiles);
|
|
16561
16773
|
lease = null;
|
|
@@ -16640,11 +16852,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
16640
16852
|
const interrupted = interruptStudioReplSession(msg.sessionName);
|
|
16641
16853
|
if (!interrupted.ok) {
|
|
16642
16854
|
sendToClient(client, { type: "error", requestId: msg.requestId, message: interrupted.message });
|
|
16643
|
-
|
|
16855
|
+
sendReplStateToClient(client, { requestId: msg.requestId, replError: interrupted.message });
|
|
16644
16856
|
return;
|
|
16645
16857
|
}
|
|
16646
|
-
|
|
16647
|
-
|
|
16858
|
+
// An interrupt is an explicit choice of its session.
|
|
16859
|
+
const interruptedSession = inspectStudioReplSession(msg.sessionName);
|
|
16860
|
+
if (interruptedSession) studioReplSelection.choose({ session: interruptedSession });
|
|
16861
|
+
sendReplSelectionToClient(client, { requestId: msg.requestId, replMessage: interrupted.message });
|
|
16648
16862
|
return;
|
|
16649
16863
|
}
|
|
16650
16864
|
|