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/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 { buildStudioReplTmuxStartArgs } from "./shared/studio-repl-tmux.js";
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
- const openCommand =
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(sessionName: string, optionName: string): string | undefined {
11294
- const result = runStudioTmux(["show-options", "-v", "-t", sessionName, optionName], { timeout: 3_000 });
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(sessionName: string, optionName: string, value: string): boolean {
11301
- return runStudioTmux(["set-option", "-qo", "-t", sessionName, optionName, value], { timeout: 3_000 }).ok;
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
- let recordId = recordIdHint || readStudioReplTmuxOption(session.sessionName, REPL_SESSION_RECORD_ID_OPTION);
11310
- let version = versionHint || readStudioReplTmuxOption(session.sessionName, REPL_SESSION_RECORD_VERSION_OPTION);
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(session.sessionName, REPL_SESSION_RECORD_ID_OPTION, candidate)) {
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(session.sessionName, REPL_SESSION_RECORD_ID_OPTION);
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(session.sessionName, REPL_SESSION_RECORD_VERSION_OPTION, String(REPL_SESSION_RECORD_VERSION));
11326
- version = readStudioReplTmuxOption(session.sessionName, REPL_SESSION_RECORD_VERSION_OPTION);
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
- function makeStudioReplSessionInfo(
11356
- sessionName: string,
11357
- tmuxSessionId = "",
11358
- tmuxSessionCreatedRaw = "0",
11359
- runtimeMetadata = "",
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
- target: getStudioReplPaneTarget(tmuxSessionId || sessionName),
11369
- tmuxSessionId,
11370
- tmuxSessionCreatedAt: Math.max(0, Math.floor(Number(tmuxSessionCreatedRaw) || 0)),
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
- }, recordIdHint, versionHint);
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 result = runStudioTmux([
11380
- "display-message",
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
- .split(/\r?\n/)
11404
- .map((line) => line.trim())
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
- const result = runStudioTmux(["capture-pane", "-J", "-p", "-t", session.target, "-S", `-${STUDIO_REPL_CAPTURE_LINES}`], { timeout: 3_000 });
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
- function startStudioReplSession(runtime: StudioReplRuntime, cwd: string, options?: { newSession?: boolean; command?: string }): { ok: true; session: StudioReplSessionInfo; message: string } | { ok: false; message: string } {
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 existing = runStudioTmux(["has-session", "-t", sessionName], { timeout: 3_000 });
11428
- if (existing.ok) {
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 result = runStudioTmux(["kill-session", "-t", sessionName], { timeout: 5_000 });
11460
- if (!result.ok) return { ok: false, message: result.message || "Failed to stop REPL session." };
11461
- return { ok: true, message: `Stopped ${sessionName}.` };
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(${JSON.stringify(line)})`);
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(${JSON.stringify(display.endMarker)})`] : [];
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, ${JSON.stringify(code)}, "pi-studio-repl")`,
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(${JSON.stringify(doneFile)}, "done\\n")`,
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(${quotedPath})`;
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
- runtimeHint?: StudioReplRuntime | "unknown",
11808
+ runtime: StudioReplRuntime,
11713
11809
  ): StudioReplPreparedSubmission {
11714
11810
  const normalizedSource = String(source || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
11715
- const runtime = runtimeHint && runtimeHint !== "unknown" ? runtimeHint : inferStudioReplSessionRuntime(sessionName).runtime;
11716
- if (runtime !== "unknown") {
11717
- const display = createReplSubmissionDisplay({
11718
- entryId: details.submissionId,
11719
- origin: "pi-studio",
11720
- code: normalizedSource,
11721
- mode: details.echoMode,
11722
- });
11723
- const controlFiles: StudioReplControlFiles = createPrivateReplControlFiles({
11724
- extension: getStudioReplControlExtension(runtime),
11725
- buildSource: ({ doneFile }: StudioReplControlFiles) => {
11726
- const source = buildStudioReplControlSource(runtime, normalizedSource, doneFile, display);
11727
- if (source === null) throw new Error(`No control-file wrapper is available for ${runtime}.`);
11728
- return source;
11729
- },
11730
- });
11731
- const submissionLine = buildStudioReplSubmissionLine(runtime, controlFiles.sourceFile, controlFiles.doneFile);
11732
- const completionLine = runtime === "ghci" ? buildStudioGhciCompletionLine(controlFiles.doneFile, display) : undefined;
11733
- return {
11734
- runtime,
11735
- usedControlFile: true,
11736
- controlFiles,
11737
- display,
11738
- completionLine,
11739
- submissionText: [submissionLine, completionLine].filter(Boolean).join("\n"),
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: false,
11746
- submissionText: normalizedSource.replace(/\n+$/, ""),
11853
+ usedControlFile: true,
11854
+ controlFiles,
11855
+ display,
11856
+ submissionText: buildStudioReplSubmissionLine(runtime, loader, controlFiles.doneFile),
11747
11857
  };
11748
11858
  }
11749
11859
 
11750
- function pasteTextToStudioReplPane(sessionName: string, text: string, paneTarget?: string): { ok: true } | { ok: false; message: string; submissionStarted: boolean } {
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 || getStudioReplPaneTarget(sessionName);
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?: string,
11770
- runtimeHint?: StudioReplRuntime | "unknown",
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(sessionName, source, {
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
- }, runtimeHint);
11783
- const pasted = pasteTextToStudioReplPane(sessionName, prepared.submissionText, paneTarget);
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
- cleanupPrivateReplControlFiles(controlFiles);
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
- cleanupPrivateReplControlFiles(controlFiles);
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 result = runStudioTmux(["send-keys", "-t", getStudioReplPaneTarget(sessionName), "C-c"], { timeout: 5_000 });
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
- let studioReplActiveSessionName: string | null = null;
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 active = studioReplActiveSessionName
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 (studioReplActiveSessionName) {
13304
- const active = sessions.find((candidate) => candidate.sessionName === studioReplActiveSessionName);
13305
- if (active) return { session: active, sessions };
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: ${studioReplActiveSessionName || "none"}`,
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: studioReplActiveSessionName,
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 = cleanStudioReplCapturedOutput(rawOutput, sent.display, sent.completionLine);
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
- if (studioReplActiveSessionName && !state.sessions.some((session) => session.sessionName === studioReplActiveSessionName)) {
14334
- studioReplActiveSessionName = state.sessions[0]?.sessionName ?? null;
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: studioReplActiveSessionName,
14343
- journalEntries: getStudioReplJournalEntries(studioReplActiveSessionName),
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
- const sendReplCaptureToClient = (client: WebSocket, sessionName?: string | null, extra?: Record<string, unknown>) => {
14350
- const targetSession = (typeof sessionName === "string" && sessionName.trim())
14351
- ? sessionName.trim()
14352
- : studioReplActiveSessionName;
14353
- if (!targetSession) {
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(targetSession);
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
- sendReplCaptureToClient(client, msg.sessionName ?? null);
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
- const started = startStudioReplSession(msg.runtime, studioCwd, { newSession: msg.newSession, command: msg.command });
16434
- if (!started.ok) {
16435
- sendReplStateToClient(client, { requestId: msg.requestId, replError: started.message });
16436
- sendToClient(client, { type: "error", requestId: msg.requestId, message: started.message });
16437
- return;
16438
- }
16439
- studioReplActiveSessionName = started.session.sessionName;
16440
- sendReplStateToClient(client, { requestId: msg.requestId, replMessage: started.message });
16441
- sendReplCaptureToClient(client, started.session.sessionName, { requestId: msg.requestId, replMessage: started.message });
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
- const stopped = stopStudioReplSession(msg.sessionName);
16451
- if (!stopped.ok) {
16452
- sendReplStateToClient(client, { requestId: msg.requestId, replError: stopped.message });
16453
- sendToClient(client, { type: "error", requestId: msg.requestId, message: stopped.message });
16454
- return;
16455
- }
16456
- if (studioReplActiveSessionName === msg.sessionName) studioReplActiveSessionName = null;
16457
- sendReplStateToClient(client, { requestId: msg.requestId, replMessage: stopped.message, transcript: "", capturedAt: Date.now() });
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 = cleanStudioReplCapturedOutput(rawOutput, sent.display, sent.completionLine);
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
- sendReplCaptureToClient(client, msg.sessionName, { requestId: msg.requestId });
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
- sendReplCaptureToClient(client, msg.sessionName, { requestId: msg.requestId, replError: message });
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
- sendReplCaptureToClient(client, msg.sessionName, { requestId: msg.requestId, replError: interrupted.message });
16855
+ sendReplStateToClient(client, { requestId: msg.requestId, replError: interrupted.message });
16644
16856
  return;
16645
16857
  }
16646
- studioReplActiveSessionName = msg.sessionName;
16647
- sendReplCaptureToClient(client, msg.sessionName, { requestId: msg.requestId, replMessage: interrupted.message });
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