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/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[] {
@@ -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
- ` __pi_studio_result = __pi_studio_ip.run_cell(${JSON.stringify(code)}, store_history=False)`,
11558
- " if getattr(__pi_studio_result, 'error_in_exec', None) is None and getattr(__pi_studio_result, 'result', None) is not None:",
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(${JSON.stringify(display.endMarker)})`] : [];
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, ${JSON.stringify(code)}, "pi-studio-repl")`,
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(${JSON.stringify(doneFile)}, "done\\n")`,
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(${quotedPath})`;
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
- runtimeHint?: StudioReplRuntime | "unknown",
11807
+ runtime: StudioReplRuntime,
11713
11808
  ): StudioReplPreparedSubmission {
11714
11809
  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
- };
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: false,
11746
- submissionText: normalizedSource.replace(/\n+$/, ""),
11852
+ usedControlFile: true,
11853
+ controlFiles,
11854
+ display,
11855
+ submissionText: buildStudioReplSubmissionLine(runtime, loader, controlFiles.doneFile),
11747
11856
  };
11748
11857
  }
11749
11858
 
11750
- function pasteTextToStudioReplPane(sessionName: string, text: string, paneTarget?: string): { ok: true } | { ok: false; message: string; submissionStarted: boolean } {
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 || getStudioReplPaneTarget(sessionName);
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?: string,
11770
- runtimeHint?: StudioReplRuntime | "unknown",
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(sessionName, source, {
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
- }, runtimeHint);
11783
- const pasted = pasteTextToStudioReplPane(sessionName, prepared.submissionText, paneTarget);
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
- cleanupPrivateReplControlFiles(controlFiles);
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
- cleanupPrivateReplControlFiles(controlFiles);
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 result = runStudioTmux(["send-keys", "-t", getStudioReplPaneTarget(sessionName), "C-c"], { timeout: 5_000 });
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
- let studioReplActiveSessionName: string | null = null;
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 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;
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 (studioReplActiveSessionName) {
13304
- const active = sessions.find((candidate) => candidate.sessionName === studioReplActiveSessionName);
13305
- if (active) return { session: active, sessions };
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: ${studioReplActiveSessionName || "none"}`,
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: studioReplActiveSessionName,
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 = cleanStudioReplCapturedOutput(rawOutput, sent.display, sent.completionLine);
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
- 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
- }
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: studioReplActiveSessionName,
14343
- journalEntries: getStudioReplJournalEntries(studioReplActiveSessionName),
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
- 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
- });
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(targetSession);
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
- sendReplCaptureToClient(client, msg.sessionName ?? null);
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
- 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 });
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
- 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() });
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 = cleanStudioReplCapturedOutput(rawOutput, sent.display, sent.completionLine);
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
- sendReplCaptureToClient(client, msg.sessionName, { requestId: msg.requestId });
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
- sendReplCaptureToClient(client, msg.sessionName, { requestId: msg.requestId, replError: message });
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
- sendReplCaptureToClient(client, msg.sessionName, { requestId: msg.requestId, replError: interrupted.message });
16854
+ sendReplStateToClient(client, { requestId: msg.requestId, replError: interrupted.message });
16644
16855
  return;
16645
16856
  }
16646
- studioReplActiveSessionName = msg.sessionName;
16647
- sendReplCaptureToClient(client, msg.sessionName, { requestId: msg.requestId, replMessage: interrupted.message });
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