pi-studio 0.10.1 → 0.10.3
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 +28 -0
- package/README.md +13 -18
- package/client/studio-client.js +284 -67
- package/index.ts +486 -273
- 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[] {
|
|
@@ -11554,9 +11640,8 @@ function buildStudioPythonControlSource(runtime: "python" | "ipython", code: str
|
|
|
11554
11640
|
" __pi_studio_ip = get_ipython()",
|
|
11555
11641
|
" if __pi_studio_ip is None:",
|
|
11556
11642
|
" raise RuntimeError('Expected IPython session, but get_ipython() returned None.')",
|
|
11557
|
-
|
|
11558
|
-
|
|
11559
|
-
" print(repr(__pi_studio_result.result))",
|
|
11643
|
+
// run_cell shows the value itself (Out[N]: ...), so nothing more is printed here.
|
|
11644
|
+
` __pi_studio_ip.run_cell(${JSON.stringify(code)}, store_history=False)`,
|
|
11560
11645
|
"except Exception:",
|
|
11561
11646
|
" __pi_studio_traceback.print_exc()",
|
|
11562
11647
|
"finally:",
|
|
@@ -11589,11 +11674,11 @@ function buildStudioPythonControlSource(runtime: "python" | "ipython", code: str
|
|
|
11589
11674
|
}
|
|
11590
11675
|
|
|
11591
11676
|
function buildStudioJuliaControlSource(code: string, doneFile: string, display: ReplSubmissionDisplay): string {
|
|
11592
|
-
const completion = display.enabled ? [` Base.println(${
|
|
11677
|
+
const completion = display.enabled ? [` Base.println(${juliaStringLiteral(display.endMarker)})`] : [];
|
|
11593
11678
|
return [
|
|
11594
11679
|
...buildStudioJuliaDisplayStatements(display),
|
|
11595
11680
|
"try",
|
|
11596
|
-
` local __pi_studio_result = Base.include_string(Main, ${
|
|
11681
|
+
` local __pi_studio_result = Base.include_string(Main, ${juliaStringLiteral(code)}, "pi-studio-repl")`,
|
|
11597
11682
|
" if !isnothing(__pi_studio_result)",
|
|
11598
11683
|
" println(repr(__pi_studio_result))",
|
|
11599
11684
|
" end",
|
|
@@ -11601,7 +11686,7 @@ function buildStudioJuliaControlSource(code: string, doneFile: string, display:
|
|
|
11601
11686
|
" Base.display_error(stderr, e, catch_backtrace())",
|
|
11602
11687
|
"finally",
|
|
11603
11688
|
...completion,
|
|
11604
|
-
` write(${
|
|
11689
|
+
` write(${juliaStringLiteral(doneFile)}, "done\\n")`,
|
|
11605
11690
|
"end",
|
|
11606
11691
|
].join("\n");
|
|
11607
11692
|
}
|
|
@@ -11639,7 +11724,8 @@ function buildStudioRControlSource(code: string, doneFile: string, display: Repl
|
|
|
11639
11724
|
].join("\n");
|
|
11640
11725
|
}
|
|
11641
11726
|
|
|
11642
|
-
function buildStudioGhciControlSource(code: string, display: ReplSubmissionDisplay): string {
|
|
11727
|
+
function buildStudioGhciControlSource(code: string, doneFile: string, display: ReplSubmissionDisplay): string {
|
|
11728
|
+
if (/[\r\n]/.test(doneFile)) throw new Error("GHCi control paths cannot contain line breaks.");
|
|
11643
11729
|
const prefix = display.enabled
|
|
11644
11730
|
? display.prefixLines.map((line) => `:! command printf '%s\\n' ${shellQuote(line)}`)
|
|
11645
11731
|
: [];
|
|
@@ -11689,7 +11775,7 @@ function buildStudioReplControlSource(runtime: StudioReplRuntime, code: string,
|
|
|
11689
11775
|
if (runtime === "python" || runtime === "ipython") return buildStudioPythonControlSource(runtime, code, doneFile, display);
|
|
11690
11776
|
if (runtime === "julia") return buildStudioJuliaControlSource(code, doneFile, display);
|
|
11691
11777
|
if (runtime === "r") return buildStudioRControlSource(code, doneFile, display);
|
|
11692
|
-
if (runtime === "ghci") return buildStudioGhciControlSource(code, display);
|
|
11778
|
+
if (runtime === "ghci") return buildStudioGhciControlSource(code, doneFile, display);
|
|
11693
11779
|
if (runtime === "clojure") return buildStudioClojureControlSource(code, doneFile, display);
|
|
11694
11780
|
if (runtime === "shell") return buildStudioShellControlSource(code, display);
|
|
11695
11781
|
return null;
|
|
@@ -11697,7 +11783,7 @@ function buildStudioReplControlSource(runtime: StudioReplRuntime, code: string,
|
|
|
11697
11783
|
|
|
11698
11784
|
function buildStudioReplSubmissionLine(runtime: StudioReplRuntime, sourceFile: string, doneFile: string): string {
|
|
11699
11785
|
const quotedPath = JSON.stringify(sourceFile);
|
|
11700
|
-
if (runtime === "julia") return `include(${
|
|
11786
|
+
if (runtime === "julia") return `include(${juliaStringLiteral(sourceFile)})`;
|
|
11701
11787
|
if (runtime === "r") return `source(${quotedPath}, local=.GlobalEnv)`;
|
|
11702
11788
|
if (runtime === "ghci") return `:script ${quotedPath}`;
|
|
11703
11789
|
if (runtime === "clojure") return `(do (load-file ${quotedPath}) :pi-studio/silent)`;
|
|
@@ -11705,51 +11791,74 @@ function buildStudioReplSubmissionLine(runtime: StudioReplRuntime, sourceFile: s
|
|
|
11705
11791
|
return `exec(open(${quotedPath}, encoding="utf-8").read(), globals())`;
|
|
11706
11792
|
}
|
|
11707
11793
|
|
|
11794
|
+
function unsupportedStudioReplRuntimeMessage(sessionName: string): string {
|
|
11795
|
+
return `Studio can't send code to ${sessionName} yet: it doesn't support that runtime.`;
|
|
11796
|
+
}
|
|
11797
|
+
|
|
11798
|
+
function cleanupStudioReplControlFiles(controlFiles: StudioReplControlFiles | undefined): void {
|
|
11799
|
+
if (!controlFiles) return;
|
|
11800
|
+
cleanupPrivateReplControlFiles(controlFiles);
|
|
11801
|
+
for (const helper of controlFiles.helpers ?? []) cleanupPrivateReplControlFiles(helper);
|
|
11802
|
+
}
|
|
11803
|
+
|
|
11708
11804
|
function prepareStudioReplSubmission(
|
|
11709
|
-
sessionName: string,
|
|
11710
11805
|
source: string,
|
|
11711
11806
|
details: { submissionId: string; echoMode: ReplSubmissionEchoMode },
|
|
11712
|
-
|
|
11807
|
+
runtime: StudioReplRuntime,
|
|
11713
11808
|
): StudioReplPreparedSubmission {
|
|
11714
11809
|
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
|
-
|
|
11810
|
+
const display = createReplSubmissionDisplay({
|
|
11811
|
+
entryId: details.submissionId,
|
|
11812
|
+
origin: "pi-studio",
|
|
11813
|
+
code: normalizedSource,
|
|
11814
|
+
mode: details.echoMode,
|
|
11815
|
+
});
|
|
11816
|
+
const controlFiles: StudioReplControlFiles = createPrivateReplControlFiles({
|
|
11817
|
+
extension: getStudioReplControlExtension(runtime),
|
|
11818
|
+
buildSource: ({ doneFile }: StudioReplControlFiles) => {
|
|
11819
|
+
const source = buildStudioReplControlSource(runtime, normalizedSource, doneFile, display);
|
|
11820
|
+
if (source === null) throw new Error(`No control-file wrapper is available for ${runtime}.`);
|
|
11821
|
+
return source;
|
|
11822
|
+
},
|
|
11823
|
+
});
|
|
11824
|
+
let loader = controlFiles.sourceFile;
|
|
11825
|
+
if (runtime === "ghci") {
|
|
11826
|
+
// As in pi-repl: the completion step runs from a driver script, never as
|
|
11827
|
+
// typed input that code reading stdin could take. An unclosed :{ ends the
|
|
11828
|
+
// guard's script, and the driver still completes.
|
|
11829
|
+
try {
|
|
11830
|
+
const guard = createPrivateReplControlFiles({
|
|
11831
|
+
extension: "ghci",
|
|
11832
|
+
buildSource: () => buildStudioReplSubmissionLine("ghci", controlFiles.sourceFile, controlFiles.doneFile) + "\n",
|
|
11833
|
+
});
|
|
11834
|
+
controlFiles.helpers = [guard];
|
|
11835
|
+
const driver = createPrivateReplControlFiles({
|
|
11836
|
+
extension: "ghci",
|
|
11837
|
+
buildSource: () => [
|
|
11838
|
+
buildStudioReplSubmissionLine("ghci", guard.sourceFile, controlFiles.doneFile),
|
|
11839
|
+
buildStudioGhciCompletionLine(controlFiles.doneFile, display),
|
|
11840
|
+
"",
|
|
11841
|
+
].join("\n"),
|
|
11842
|
+
});
|
|
11843
|
+
controlFiles.helpers.push(driver);
|
|
11844
|
+
loader = driver.sourceFile;
|
|
11845
|
+
} catch (error) {
|
|
11846
|
+
cleanupStudioReplControlFiles(controlFiles);
|
|
11847
|
+
throw error;
|
|
11848
|
+
}
|
|
11741
11849
|
}
|
|
11742
|
-
|
|
11743
11850
|
return {
|
|
11744
11851
|
runtime,
|
|
11745
|
-
usedControlFile:
|
|
11746
|
-
|
|
11852
|
+
usedControlFile: true,
|
|
11853
|
+
controlFiles,
|
|
11854
|
+
display,
|
|
11855
|
+
submissionText: buildStudioReplSubmissionLine(runtime, loader, controlFiles.doneFile),
|
|
11747
11856
|
};
|
|
11748
11857
|
}
|
|
11749
11858
|
|
|
11750
|
-
function pasteTextToStudioReplPane(
|
|
11859
|
+
function pasteTextToStudioReplPane(text: string, paneTarget: string): { ok: true } | { ok: false; message: string; submissionStarted: boolean } {
|
|
11751
11860
|
const bufferName = `pi-studio-repl-${randomUUID().replace(/-/g, "")}`;
|
|
11752
|
-
const target = paneTarget
|
|
11861
|
+
const target = paneTarget;
|
|
11753
11862
|
const loadResult = runStudioTmux(["load-buffer", "-b", bufferName, "-"], { input: text, timeout: 5_000 });
|
|
11754
11863
|
if (!loadResult.ok) return { ok: false, message: loadResult.message || "Failed to load text into tmux buffer.", submissionStarted: false };
|
|
11755
11864
|
try {
|
|
@@ -11766,21 +11875,23 @@ function pasteTextToStudioReplPane(sessionName: string, text: string, paneTarget
|
|
|
11766
11875
|
function sendTextToStudioReplSession(
|
|
11767
11876
|
sessionName: string,
|
|
11768
11877
|
text: string,
|
|
11769
|
-
paneTarget
|
|
11770
|
-
|
|
11878
|
+
paneTarget: string,
|
|
11879
|
+
runtime: StudioReplRuntime | "unknown",
|
|
11771
11880
|
options: { submissionId?: string; echoMode?: string } = {},
|
|
11772
11881
|
): StudioReplSendSuccess | StudioReplSendFailure {
|
|
11773
11882
|
if (!/^[-_.A-Za-z0-9]+$/.test(sessionName)) return { ok: false, message: "Invalid REPL session name." };
|
|
11883
|
+
// No raw pasting: without its runtime's wrapper, Studio can't tell when code has finished.
|
|
11884
|
+
if (runtime === "unknown") return { ok: false, message: unsupportedStudioReplRuntimeMessage(sessionName) };
|
|
11774
11885
|
const source = String(text || "");
|
|
11775
11886
|
if (!source.trim()) return { ok: false, message: "Editor text is empty." };
|
|
11776
11887
|
if (source.length > STUDIO_REPL_SEND_MAX_CHARS) {
|
|
11777
11888
|
return { ok: false, message: `REPL input is too large (${source.length} chars; max ${STUDIO_REPL_SEND_MAX_CHARS}).` };
|
|
11778
11889
|
}
|
|
11779
|
-
const prepared = prepareStudioReplSubmission(
|
|
11890
|
+
const prepared = prepareStudioReplSubmission(source, {
|
|
11780
11891
|
submissionId: options.submissionId || `pi-studio:local:${randomUUID()}`,
|
|
11781
11892
|
echoMode: normalizeReplSubmissionEchoMode(options.echoMode, STUDIO_REPL_TOOL_ECHO_MODE) as ReplSubmissionEchoMode,
|
|
11782
|
-
},
|
|
11783
|
-
const pasted = pasteTextToStudioReplPane(
|
|
11893
|
+
}, runtime);
|
|
11894
|
+
const pasted = pasteTextToStudioReplPane(prepared.submissionText, paneTarget);
|
|
11784
11895
|
if (!pasted.ok) return {
|
|
11785
11896
|
ok: false,
|
|
11786
11897
|
message: pasted.message,
|
|
@@ -11788,7 +11899,6 @@ function sendTextToStudioReplSession(
|
|
|
11788
11899
|
runtime: prepared.runtime,
|
|
11789
11900
|
usedControlFile: prepared.usedControlFile,
|
|
11790
11901
|
submissionText: prepared.submissionText,
|
|
11791
|
-
completionLine: prepared.completionLine,
|
|
11792
11902
|
controlFiles: prepared.controlFiles,
|
|
11793
11903
|
display: prepared.display,
|
|
11794
11904
|
};
|
|
@@ -11798,7 +11908,6 @@ function sendTextToStudioReplSession(
|
|
|
11798
11908
|
runtime: prepared.runtime,
|
|
11799
11909
|
usedControlFile: prepared.usedControlFile,
|
|
11800
11910
|
submissionText: prepared.submissionText,
|
|
11801
|
-
completionLine: prepared.completionLine,
|
|
11802
11911
|
controlFiles: prepared.controlFiles,
|
|
11803
11912
|
display: prepared.display,
|
|
11804
11913
|
};
|
|
@@ -11821,50 +11930,6 @@ function extractStudioReplTranscriptDelta(before: string, after: string): string
|
|
|
11821
11930
|
return current.trim();
|
|
11822
11931
|
}
|
|
11823
11932
|
|
|
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
11933
|
function normalizeStudioReplJournalMode(mode: unknown): StudioReplJournalEntry["mode"] {
|
|
11869
11934
|
return mode === "literate" || mode === "agent" ? mode : "raw";
|
|
11870
11935
|
}
|
|
@@ -12105,7 +12170,7 @@ function retainStudioReplSubmissionUntilSettled(
|
|
|
12105
12170
|
await sleepWithoutKeepingStudioProcessAlive(100);
|
|
12106
12171
|
}
|
|
12107
12172
|
} finally {
|
|
12108
|
-
|
|
12173
|
+
cleanupStudioReplControlFiles(controlFiles);
|
|
12109
12174
|
await lease?.release().catch(() => undefined);
|
|
12110
12175
|
}
|
|
12111
12176
|
})();
|
|
@@ -12120,13 +12185,15 @@ async function releaseOrRetainStudioReplSubmission(
|
|
|
12120
12185
|
retainStudioReplSubmissionUntilSettled(session, controlFiles, lease);
|
|
12121
12186
|
return;
|
|
12122
12187
|
}
|
|
12123
|
-
|
|
12188
|
+
cleanupStudioReplControlFiles(controlFiles);
|
|
12124
12189
|
await lease?.release().catch(() => undefined);
|
|
12125
12190
|
}
|
|
12126
12191
|
|
|
12127
12192
|
function interruptStudioReplSession(sessionName: string): { ok: true; message: string } | { ok: false; message: string } {
|
|
12128
12193
|
if (!/^[-_.A-Za-z0-9]+$/.test(sessionName)) return { ok: false, message: "Invalid REPL session name." };
|
|
12129
|
-
const
|
|
12194
|
+
const session = inspectStudioReplSession(sessionName);
|
|
12195
|
+
if (!session) return { ok: false, message: `No tmux REPL session named ${sessionName}.` };
|
|
12196
|
+
const result = runStudioTmux(["send-keys", "-t", session.target, "C-c"], { timeout: 5_000 });
|
|
12130
12197
|
if (!result.ok) return { ok: false, message: result.message || "Failed to interrupt REPL session." };
|
|
12131
12198
|
return { ok: true, message: `Interrupted ${sessionName}.` };
|
|
12132
12199
|
}
|
|
@@ -13222,7 +13289,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
13222
13289
|
contextWindow: null,
|
|
13223
13290
|
percent: null,
|
|
13224
13291
|
};
|
|
13225
|
-
|
|
13292
|
+
// The selected REPL session, the count of explicit actions and `shown`.
|
|
13293
|
+
const studioReplSelection = createReplSelection<StudioReplSessionInfo>();
|
|
13226
13294
|
let compactInProgress = false;
|
|
13227
13295
|
let compactRequestId: string | null = null;
|
|
13228
13296
|
const activeCompletionSuggestions = new Map<string, AbortController>();
|
|
@@ -13283,6 +13351,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
13283
13351
|
const state = listStudioReplSessions();
|
|
13284
13352
|
const sessions = state.sessions;
|
|
13285
13353
|
if (!state.tmuxAvailable) return { session: null, error: "tmux is not available.", sessions };
|
|
13354
|
+
studioReplSelection.resolve(sessions);
|
|
13286
13355
|
if (typeof params.sessionName === "string" && params.sessionName.trim()) {
|
|
13287
13356
|
const requested = params.sessionName.trim();
|
|
13288
13357
|
const session = sessions.find((candidate) => candidate.sessionName === requested) ?? null;
|
|
@@ -13290,19 +13359,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
13290
13359
|
? { session, sessions }
|
|
13291
13360
|
: { session: null, error: `No Studio-visible REPL session named ${requested}.`, sessions };
|
|
13292
13361
|
}
|
|
13362
|
+
const selected = studioReplSelection.session;
|
|
13363
|
+
const active = selected ? sessions.find((candidate) => sameReplSessionLifetime(candidate, selected)) ?? null : null;
|
|
13293
13364
|
const target = normalizeStudioReplRuntime(params.target);
|
|
13294
13365
|
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;
|
|
13366
|
+
const session = (active?.runtime === target ? active : null) ?? sessions.find((candidate) => candidate.runtime === target) ?? null;
|
|
13299
13367
|
return session
|
|
13300
13368
|
? { session, sessions }
|
|
13301
13369
|
: { session: null, error: `No running Studio-visible ${STUDIO_REPL_RUNTIME_LABELS[target]} REPL session.`, sessions };
|
|
13302
13370
|
}
|
|
13303
|
-
if (
|
|
13304
|
-
|
|
13305
|
-
|
|
13371
|
+
if (active) return { session: active, sessions };
|
|
13372
|
+
// With no session shown for the selected runtime, an unaddressed send doesn't
|
|
13373
|
+
// guess another interpreter (Sol, Astra).
|
|
13374
|
+
if (studioReplSelection.hasPreference) {
|
|
13375
|
+
return { session: null, error: "No REPL session is selected in Studio. Choose one under Session, or pass sessionName.", sessions };
|
|
13306
13376
|
}
|
|
13307
13377
|
return sessions[0]
|
|
13308
13378
|
? { session: sessions[0], sessions }
|
|
@@ -13336,7 +13406,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
13336
13406
|
const selected = selectStudioReplSessionForTool({ sessionName: params.sessionName, target: params.target });
|
|
13337
13407
|
const recordEntries = selected.session ? getStudioReplJournalEntries(selected.session.sessionName) : [];
|
|
13338
13408
|
const lines = [
|
|
13339
|
-
`Active Studio REPL: ${
|
|
13409
|
+
`Active Studio REPL: ${studioReplSelection.session?.sessionName || "none"}`,
|
|
13340
13410
|
`tmux sessions visible to Studio: ${selected.sessions.length}`,
|
|
13341
13411
|
];
|
|
13342
13412
|
if (selected.error) lines.push(`Selection: ${selected.error}`);
|
|
@@ -13353,7 +13423,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
13353
13423
|
return {
|
|
13354
13424
|
content: [{ type: "text", text: lines.join("\n") }],
|
|
13355
13425
|
details: {
|
|
13356
|
-
activeSessionName:
|
|
13426
|
+
activeSessionName: studioReplSelection.session?.sessionName ?? null,
|
|
13357
13427
|
selectedSession: selected.session,
|
|
13358
13428
|
selectedRecordEntries: recordEntries,
|
|
13359
13429
|
sessions: selected.sessions,
|
|
@@ -13383,7 +13453,30 @@ export default function (pi: ExtensionAPI) {
|
|
|
13383
13453
|
details: { ok: false, error: selected.error || "No Studio REPL session selected.", sessions: selected.sessions } as Record<string, unknown>,
|
|
13384
13454
|
};
|
|
13385
13455
|
}
|
|
13456
|
+
if (selected.session.runtime === "unknown") {
|
|
13457
|
+
const message = unsupportedStudioReplRuntimeMessage(selected.session.sessionName);
|
|
13458
|
+
return {
|
|
13459
|
+
content: [{ type: "text", text: message }],
|
|
13460
|
+
details: { ok: false, error: message, session: selected.session, sessions: selected.sessions } as Record<string, unknown>,
|
|
13461
|
+
};
|
|
13462
|
+
}
|
|
13386
13463
|
|
|
13464
|
+
// Input that can't be sent is refused before it counts as a choice (Sol).
|
|
13465
|
+
const inputError = !params.code.trim()
|
|
13466
|
+
? "Editor text is empty."
|
|
13467
|
+
: (params.code.length > STUDIO_REPL_SEND_MAX_CHARS ? `REPL input is too large (${params.code.length} chars; max ${STUDIO_REPL_SEND_MAX_CHARS}).` : "");
|
|
13468
|
+
if (inputError) {
|
|
13469
|
+
return {
|
|
13470
|
+
content: [{ type: "text", text: inputError }],
|
|
13471
|
+
details: { ok: false, error: inputError, session: selected.session, sessions: selected.sessions } as Record<string, unknown>,
|
|
13472
|
+
};
|
|
13473
|
+
}
|
|
13474
|
+
// The agent's send is an explicit choice when admitted, before it waits for the
|
|
13475
|
+
// session's lease; only a ticket still current lets its result select (Sol, Astra).
|
|
13476
|
+
const ticket = studioReplSelection.choose({ session: selected.session });
|
|
13477
|
+
// Every exit after admission tells the pages Studio's selection: the result when
|
|
13478
|
+
// it selects, otherwise a repl_state, so a failure can't leave them behind (Sol).
|
|
13479
|
+
let published = false;
|
|
13387
13480
|
const timeoutMs = clampStudioReplSendTimeout(params.timeoutMs);
|
|
13388
13481
|
let lease: Awaited<ReturnType<typeof acquireReplSessionSendLease>> | null = null;
|
|
13389
13482
|
let submittedSession: StudioReplSessionInfo | null = null;
|
|
@@ -13404,7 +13497,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
13404
13497
|
if (lease && currentSession.recordId !== selected.session.recordId) {
|
|
13405
13498
|
throw new Error(`The shared record for ${selected.session.sessionName} changed while Studio was waiting to send.`);
|
|
13406
13499
|
}
|
|
13407
|
-
const before = captureStudioReplSession(currentSession.sessionName);
|
|
13500
|
+
const before = captureStudioReplSession(currentSession.sessionName, currentSession);
|
|
13408
13501
|
if (!before.ok) throw new Error(`Could not capture ${currentSession.sessionName} before sending: ${before.message}`);
|
|
13409
13502
|
if (!isSameStudioReplSessionLifetime(currentSession, before.session)) {
|
|
13410
13503
|
throw new Error(`REPL session ${currentSession.sessionName} changed before Studio could capture it.`);
|
|
@@ -13440,7 +13533,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
13440
13533
|
};
|
|
13441
13534
|
}
|
|
13442
13535
|
submittedSession = currentSession;
|
|
13443
|
-
studioReplActiveSessionName = selected.session.sessionName;
|
|
13444
13536
|
|
|
13445
13537
|
let completed = false;
|
|
13446
13538
|
if (sent.controlFiles?.doneFile) {
|
|
@@ -13451,14 +13543,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
13451
13543
|
} else {
|
|
13452
13544
|
await sleep(Math.min(750, timeoutMs));
|
|
13453
13545
|
}
|
|
13454
|
-
const after = captureStudioReplSession(currentSession.sessionName);
|
|
13546
|
+
const after = captureStudioReplSession(currentSession.sessionName, currentSession);
|
|
13455
13547
|
if (!after.ok) throw new Error(`Could not capture ${currentSession.sessionName} after sending: ${after.message}`);
|
|
13456
13548
|
if (!isSameStudioReplSessionLifetime(currentSession, after.session)) {
|
|
13457
13549
|
throw new Error(`REPL session ${currentSession.sessionName} changed before Studio captured the result.`);
|
|
13458
13550
|
}
|
|
13459
13551
|
const afterTranscript = after.transcript;
|
|
13460
13552
|
const rawOutput = extractStudioReplTranscriptDelta(beforeTranscript, afterTranscript);
|
|
13461
|
-
const output =
|
|
13553
|
+
const output = cleanStudioReplOutput(rawOutput, {
|
|
13554
|
+
submissionLine: sent.submissionText,
|
|
13555
|
+
runtime: sent.runtime,
|
|
13556
|
+
display: sent.display,
|
|
13557
|
+
promptLine: beforeTranscript.split("\n").at(-1),
|
|
13558
|
+
completed,
|
|
13559
|
+
});
|
|
13462
13560
|
const status: StudioReplJournalEntry["status"] = sent.controlFiles?.doneFile
|
|
13463
13561
|
? (completed ? "captured" : "timeout")
|
|
13464
13562
|
: (output.trim() ? "captured" : "sent");
|
|
@@ -13477,6 +13575,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
13477
13575
|
output ? "" : undefined,
|
|
13478
13576
|
output || undefined,
|
|
13479
13577
|
].filter(Boolean).join("\n");
|
|
13578
|
+
// Its result selects only if nothing was chosen since it was admitted.
|
|
13579
|
+
const select = studioReplSelection.isCurrent(ticket);
|
|
13580
|
+
if (select) studioReplSelection.show({ session: currentSession });
|
|
13581
|
+
published = true;
|
|
13480
13582
|
broadcastStudioReplToolSend({
|
|
13481
13583
|
toolCallId,
|
|
13482
13584
|
sessionName: selected.session.sessionName,
|
|
@@ -13487,9 +13589,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
13487
13589
|
sharedSynced: journalEntry.sharedSynced === true,
|
|
13488
13590
|
completed,
|
|
13489
13591
|
timedOut: Boolean(sent.controlFiles?.doneFile && !completed),
|
|
13490
|
-
transcript: afterTranscript,
|
|
13491
|
-
capturedAt: Date.now(),
|
|
13492
13592
|
journalEntries: getStudioReplJournalEntries(selected.session.sessionName),
|
|
13593
|
+
select,
|
|
13594
|
+
...(select ? {
|
|
13595
|
+
session: currentSession,
|
|
13596
|
+
activeSessionName: currentSession.sessionName,
|
|
13597
|
+
activeRuntime: currentSession.runtime,
|
|
13598
|
+
shown: studioReplSelection.shown,
|
|
13599
|
+
transcript: afterTranscript,
|
|
13600
|
+
capturedAt: Date.now(),
|
|
13601
|
+
} : {}),
|
|
13493
13602
|
});
|
|
13494
13603
|
return {
|
|
13495
13604
|
content: [{ type: "text", text }],
|
|
@@ -13526,6 +13635,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
13526
13635
|
}
|
|
13527
13636
|
throw error;
|
|
13528
13637
|
} finally {
|
|
13638
|
+
if (!published) broadcastStudioReplSelection();
|
|
13529
13639
|
await releaseOrRetainStudioReplSubmission(lease, submittedSession, submittedControlFiles);
|
|
13530
13640
|
lease = null;
|
|
13531
13641
|
submittedControlFiles = undefined;
|
|
@@ -14328,54 +14438,56 @@ export default function (pi: ExtensionAPI) {
|
|
|
14328
14438
|
return next;
|
|
14329
14439
|
};
|
|
14330
14440
|
|
|
14441
|
+
// A REPL reply for a page that has closed, which includes every page from before
|
|
14442
|
+
// a Studio restart, leaves Studio's active REPL session alone (Sol).
|
|
14443
|
+
const isOpenReplClient = (client: WebSocket): boolean => (
|
|
14444
|
+
!studioRestarting && Boolean(serverState?.clients.has(client)) && client.readyState === WebSocket.OPEN
|
|
14445
|
+
);
|
|
14446
|
+
|
|
14447
|
+
// Every repl_state states Studio's selection as it is when sent. The page adopts it,
|
|
14448
|
+
// and its `shown` is the basis for the page's own reconciles (Sol, Astra).
|
|
14331
14449
|
const sendReplStateToClient = (client: WebSocket, extra?: Record<string, unknown>) => {
|
|
14450
|
+
if (!isOpenReplClient(client)) return;
|
|
14332
14451
|
const state = listStudioReplSessions();
|
|
14333
|
-
|
|
14334
|
-
|
|
14335
|
-
} else if (!studioReplActiveSessionName && state.sessions.length > 0) {
|
|
14336
|
-
studioReplActiveSessionName = state.sessions[0].sessionName;
|
|
14337
|
-
}
|
|
14452
|
+
studioReplSelection.resolve(state.sessions);
|
|
14453
|
+
const active = studioReplSelection.session;
|
|
14338
14454
|
sendToClient(client, {
|
|
14339
14455
|
type: "repl_state",
|
|
14340
14456
|
tmuxAvailable: state.tmuxAvailable,
|
|
14341
14457
|
sessions: state.sessions,
|
|
14342
|
-
activeSessionName:
|
|
14343
|
-
|
|
14458
|
+
activeSessionName: active?.sessionName ?? null,
|
|
14459
|
+
activeRuntime: studioReplSelection.runtime,
|
|
14460
|
+
shown: studioReplSelection.shown,
|
|
14461
|
+
journalEntries: getStudioReplJournalEntries(active?.sessionName ?? null),
|
|
14344
14462
|
error: state.error ?? null,
|
|
14345
14463
|
...extra,
|
|
14346
14464
|
});
|
|
14347
14465
|
};
|
|
14348
14466
|
|
|
14349
|
-
|
|
14350
|
-
|
|
14351
|
-
|
|
14352
|
-
|
|
14353
|
-
if (!
|
|
14354
|
-
sendReplStateToClient(client,
|
|
14355
|
-
transcript: "",
|
|
14356
|
-
capturedAt: Date.now(),
|
|
14357
|
-
journalEntries: [],
|
|
14358
|
-
...extra,
|
|
14359
|
-
});
|
|
14467
|
+
// The selected session's capture, as the selection is when sent.
|
|
14468
|
+
const sendReplSelectionToClient = (client: WebSocket, extra?: Record<string, unknown>) => {
|
|
14469
|
+
if (!isOpenReplClient(client)) return;
|
|
14470
|
+
const selected = studioReplSelection.session;
|
|
14471
|
+
if (!selected) {
|
|
14472
|
+
sendReplStateToClient(client, extra);
|
|
14360
14473
|
return;
|
|
14361
14474
|
}
|
|
14362
|
-
const captured = captureStudioReplSession(
|
|
14363
|
-
if (!captured.ok) {
|
|
14475
|
+
const captured = captureStudioReplSession(selected.sessionName);
|
|
14476
|
+
if (!captured.ok || !sameReplSessionLifetime(captured.session, selected)) {
|
|
14364
14477
|
sendReplStateToClient(client, {
|
|
14365
|
-
activeSessionName: targetSession,
|
|
14366
14478
|
transcript: "",
|
|
14367
|
-
captureError: captured.message,
|
|
14479
|
+
captureError: captured.ok ? `REPL session ${selected.sessionName} changed before Studio could capture it.` : captured.message,
|
|
14368
14480
|
capturedAt: Date.now(),
|
|
14369
|
-
journalEntries: getStudioReplJournalEntries(targetSession),
|
|
14370
14481
|
...extra,
|
|
14371
14482
|
});
|
|
14372
14483
|
return;
|
|
14373
14484
|
}
|
|
14374
|
-
studioReplActiveSessionName = captured.session.sessionName;
|
|
14375
14485
|
sendToClient(client, {
|
|
14376
14486
|
type: "repl_capture",
|
|
14377
14487
|
session: captured.session,
|
|
14378
14488
|
activeSessionName: captured.session.sessionName,
|
|
14489
|
+
activeRuntime: captured.session.runtime,
|
|
14490
|
+
shown: studioReplSelection.shown,
|
|
14379
14491
|
transcript: captured.transcript,
|
|
14380
14492
|
capturedAt: Date.now(),
|
|
14381
14493
|
journalEntries: getStudioReplJournalEntries(captured.session.sessionName),
|
|
@@ -14383,6 +14495,41 @@ export default function (pi: ExtensionAPI) {
|
|
|
14383
14495
|
});
|
|
14384
14496
|
};
|
|
14385
14497
|
|
|
14498
|
+
// A poll's reply: what one session shows now. It carries no selection (Sol, Astra).
|
|
14499
|
+
const sendReplObservationToClient = (client: WebSocket, sessionName: string) => {
|
|
14500
|
+
if (!isOpenReplClient(client)) return;
|
|
14501
|
+
const captured = captureStudioReplSession(sessionName);
|
|
14502
|
+
sendToClient(client, captured.ok ? {
|
|
14503
|
+
type: "repl_capture",
|
|
14504
|
+
observation: true,
|
|
14505
|
+
session: captured.session,
|
|
14506
|
+
transcript: captured.transcript,
|
|
14507
|
+
capturedAt: Date.now(),
|
|
14508
|
+
journalEntries: getStudioReplJournalEntries(captured.session.sessionName),
|
|
14509
|
+
} : {
|
|
14510
|
+
type: "repl_capture",
|
|
14511
|
+
observation: true,
|
|
14512
|
+
sessionName,
|
|
14513
|
+
captureError: captured.message,
|
|
14514
|
+
journalEntries: getStudioReplJournalEntries(sessionName),
|
|
14515
|
+
});
|
|
14516
|
+
};
|
|
14517
|
+
|
|
14518
|
+
// Studio's selection, to every open page.
|
|
14519
|
+
const broadcastStudioReplSelection = () => {
|
|
14520
|
+
for (const client of serverState?.clients ?? []) sendReplStateToClient(client);
|
|
14521
|
+
};
|
|
14522
|
+
|
|
14523
|
+
// A send's result selects only if nothing was chosen since the send was admitted.
|
|
14524
|
+
// Otherwise the page gets the send's record entries and keeps its choice.
|
|
14525
|
+
const sendReplSendResultToClient = (client: WebSocket, ticket: number | null, sessionName: string, extra: Record<string, unknown>) => {
|
|
14526
|
+
if (ticket !== null && studioReplSelection.isCurrent(ticket)) {
|
|
14527
|
+
sendReplSelectionToClient(client, extra);
|
|
14528
|
+
return;
|
|
14529
|
+
}
|
|
14530
|
+
sendReplStateToClient(client, { journalEntries: getStudioReplJournalEntries(sessionName), ...extra });
|
|
14531
|
+
};
|
|
14532
|
+
|
|
14386
14533
|
const emitDebugEvent = (event: string, details?: Record<string, unknown>) => {
|
|
14387
14534
|
broadcast({
|
|
14388
14535
|
type: "debug_event",
|
|
@@ -16421,7 +16568,41 @@ export default function (pi: ExtensionAPI) {
|
|
|
16421
16568
|
}
|
|
16422
16569
|
|
|
16423
16570
|
if (msg.type === "repl_capture_request") {
|
|
16424
|
-
|
|
16571
|
+
if (!msg.choice) {
|
|
16572
|
+
// A poll observes one session; with none, it asks for the list.
|
|
16573
|
+
if (msg.sessionName) sendReplObservationToClient(client, msg.sessionName);
|
|
16574
|
+
else sendReplStateToClient(client);
|
|
16575
|
+
return;
|
|
16576
|
+
}
|
|
16577
|
+
if (!isOpenReplClient(client)) return;
|
|
16578
|
+
// A choice on the page's Session or Runtime selector counts, even of a session
|
|
16579
|
+
// that has gone. Its reply carries its revision, so the page stops waiting.
|
|
16580
|
+
const answer = { choiceRevision: msg.choiceRevision };
|
|
16581
|
+
const session = msg.sessionName ? inspectStudioReplSession(msg.sessionName) : null;
|
|
16582
|
+
if (session) {
|
|
16583
|
+
studioReplSelection.choose({ session });
|
|
16584
|
+
sendReplSelectionToClient(client, answer);
|
|
16585
|
+
return;
|
|
16586
|
+
}
|
|
16587
|
+
// A session that has gone, or a runtime with none: no session, for that runtime.
|
|
16588
|
+
const replError = msg.sessionName ? `No tmux REPL session named ${msg.sessionName}.` : (msg.runtime ? "" : "Choose a runtime or a session.");
|
|
16589
|
+
if (msg.runtime) studioReplSelection.choose({ runtime: msg.runtime });
|
|
16590
|
+
sendReplStateToClient(client, { ...answer, ...(replError ? { replError } : {}) });
|
|
16591
|
+
return;
|
|
16592
|
+
}
|
|
16593
|
+
|
|
16594
|
+
if (msg.type === "repl_reconcile_request") {
|
|
16595
|
+
// The page's own selection applies only if the page saw the selection as it is
|
|
16596
|
+
// now. It doesn't count, so an action in progress keeps its ticket (Sol, Astra).
|
|
16597
|
+
if (msg.sessionName) {
|
|
16598
|
+
const session = inspectStudioReplSession(msg.sessionName);
|
|
16599
|
+
if (session && session.tmuxSessionId === msg.tmuxSessionId && session.tmuxSessionCreatedAt === msg.tmuxSessionCreatedAt) {
|
|
16600
|
+
studioReplSelection.reconcile(msg.basis, { session });
|
|
16601
|
+
}
|
|
16602
|
+
} else if (msg.runtime) {
|
|
16603
|
+
studioReplSelection.reconcile(msg.basis, { runtime: msg.runtime });
|
|
16604
|
+
}
|
|
16605
|
+
sendReplStateToClient(client);
|
|
16425
16606
|
return;
|
|
16426
16607
|
}
|
|
16427
16608
|
|
|
@@ -16430,15 +16611,30 @@ export default function (pi: ExtensionAPI) {
|
|
|
16430
16611
|
sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
|
|
16431
16612
|
return;
|
|
16432
16613
|
}
|
|
16433
|
-
|
|
16434
|
-
|
|
16435
|
-
|
|
16436
|
-
|
|
16437
|
-
|
|
16438
|
-
|
|
16439
|
-
|
|
16440
|
-
|
|
16441
|
-
|
|
16614
|
+
// Waits for the runtime's prompt; the page shows "Starting…" meanwhile. The request
|
|
16615
|
+
// counts on arrival, so of two overlapping Starts the newer one selects (Sol).
|
|
16616
|
+
const ticket = studioReplSelection.startRequested();
|
|
16617
|
+
void (async () => {
|
|
16618
|
+
const started = await startStudioReplSession(msg.runtime, studioCwd, { newSession: msg.newSession, command: msg.command });
|
|
16619
|
+
// Studio may have restarted, or the page closed, while the runtime started.
|
|
16620
|
+
if (!isOpenReplClient(client)) return;
|
|
16621
|
+
if (!started.ok) {
|
|
16622
|
+
sendReplStateToClient(client, { requestId: msg.requestId, replError: started.message });
|
|
16623
|
+
sendToClient(client, { type: "error", requestId: msg.requestId, message: started.message });
|
|
16624
|
+
return;
|
|
16625
|
+
}
|
|
16626
|
+
// A session chosen while this one started stays selected, unless the latest
|
|
16627
|
+
// choice was this session itself (Astra), or it is already selected, as when
|
|
16628
|
+
// two Starts reach the same session.
|
|
16629
|
+
const unchanged = sameReplSessionLifetime(studioReplSelection.session, started.session);
|
|
16630
|
+
if (!studioReplSelection.isCurrent(ticket) && !studioReplSelection.latestChose(started.session) && !unchanged) {
|
|
16631
|
+
sendReplStateToClient(client, { requestId: msg.requestId, replMessage: `${started.message} Select ${started.session.sessionName} under Session to use it.` });
|
|
16632
|
+
return;
|
|
16633
|
+
}
|
|
16634
|
+
studioReplSelection.show({ session: started.session });
|
|
16635
|
+
sendReplStateToClient(client, { requestId: msg.requestId, replMessage: started.message });
|
|
16636
|
+
sendReplSelectionToClient(client, { requestId: msg.requestId, replMessage: started.message });
|
|
16637
|
+
})();
|
|
16442
16638
|
return;
|
|
16443
16639
|
}
|
|
16444
16640
|
|
|
@@ -16447,14 +16643,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
16447
16643
|
sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
|
|
16448
16644
|
return;
|
|
16449
16645
|
}
|
|
16450
|
-
|
|
16451
|
-
|
|
16452
|
-
|
|
16453
|
-
|
|
16454
|
-
|
|
16455
|
-
|
|
16456
|
-
|
|
16457
|
-
|
|
16646
|
+
void (async () => {
|
|
16647
|
+
const stopped = await stopStudioReplSession(msg.sessionName);
|
|
16648
|
+
if (!isOpenReplClient(client)) return;
|
|
16649
|
+
if (!stopped.ok) {
|
|
16650
|
+
sendReplStateToClient(client, { requestId: msg.requestId, replError: stopped.message });
|
|
16651
|
+
sendToClient(client, { type: "error", requestId: msg.requestId, message: stopped.message });
|
|
16652
|
+
return;
|
|
16653
|
+
}
|
|
16654
|
+
// Stop clears the view only if the session it stopped is still selected (Astra).
|
|
16655
|
+
// Listing then selects another of its runtime, or none.
|
|
16656
|
+
const cleared = sameReplSessionLifetime(studioReplSelection.session, stopped.session);
|
|
16657
|
+
sendReplStateToClient(client, { requestId: msg.requestId, replMessage: stopped.message, ...(cleared ? { transcript: "", capturedAt: Date.now() } : {}) });
|
|
16658
|
+
})();
|
|
16458
16659
|
return;
|
|
16459
16660
|
}
|
|
16460
16661
|
|
|
@@ -16469,8 +16670,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
16469
16670
|
let lease: Awaited<ReturnType<typeof acquireReplSessionSendLease>> | null = null;
|
|
16470
16671
|
let submittedSession: StudioReplSessionInfo | null = null;
|
|
16471
16672
|
let submittedControlFiles: StudioReplControlFiles | undefined;
|
|
16673
|
+
let ticket: number | null = null;
|
|
16472
16674
|
try {
|
|
16473
16675
|
if (!session) throw new Error(`No tmux REPL session named ${msg.sessionName}.`);
|
|
16676
|
+
if (session.runtime === "unknown") throw new Error(unsupportedStudioReplRuntimeMessage(msg.sessionName));
|
|
16677
|
+
// The send is an explicit choice when admitted, before it waits for the
|
|
16678
|
+
// session's lease, even of the session already selected (Sol, Astra).
|
|
16679
|
+
if (isOpenReplClient(client)) ticket = studioReplSelection.choose({ session });
|
|
16474
16680
|
if (session.recordId && !session.recordWarning) {
|
|
16475
16681
|
lease = await acquireReplSessionSendLease(session.recordId, {
|
|
16476
16682
|
owner: `pi-studio:browser:${msg.requestId}`,
|
|
@@ -16484,7 +16690,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
16484
16690
|
if (lease && currentSession.recordId !== session.recordId) {
|
|
16485
16691
|
throw new Error(`The shared record for ${msg.sessionName} changed while Studio was waiting to send.`);
|
|
16486
16692
|
}
|
|
16487
|
-
const before = captureStudioReplSession(msg.sessionName);
|
|
16693
|
+
const before = captureStudioReplSession(msg.sessionName, currentSession);
|
|
16488
16694
|
if (!before.ok) throw new Error(`Could not capture ${msg.sessionName} before sending: ${before.message}`);
|
|
16489
16695
|
if (!isSameStudioReplSessionLifetime(currentSession, before.session)) {
|
|
16490
16696
|
throw new Error(`REPL session ${msg.sessionName} changed before Studio could capture it.`);
|
|
@@ -16514,7 +16720,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
16514
16720
|
throw new Error(sent.message);
|
|
16515
16721
|
}
|
|
16516
16722
|
submittedSession = currentSession;
|
|
16517
|
-
studioReplActiveSessionName = msg.sessionName;
|
|
16518
16723
|
sendToClient(client, {
|
|
16519
16724
|
type: "repl_send_ack",
|
|
16520
16725
|
requestId: msg.requestId,
|
|
@@ -16529,14 +16734,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
16529
16734
|
} else {
|
|
16530
16735
|
await sleep(Math.min(750, timeoutMs));
|
|
16531
16736
|
}
|
|
16532
|
-
const after = captureStudioReplSession(msg.sessionName);
|
|
16737
|
+
const after = captureStudioReplSession(msg.sessionName, currentSession);
|
|
16533
16738
|
if (!after.ok) throw new Error(`Could not capture ${msg.sessionName} after sending: ${after.message}`);
|
|
16534
16739
|
if (!isSameStudioReplSessionLifetime(currentSession, after.session)) {
|
|
16535
16740
|
throw new Error(`REPL session ${msg.sessionName} changed before Studio captured the result.`);
|
|
16536
16741
|
}
|
|
16537
16742
|
const afterTranscript = after.transcript;
|
|
16538
16743
|
const rawOutput = extractStudioReplTranscriptDelta(beforeTranscript, afterTranscript);
|
|
16539
|
-
const output =
|
|
16744
|
+
const output = cleanStudioReplOutput(rawOutput, {
|
|
16745
|
+
submissionLine: sent.submissionText,
|
|
16746
|
+
runtime: sent.runtime,
|
|
16747
|
+
display: sent.display,
|
|
16748
|
+
promptLine: beforeTranscript.split("\n").at(-1),
|
|
16749
|
+
completed,
|
|
16750
|
+
});
|
|
16540
16751
|
updateStudioReplJournalEntryOutput(
|
|
16541
16752
|
msg.requestId,
|
|
16542
16753
|
msg.sessionName,
|
|
@@ -16544,7 +16755,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
16544
16755
|
sent.controlFiles?.doneFile ? (completed ? "captured" : "timeout") : (output.trim() ? "captured" : "sent"),
|
|
16545
16756
|
currentSession,
|
|
16546
16757
|
);
|
|
16547
|
-
|
|
16758
|
+
sendReplSendResultToClient(client, ticket, msg.sessionName, { requestId: msg.requestId });
|
|
16548
16759
|
} catch (error) {
|
|
16549
16760
|
const message = error instanceof Error ? error.message : String(error);
|
|
16550
16761
|
if (journalEntry) updateStudioReplJournalEntryOutput(
|
|
@@ -16555,7 +16766,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
16555
16766
|
submittedSession || session,
|
|
16556
16767
|
);
|
|
16557
16768
|
sendToClient(client, { type: "error", requestId: msg.requestId, message });
|
|
16558
|
-
|
|
16769
|
+
sendReplSendResultToClient(client, ticket, msg.sessionName, { requestId: msg.requestId, replError: message });
|
|
16559
16770
|
} finally {
|
|
16560
16771
|
await releaseOrRetainStudioReplSubmission(lease, submittedSession, submittedControlFiles);
|
|
16561
16772
|
lease = null;
|
|
@@ -16640,11 +16851,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
16640
16851
|
const interrupted = interruptStudioReplSession(msg.sessionName);
|
|
16641
16852
|
if (!interrupted.ok) {
|
|
16642
16853
|
sendToClient(client, { type: "error", requestId: msg.requestId, message: interrupted.message });
|
|
16643
|
-
|
|
16854
|
+
sendReplStateToClient(client, { requestId: msg.requestId, replError: interrupted.message });
|
|
16644
16855
|
return;
|
|
16645
16856
|
}
|
|
16646
|
-
|
|
16647
|
-
|
|
16857
|
+
// An interrupt is an explicit choice of its session.
|
|
16858
|
+
const interruptedSession = inspectStudioReplSession(msg.sessionName);
|
|
16859
|
+
if (interruptedSession) studioReplSelection.choose({ session: interruptedSession });
|
|
16860
|
+
sendReplSelectionToClient(client, { requestId: msg.requestId, replMessage: interrupted.message });
|
|
16648
16861
|
return;
|
|
16649
16862
|
}
|
|
16650
16863
|
|