pi-studio 0.9.55 → 0.9.57

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
@@ -42,6 +42,28 @@ import { createStudioResourceGrantRegistry } from "./shared/studio-resource-gran
42
42
  import { prepareStudioLatexForPandoc } from "./shared/studio-latex-pandoc-compat.js";
43
43
  import { isStudioCmuxSession, openStudioUrlInBrowser } from "./shared/studio-browser-launcher.js";
44
44
  import { buildStudioReplTmuxStartArgs } from "./shared/studio-repl-tmux.js";
45
+ import {
46
+ REPL_SESSION_RECORD_ID_OPTION,
47
+ REPL_SESSION_RECORD_VERSION,
48
+ REPL_SESSION_RECORD_VERSION_OPTION,
49
+ acquireReplSessionSendLease,
50
+ clearReplSessionRecord,
51
+ createReplSessionRecordId,
52
+ ensureReplSessionRecord,
53
+ getReplSessionRecordPath,
54
+ isValidReplSessionRecordId,
55
+ readReplSessionRecord,
56
+ upsertReplSessionRecordEntry,
57
+ } from "./shared/repl-session-record.js";
58
+ import {
59
+ createReplSubmissionDisplay,
60
+ normalizeReplSubmissionEchoMode,
61
+ stripReplSubmissionDisplay,
62
+ } from "./shared/repl-submission-display.js";
63
+ import {
64
+ cleanupPrivateReplControlFiles,
65
+ createPrivateReplControlFiles,
66
+ } from "./shared/repl-control-files.js";
45
67
  import { buildStudioForwardingHint, buildStudioSshTunnelHint, isStudioSshSession as isSshSession } from "./shared/studio-ssh-hint.js";
46
68
  import {
47
69
  buildStudioPendingPage,
@@ -114,6 +136,8 @@ type TerminalActivityPhase = "idle" | "running" | "tool" | "responding";
114
136
  type StudioPromptMode = "response" | "run" | "effective";
115
137
  type StudioPromptTriggerKind = "run" | "steer";
116
138
  type StudioReplRuntime = "shell" | "python" | "ipython" | "julia" | "r" | "ghci" | "clojure";
139
+ type ReplSubmissionEchoMode = "off" | "summary" | "full";
140
+ type ReplSubmissionDisplay = ReturnType<typeof createReplSubmissionDisplay>;
117
141
  type StudioQuizAngle = "general" | "scientist" | "mathematician" | "statistician" | "developer" | "reviewer";
118
142
  type StudioQuizScope = "selection" | "editor" | "file" | "folder" | "repo";
119
143
  type StudioQuizThinking = "off" | "minimal" | "low" | "medium" | "high";
@@ -249,9 +273,14 @@ interface StudioContextUsageSnapshot {
249
273
  interface StudioReplSessionInfo {
250
274
  sessionName: string;
251
275
  target: string;
276
+ tmuxSessionId: string;
277
+ tmuxSessionCreatedAt: number;
252
278
  runtime: StudioReplRuntime | "unknown";
253
279
  label: string;
254
280
  source: "studio" | "pi-repl" | "tmux";
281
+ recordId?: string;
282
+ recordPath?: string;
283
+ recordWarning?: string;
255
284
  }
256
285
 
257
286
  interface StudioReplJournalEntry {
@@ -259,15 +288,22 @@ interface StudioReplJournalEntry {
259
288
  requestId: string;
260
289
  createdAt: number;
261
290
  updatedAt: number;
291
+ completedAt: number | null;
262
292
  sessionName: string;
263
293
  runtime: StudioReplRuntime | "unknown";
294
+ origin: "pi-studio" | "pi-repl" | "unknown";
264
295
  label: string;
265
296
  mode: "raw" | "literate" | "agent";
266
297
  prose: string;
267
298
  code: string;
268
299
  output: string;
269
- status: "sent" | "captured" | "timeout" | "error" | "note";
300
+ status: "sending" | "sent" | "captured" | "timeout" | "error" | "note";
270
301
  skippedChunks: number;
302
+ codeOmittedChars?: number;
303
+ proseOmittedChars?: number;
304
+ outputOmittedChars?: number;
305
+ /** Studio-local delivery state; not persisted in the shared protocol snapshot. */
306
+ sharedSynced?: boolean;
271
307
  }
272
308
 
273
309
  interface PreparedStudioPdfExport {
@@ -721,6 +757,33 @@ interface ReplSendRequestMessage {
721
757
  requestId: string;
722
758
  sessionName: string;
723
759
  text: string;
760
+ echoMode?: ReplSubmissionEchoMode;
761
+ journalEntryId?: string;
762
+ createdAt?: number;
763
+ label?: string;
764
+ mode?: StudioReplJournalEntry["mode"];
765
+ prose?: string;
766
+ skippedChunks?: number;
767
+ }
768
+
769
+ interface ReplJournalUpsertRequestMessage {
770
+ type: "repl_journal_upsert_request";
771
+ requestId: string;
772
+ sessionName: string;
773
+ entry: Partial<StudioReplJournalEntry>;
774
+ }
775
+
776
+ interface ReplJournalImportRequestMessage {
777
+ type: "repl_journal_import_request";
778
+ requestId: string;
779
+ sessionName: string;
780
+ entries: Array<Partial<StudioReplJournalEntry>>;
781
+ }
782
+
783
+ interface ReplJournalClearRequestMessage {
784
+ type: "repl_journal_clear_request";
785
+ requestId: string;
786
+ sessionName: string;
724
787
  }
725
788
 
726
789
  interface ReplInterruptRequestMessage {
@@ -848,6 +911,9 @@ type IncomingStudioMessage =
848
911
  | ReplStartRequestMessage
849
912
  | ReplStopRequestMessage
850
913
  | ReplSendRequestMessage
914
+ | ReplJournalUpsertRequestMessage
915
+ | ReplJournalImportRequestMessage
916
+ | ReplJournalClearRequestMessage
851
917
  | ReplInterruptRequestMessage
852
918
  | CompactRequestMessage
853
919
  | SaveAsRequestMessage
@@ -913,7 +979,7 @@ const STUDIO_REPL_SEND_MAX_CHARS = 200_000;
913
979
  const STUDIO_REPL_SEND_DEFAULT_TIMEOUT_MS = 20_000;
914
980
  const STUDIO_REPL_SEND_MAX_TIMEOUT_MS = 120_000;
915
981
  const STUDIO_REPL_JOURNAL_MAX_ENTRIES = 300;
916
- const STUDIO_REPL_CONTROL_ROOT = join(tmpdir(), "pi-studio-repl");
982
+ const STUDIO_REPL_RUNTIME_OPTION = "@pi_repl_runtime";
917
983
  const STUDIO_SUBPROCESS_OUTPUT_MAX_BYTES = 2_000_000;
918
984
  const STUDIO_PANDOC_TIMEOUT_MS = readStudioPositiveEnvMs("PI_STUDIO_PANDOC_TIMEOUT_MS", 120_000, 5_000, 15 * 60_000);
919
985
  const STUDIO_LATEX_TIMEOUT_MS = readStudioPositiveEnvMs("PI_STUDIO_LATEX_TIMEOUT_MS", 120_000, 5_000, 15 * 60_000);
@@ -933,7 +999,12 @@ const STUDIO_REPL_SEND_TOOL_PARAMS = Type.Object({
933
999
  sessionName: Type.Optional(Type.String({ description: "Exact Studio/pi-repl tmux session name. If omitted, Studio uses the active REPL session, or the first session matching target." })),
934
1000
  target: Type.Optional(Type.String({ description: "Optional runtime target: shell, python, ipython, julia, r, ghci, or clojure. Used when sessionName is omitted." })),
935
1001
  timeoutMs: Type.Optional(Type.Number({ description: "Maximum time to wait for completion when Studio can detect it (default 20000, max 120000).", minimum: 1000, maximum: STUDIO_REPL_SEND_MAX_TIMEOUT_MS })),
1002
+ echoMode: Type.Optional(Type.Union(
1003
+ [Type.Literal("off"), Type.Literal("summary"), Type.Literal("full")],
1004
+ { description: "How much submitted code to echo visibly in the raw REPL pane. Defaults to PI_STUDIO_REPL_ECHO_MODE or off. Summary shows short submissions in full and truncates longer ones; Full has larger bounds and writes source code into persistent raw terminal history." },
1005
+ )),
936
1006
  });
1007
+ const STUDIO_REPL_TOOL_ECHO_MODE = normalizeReplSubmissionEchoMode(process.env.PI_STUDIO_REPL_ECHO_MODE) as ReplSubmissionEchoMode;
937
1008
  const STUDIO_REPL_STATUS_TOOL_PARAMS = Type.Object({
938
1009
  sessionName: Type.Optional(Type.String({ description: "Exact Studio/pi-repl tmux session name to inspect." })),
939
1010
  target: Type.Optional(Type.String({ description: "Optional runtime target: shell, python, ipython, julia, r, ghci, or clojure. If omitted, report all Studio-visible REPL sessions." })),
@@ -1206,6 +1277,7 @@ let studioPersistentStateCache: StudioPersistentState | null = null;
1206
1277
  let studioPersistentStateQueue: Promise<void> = Promise.resolve();
1207
1278
  let transientStudioDocuments: Map<string, { document: InitialStudioDocument; createdAt: number }> = new Map();
1208
1279
  let studioReplJournalEntries: StudioReplJournalEntry[] = [];
1280
+ const studioReplUnsyncedJournalEntryIds = new Set<string>();
1209
1281
 
1210
1282
  function createEmptyStudioPersistentState(): StudioPersistentState {
1211
1283
  return {
@@ -9679,6 +9751,31 @@ function normalizeStudioQuizThinking(value: unknown): StudioQuizThinking {
9679
9751
  return "minimal";
9680
9752
  }
9681
9753
 
9754
+ function parseStudioReplJournalEntryInput(value: unknown): Partial<StudioReplJournalEntry> | null {
9755
+ if (!value || typeof value !== "object") return null;
9756
+ const entry = value as Record<string, unknown>;
9757
+ const code = typeof entry.code === "string" ? entry.code.slice(0, STUDIO_REPL_SEND_MAX_CHARS) : "";
9758
+ const prose = typeof entry.prose === "string" ? entry.prose.slice(0, 80_000) : "";
9759
+ const output = typeof entry.output === "string" ? entry.output.slice(0, 200_000) : "";
9760
+ if (!code.trim() && !prose.trim() && !output.trim()) return null;
9761
+ return {
9762
+ id: typeof entry.id === "string" ? entry.id.slice(0, 240) : undefined,
9763
+ requestId: typeof entry.requestId === "string" ? entry.requestId.slice(0, 300) : undefined,
9764
+ createdAt: typeof entry.createdAt === "number" && Number.isFinite(entry.createdAt) ? entry.createdAt : undefined,
9765
+ updatedAt: typeof entry.updatedAt === "number" && Number.isFinite(entry.updatedAt) ? entry.updatedAt : undefined,
9766
+ completedAt: typeof entry.completedAt === "number" && Number.isFinite(entry.completedAt) ? entry.completedAt : null,
9767
+ runtime: normalizeStudioReplRuntime(entry.runtime) || "unknown",
9768
+ origin: entry.origin === "pi-repl" ? "pi-repl" : "pi-studio",
9769
+ label: typeof entry.label === "string" ? entry.label.slice(0, 240) : undefined,
9770
+ mode: normalizeStudioReplJournalMode(entry.mode),
9771
+ prose,
9772
+ code,
9773
+ output,
9774
+ status: normalizeStudioReplJournalStatus(entry.status),
9775
+ skippedChunks: Math.max(0, Math.min(100_000, Math.floor(Number(entry.skippedChunks) || 0))),
9776
+ };
9777
+ }
9778
+
9682
9779
  function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
9683
9780
  let parsed: unknown;
9684
9781
  try {
@@ -9983,9 +10080,30 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
9983
10080
  requestId: msg.requestId,
9984
10081
  sessionName: msg.sessionName,
9985
10082
  text: msg.text,
10083
+ echoMode: normalizeReplSubmissionEchoMode(msg.echoMode) as ReplSubmissionEchoMode,
10084
+ journalEntryId: typeof msg.journalEntryId === "string" ? msg.journalEntryId.slice(0, 240) : undefined,
10085
+ createdAt: typeof msg.createdAt === "number" && Number.isFinite(msg.createdAt) ? msg.createdAt : undefined,
10086
+ label: typeof msg.label === "string" ? msg.label.slice(0, 240) : undefined,
10087
+ mode: normalizeStudioReplJournalMode(msg.mode),
10088
+ prose: typeof msg.prose === "string" ? msg.prose.slice(0, 80_000) : undefined,
10089
+ skippedChunks: Math.max(0, Math.min(100_000, Math.floor(Number(msg.skippedChunks) || 0))),
9986
10090
  };
9987
10091
  }
9988
10092
 
10093
+ if (msg.type === "repl_journal_upsert_request" && typeof msg.requestId === "string" && typeof msg.sessionName === "string") {
10094
+ const entry = parseStudioReplJournalEntryInput(msg.entry);
10095
+ if (entry) return { type: "repl_journal_upsert_request", requestId: msg.requestId, sessionName: msg.sessionName, entry };
10096
+ }
10097
+
10098
+ if (msg.type === "repl_journal_import_request" && typeof msg.requestId === "string" && typeof msg.sessionName === "string" && Array.isArray(msg.entries)) {
10099
+ const entries = msg.entries.slice(0, 80).map(parseStudioReplJournalEntryInput).filter((entry): entry is Partial<StudioReplJournalEntry> => Boolean(entry));
10100
+ return { type: "repl_journal_import_request", requestId: msg.requestId, sessionName: msg.sessionName, entries };
10101
+ }
10102
+
10103
+ if (msg.type === "repl_journal_clear_request" && typeof msg.requestId === "string" && typeof msg.sessionName === "string") {
10104
+ return { type: "repl_journal_clear_request", requestId: msg.requestId, sessionName: msg.sessionName };
10105
+ }
10106
+
9989
10107
  if (msg.type === "repl_interrupt_request" && typeof msg.requestId === "string" && typeof msg.sessionName === "string") {
9990
10108
  return {
9991
10109
  type: "repl_interrupt_request",
@@ -10877,9 +10995,111 @@ function runStudioTmux(args: string[], options?: { cwd?: string; input?: string;
10877
10995
  return { ok: true, stdout, stderr };
10878
10996
  }
10879
10997
 
10998
+ function readStudioReplTmuxOption(sessionName: string, optionName: string): string | undefined {
10999
+ const result = runStudioTmux(["show-options", "-v", "-t", sessionName, optionName], { timeout: 3_000 });
11000
+ if (!result.ok) return undefined;
11001
+ const value = result.stdout.trim();
11002
+ return value || undefined;
11003
+ }
11004
+
11005
+ function setStudioReplTmuxOptionIfAbsent(sessionName: string, optionName: string, value: string): boolean {
11006
+ return runStudioTmux(["set-option", "-qo", "-t", sessionName, optionName, value], { timeout: 3_000 }).ok;
11007
+ }
11008
+
11009
+ function attachStudioReplSessionRecord(
11010
+ session: StudioReplSessionInfo,
11011
+ recordIdHint?: string,
11012
+ versionHint?: string,
11013
+ ): StudioReplSessionInfo {
11014
+ let recordId = recordIdHint || readStudioReplTmuxOption(session.sessionName, REPL_SESSION_RECORD_ID_OPTION);
11015
+ let version = versionHint || readStudioReplTmuxOption(session.sessionName, REPL_SESSION_RECORD_VERSION_OPTION);
11016
+ if (recordId && !isValidReplSessionRecordId(recordId)) {
11017
+ return { ...session, recordWarning: "Invalid shared REPL record metadata; Studio left it untouched." };
11018
+ }
11019
+ if (!recordId) {
11020
+ const candidate = createReplSessionRecordId();
11021
+ if (!setStudioReplTmuxOptionIfAbsent(session.sessionName, REPL_SESSION_RECORD_ID_OPTION, candidate)) {
11022
+ return { ...session, recordWarning: "Studio could not attach shared record metadata to this tmux session." };
11023
+ }
11024
+ recordId = readStudioReplTmuxOption(session.sessionName, REPL_SESSION_RECORD_ID_OPTION);
11025
+ }
11026
+ if (!recordId || !isValidReplSessionRecordId(recordId)) {
11027
+ return { ...session, recordWarning: "Studio could not read valid shared record metadata from this tmux session." };
11028
+ }
11029
+ if (!version) {
11030
+ setStudioReplTmuxOptionIfAbsent(session.sessionName, REPL_SESSION_RECORD_VERSION_OPTION, String(REPL_SESSION_RECORD_VERSION));
11031
+ version = readStudioReplTmuxOption(session.sessionName, REPL_SESSION_RECORD_VERSION_OPTION);
11032
+ }
11033
+ const recordPath = getReplSessionRecordPath(recordId);
11034
+ if (version !== String(REPL_SESSION_RECORD_VERSION)) {
11035
+ return {
11036
+ ...session,
11037
+ recordId,
11038
+ recordPath,
11039
+ recordWarning: `Shared REPL record version ${version || "unknown"} is not supported by this Studio version.`,
11040
+ };
11041
+ }
11042
+ try {
11043
+ ensureReplSessionRecord(recordId, {
11044
+ sessionName: session.sessionName,
11045
+ tmuxSessionId: session.tmuxSessionId,
11046
+ tmuxSessionCreatedAt: session.tmuxSessionCreatedAt,
11047
+ runtime: session.runtime,
11048
+ });
11049
+ return { ...session, recordId, recordPath };
11050
+ } catch (error) {
11051
+ return {
11052
+ ...session,
11053
+ recordId,
11054
+ recordPath,
11055
+ recordWarning: error instanceof Error ? error.message : String(error),
11056
+ };
11057
+ }
11058
+ }
11059
+
11060
+ function makeStudioReplSessionInfo(
11061
+ sessionName: string,
11062
+ tmuxSessionId = "",
11063
+ tmuxSessionCreatedRaw = "0",
11064
+ runtimeMetadata = "",
11065
+ recordIdHint = "",
11066
+ versionHint = "",
11067
+ ): StudioReplSessionInfo {
11068
+ const inferred = inferStudioReplSessionRuntime(sessionName);
11069
+ const metadataRuntime = normalizeStudioReplRuntime(runtimeMetadata);
11070
+ const runtime = metadataRuntime || inferred.runtime;
11071
+ return attachStudioReplSessionRecord({
11072
+ sessionName,
11073
+ target: getStudioReplPaneTarget(tmuxSessionId || sessionName),
11074
+ tmuxSessionId,
11075
+ tmuxSessionCreatedAt: Math.max(0, Math.floor(Number(tmuxSessionCreatedRaw) || 0)),
11076
+ runtime,
11077
+ label: formatStudioReplSessionLabel(sessionName, runtime, inferred.source),
11078
+ source: inferred.source,
11079
+ }, recordIdHint, versionHint);
11080
+ }
11081
+
11082
+ function inspectStudioReplSession(sessionName: string): StudioReplSessionInfo | null {
11083
+ if (!/^[-_.A-Za-z0-9]+$/.test(sessionName)) return null;
11084
+ const result = runStudioTmux([
11085
+ "display-message",
11086
+ "-p",
11087
+ "-t",
11088
+ getStudioReplPaneTarget(sessionName),
11089
+ "#{session_name}\t#{session_id}\t#{session_created}\t#{@pi_repl_runtime}\t#{@pi_repl_record_id}\t#{@pi_repl_record_version}",
11090
+ ], { timeout: 3_000 });
11091
+ if (!result.ok) return null;
11092
+ const [resolvedName, tmuxSessionId, createdAt, runtime, recordId, version] = result.stdout.trim().split("\t");
11093
+ return makeStudioReplSessionInfo(resolvedName || sessionName, tmuxSessionId, createdAt, runtime, recordId, version);
11094
+ }
11095
+
10880
11096
  function listStudioReplSessions(): { tmuxAvailable: boolean; sessions: StudioReplSessionInfo[]; error?: string } {
10881
11097
  if (!isTmuxAvailable()) return { tmuxAvailable: false, sessions: [], error: "tmux is not available." };
10882
- const result = runStudioTmux(["list-sessions", "-F", "#{session_name}"], { timeout: 3_000 });
11098
+ const result = runStudioTmux([
11099
+ "list-sessions",
11100
+ "-F",
11101
+ "#{session_name}\t#{session_id}\t#{session_created}\t#{@pi_repl_runtime}\t#{@pi_repl_record_id}\t#{@pi_repl_record_version}",
11102
+ ], { timeout: 3_000 });
10883
11103
  if (!result.ok) {
10884
11104
  const message = result.message.toLowerCase().includes("no server running") ? "No tmux sessions are running." : result.message;
10885
11105
  return { tmuxAvailable: true, sessions: [], error: message };
@@ -10888,30 +11108,18 @@ function listStudioReplSessions(): { tmuxAvailable: boolean; sessions: StudioRep
10888
11108
  .split(/\r?\n/)
10889
11109
  .map((line) => line.trim())
10890
11110
  .filter(Boolean)
10891
- .filter(shouldShowStudioReplTmuxSession)
10892
- .map((sessionName) => {
10893
- const inferred = inferStudioReplSessionRuntime(sessionName);
10894
- return {
10895
- sessionName,
10896
- target: getStudioReplPaneTarget(sessionName),
10897
- runtime: inferred.runtime,
10898
- label: formatStudioReplSessionLabel(sessionName, inferred.runtime, inferred.source),
10899
- source: inferred.source,
10900
- };
10901
- });
11111
+ .map((line) => line.split("\t"))
11112
+ .filter(([sessionName]) => Boolean(sessionName && shouldShowStudioReplTmuxSession(sessionName)))
11113
+ .map(([sessionName = "", tmuxSessionId = "", createdAt = "0", runtime = "", recordId = "", version = ""]) => (
11114
+ makeStudioReplSessionInfo(sessionName, tmuxSessionId, createdAt, runtime, recordId, version)
11115
+ ));
10902
11116
  return { tmuxAvailable: true, sessions };
10903
11117
  }
10904
11118
 
10905
11119
  function captureStudioReplSession(sessionName: string): { ok: true; transcript: string; session: StudioReplSessionInfo } | { ok: false; message: string } {
10906
11120
  if (!/^[-_.A-Za-z0-9]+$/.test(sessionName)) return { ok: false, message: "Invalid REPL session name." };
10907
- const inferred = inferStudioReplSessionRuntime(sessionName);
10908
- const session: StudioReplSessionInfo = {
10909
- sessionName,
10910
- target: getStudioReplPaneTarget(sessionName),
10911
- runtime: inferred.runtime,
10912
- label: formatStudioReplSessionLabel(sessionName, inferred.runtime, inferred.source),
10913
- source: inferred.source,
10914
- };
11121
+ const session = inspectStudioReplSession(sessionName);
11122
+ if (!session) return { ok: false, message: `No tmux REPL session named ${sessionName}.` };
10915
11123
  const result = runStudioTmux(["capture-pane", "-J", "-p", "-t", session.target, "-S", `-${STUDIO_REPL_CAPTURE_LINES}`], { timeout: 3_000 });
10916
11124
  if (!result.ok) return { ok: false, message: result.message };
10917
11125
  return { ok: true, transcript: String(result.stdout || "").replace(/[\t ]+$/gm, "").trimEnd(), session };
@@ -10923,31 +11131,26 @@ function startStudioReplSession(runtime: StudioReplRuntime, cwd: string, options
10923
11131
  const sessionName = options?.newSession ? getNewStudioReplSessionName(runtime, commandOverride) : getStudioReplSessionName(runtime, commandOverride);
10924
11132
  const existing = runStudioTmux(["has-session", "-t", sessionName], { timeout: 3_000 });
10925
11133
  if (existing.ok) {
10926
- const inferred = inferStudioReplSessionRuntime(sessionName);
11134
+ const session = inspectStudioReplSession(sessionName);
11135
+ if (!session) return { ok: false, message: `Could not inspect existing REPL session ${sessionName}.` };
10927
11136
  return {
10928
11137
  ok: true,
10929
- session: {
10930
- sessionName,
10931
- target: getStudioReplPaneTarget(sessionName),
10932
- runtime: inferred.runtime,
10933
- label: formatStudioReplSessionLabel(sessionName, inferred.runtime, inferred.source),
10934
- source: inferred.source,
10935
- },
11138
+ session,
10936
11139
  message: `${STUDIO_REPL_RUNTIME_LABELS[runtime]} REPL is already running.`,
10937
11140
  };
10938
11141
  }
10939
11142
  const command = getStudioReplRuntimeCommand(runtime, commandOverride);
10940
11143
  const result = runStudioTmux(buildStudioReplTmuxStartArgs(sessionName, cwd || process.cwd(), command), { timeout: 5_000 });
10941
11144
  if (!result.ok) return { ok: false, message: result.message || `Failed to start ${STUDIO_REPL_RUNTIME_LABELS[runtime]} REPL.` };
11145
+ runStudioTmux(["set-option", "-q", "-t", sessionName, STUDIO_REPL_RUNTIME_OPTION, runtime], { timeout: 3_000 });
11146
+ const session = inspectStudioReplSession(sessionName);
11147
+ if (!session) {
11148
+ runStudioTmux(["kill-session", "-t", sessionName], { timeout: 3_000 });
11149
+ return { ok: false, message: `Started ${sessionName}, but could not initialize its shared session metadata.` };
11150
+ }
10942
11151
  return {
10943
11152
  ok: true,
10944
- session: {
10945
- sessionName,
10946
- target: getStudioReplPaneTarget(sessionName),
10947
- runtime,
10948
- label: formatStudioReplSessionLabel(sessionName, runtime, "studio"),
10949
- source: "studio",
10950
- },
11153
+ session,
10951
11154
  message: `Started ${options?.newSession ? "new " : ""}${STUDIO_REPL_RUNTIME_LABELS[runtime]} REPL${commandOverride ? ` with custom command: ${commandOverride}` : ""}.`,
10952
11155
  };
10953
11156
  }
@@ -10973,7 +11176,9 @@ type StudioReplPreparedSubmission = {
10973
11176
  runtime: StudioReplRuntime | "unknown";
10974
11177
  usedControlFile: boolean;
10975
11178
  submissionText: string;
11179
+ completionLine?: string;
10976
11180
  controlFiles?: StudioReplControlFiles;
11181
+ display?: ReplSubmissionDisplay;
10977
11182
  };
10978
11183
 
10979
11184
  type StudioReplSendSuccess = {
@@ -10982,10 +11187,22 @@ type StudioReplSendSuccess = {
10982
11187
  runtime: StudioReplRuntime | "unknown";
10983
11188
  usedControlFile: boolean;
10984
11189
  submissionText: string;
11190
+ completionLine?: string;
10985
11191
  controlFiles?: StudioReplControlFiles;
11192
+ display?: ReplSubmissionDisplay;
10986
11193
  };
10987
11194
 
10988
- type StudioReplSendFailure = { ok: false; message: string };
11195
+ type StudioReplSendFailure = {
11196
+ ok: false;
11197
+ message: string;
11198
+ submissionStarted?: boolean;
11199
+ runtime?: StudioReplRuntime | "unknown";
11200
+ usedControlFile?: boolean;
11201
+ submissionText?: string;
11202
+ completionLine?: string;
11203
+ controlFiles?: StudioReplControlFiles;
11204
+ display?: ReplSubmissionDisplay;
11205
+ };
10989
11206
 
10990
11207
  function sleep(ms: number): Promise<void> {
10991
11208
  return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
@@ -11000,31 +11217,44 @@ function shellQuote(value: string): string {
11000
11217
  return `'${String(value || "").replace(/'/g, `'"'"'`)}'`;
11001
11218
  }
11002
11219
 
11003
- function getStudioReplControlFiles(sessionName: string, runtime: StudioReplRuntime | "unknown"): StudioReplControlFiles {
11004
- const safeSession = sessionName.replace(/[^-_.A-Za-z0-9]+/g, "_");
11005
- const safeRuntime = String(runtime || "repl").replace(/[^-_.A-Za-z0-9]+/g, "_");
11006
- const dir = join(STUDIO_REPL_CONTROL_ROOT, safeSession, randomUUID().replace(/-/g, ""));
11007
- const extension = runtime === "julia"
11008
- ? "jl"
11009
- : runtime === "r"
11010
- ? "R"
11011
- : runtime === "ghci"
11012
- ? "ghci"
11013
- : runtime === "clojure"
11014
- ? "clj"
11015
- : "py";
11016
- return {
11017
- dir,
11018
- sourceFile: join(dir, `studio-repl-${safeRuntime}.${extension}`),
11019
- doneFile: join(dir, "done.flag"),
11020
- };
11220
+ function getStudioReplControlExtension(runtime: StudioReplRuntime): string {
11221
+ if (runtime === "julia") return "jl";
11222
+ if (runtime === "r") return "R";
11223
+ if (runtime === "ghci") return "ghci";
11224
+ if (runtime === "clojure") return "clj";
11225
+ if (runtime === "shell") return "sh";
11226
+ return "py";
11227
+ }
11228
+
11229
+ function buildStudioPythonDisplayStatements(display: ReplSubmissionDisplay, indent = ""): string[] {
11230
+ if (!display.enabled) return [];
11231
+ return display.prefixLines.map((line) => `${indent}__pi_studio_builtins.print(${JSON.stringify(line)})`);
11232
+ }
11233
+
11234
+ function buildStudioJuliaDisplayStatements(display: ReplSubmissionDisplay, indent = ""): string[] {
11235
+ if (!display.enabled) return [];
11236
+ return display.prefixLines.map((line) => `${indent}Base.println(${JSON.stringify(line)})`);
11237
+ }
11238
+
11239
+ function buildStudioRDisplayStatements(display: ReplSubmissionDisplay, indent = ""): string[] {
11240
+ if (!display.enabled) return [];
11241
+ return display.prefixLines.map((line) => `${indent}base::cat(${JSON.stringify(`${line}\n`)})`);
11021
11242
  }
11022
11243
 
11023
- function buildStudioPythonControlSource(runtime: "python" | "ipython", code: string, doneFile: string): string {
11244
+ function buildStudioClojureDisplayStatements(display: ReplSubmissionDisplay, indent = ""): string[] {
11245
+ if (!display.enabled) return [];
11246
+ return display.prefixLines.map((line) => `${indent}(clojure.core/println ${JSON.stringify(line)})`);
11247
+ }
11248
+
11249
+ function buildStudioPythonControlSource(runtime: "python" | "ipython", code: string, doneFile: string, display: ReplSubmissionDisplay): string {
11250
+ const prefix = buildStudioPythonDisplayStatements(display);
11251
+ const completion = display.enabled ? [` __pi_studio_builtins.print(${JSON.stringify(display.endMarker)})`] : [];
11024
11252
  if (runtime === "ipython") {
11025
11253
  return [
11026
11254
  "from pathlib import Path as __pi_studio_path",
11255
+ "import builtins as __pi_studio_builtins",
11027
11256
  "import traceback as __pi_studio_traceback",
11257
+ ...prefix,
11028
11258
  "try:",
11029
11259
  " __pi_studio_ip = get_ipython()",
11030
11260
  " if __pi_studio_ip is None:",
@@ -11035,14 +11265,17 @@ function buildStudioPythonControlSource(runtime: "python" | "ipython", code: str
11035
11265
  "except Exception:",
11036
11266
  " __pi_studio_traceback.print_exc()",
11037
11267
  "finally:",
11268
+ ...completion,
11038
11269
  ` __pi_studio_path(${JSON.stringify(doneFile)}).write_text('done\\n', encoding='utf-8')`,
11039
11270
  ].join("\n");
11040
11271
  }
11041
11272
 
11042
11273
  return [
11043
11274
  "from pathlib import Path as __pi_studio_path",
11275
+ "import builtins as __pi_studio_builtins",
11044
11276
  "import traceback as __pi_studio_traceback",
11045
11277
  `__pi_studio_code = ${JSON.stringify(code)}`,
11278
+ ...prefix,
11046
11279
  "try:",
11047
11280
  " try:",
11048
11281
  " __pi_studio_expr = compile(__pi_studio_code, '<pi-studio-repl>', 'eval')",
@@ -11055,12 +11288,15 @@ function buildStudioPythonControlSource(runtime: "python" | "ipython", code: str
11055
11288
  "except Exception:",
11056
11289
  " __pi_studio_traceback.print_exc()",
11057
11290
  "finally:",
11291
+ ...completion,
11058
11292
  ` __pi_studio_path(${JSON.stringify(doneFile)}).write_text('done\\n', encoding='utf-8')`,
11059
11293
  ].join("\n");
11060
11294
  }
11061
11295
 
11062
- function buildStudioJuliaControlSource(code: string, doneFile: string): string {
11296
+ function buildStudioJuliaControlSource(code: string, doneFile: string, display: ReplSubmissionDisplay): string {
11297
+ const completion = display.enabled ? [` Base.println(${JSON.stringify(display.endMarker)})`] : [];
11063
11298
  return [
11299
+ ...buildStudioJuliaDisplayStatements(display),
11064
11300
  "try",
11065
11301
  ` local __pi_studio_result = Base.include_string(Main, ${JSON.stringify(code)}, "pi-studio-repl")`,
11066
11302
  " if !isnothing(__pi_studio_result)",
@@ -11069,14 +11305,17 @@ function buildStudioJuliaControlSource(code: string, doneFile: string): string {
11069
11305
  "catch e",
11070
11306
  " Base.display_error(stderr, e, catch_backtrace())",
11071
11307
  "finally",
11308
+ ...completion,
11072
11309
  ` write(${JSON.stringify(doneFile)}, "done\\n")`,
11073
11310
  "end",
11074
11311
  ].join("\n");
11075
11312
  }
11076
11313
 
11077
- function buildStudioRControlSource(code: string, doneFile: string): string {
11314
+ function buildStudioRControlSource(code: string, doneFile: string, display: ReplSubmissionDisplay): string {
11315
+ const completion = display.enabled ? [` base::cat(${JSON.stringify(`${display.endMarker}\n`)})`] : [];
11078
11316
  return [
11079
11317
  "local({",
11318
+ ...buildStudioRDisplayStatements(display, " "),
11080
11319
  ` .__pi_studio_done_file <- ${JSON.stringify(doneFile)}`,
11081
11320
  ` .__pi_studio_code <- ${JSON.stringify(code)}`,
11082
11321
  " tryCatch({",
@@ -11098,15 +11337,33 @@ function buildStudioRControlSource(code: string, doneFile: string): string {
11098
11337
  " message(\"Error in \", .__pi_studio_call_text, \": \", conditionMessage(e))",
11099
11338
  " }",
11100
11339
  " }, finally = {",
11340
+ ...completion,
11101
11341
  " writeLines(\"done\", .__pi_studio_done_file)",
11102
11342
  " })",
11103
11343
  "})",
11104
11344
  ].join("\n");
11105
11345
  }
11106
11346
 
11107
- function buildStudioClojureControlSource(code: string, doneFile: string): string {
11347
+ function buildStudioGhciControlSource(code: string, display: ReplSubmissionDisplay): string {
11348
+ const prefix = display.enabled
11349
+ ? display.prefixLines.map((line) => `:! command printf '%s\\n' ${shellQuote(line)}`)
11350
+ : [];
11351
+ return [
11352
+ ...prefix,
11353
+ code.replace(/\r/g, "").trimEnd(),
11354
+ ].filter(Boolean).join("\n") + "\n";
11355
+ }
11356
+
11357
+ function buildStudioGhciCompletionLine(doneFile: string, display: ReplSubmissionDisplay): string {
11358
+ const completion = display.enabled ? `command printf '%s\\n' ${shellQuote(display.endMarker)}; ` : "";
11359
+ return `:! ${completion}touch ${shellQuote(doneFile)}`;
11360
+ }
11361
+
11362
+ function buildStudioClojureControlSource(code: string, doneFile: string, display: ReplSubmissionDisplay): string {
11363
+ const completion = display.enabled ? [` (clojure.core/println ${JSON.stringify(display.endMarker)})`] : [];
11108
11364
  return [
11109
11365
  "(let [code " + JSON.stringify(code) + "]",
11366
+ ...buildStudioClojureDisplayStatements(display, " "),
11110
11367
  " (try",
11111
11368
  " (let [rdr (clojure.lang.LineNumberingPushbackReader. (java.io.StringReader. code))]",
11112
11369
  " (loop [last-val nil has-val false]",
@@ -11117,50 +11374,75 @@ function buildStudioClojureControlSource(code: string, doneFile: string): string
11117
11374
  " (catch Throwable t",
11118
11375
  " (#'clojure.main/repl-caught t))",
11119
11376
  " (finally",
11377
+ ...completion,
11120
11378
  ` (spit ${JSON.stringify(doneFile)} "done\\n"))))`,
11121
11379
  ].join("\n");
11122
11380
  }
11123
11381
 
11124
- function buildStudioReplControlSource(runtime: StudioReplRuntime, code: string, doneFile: string): string | null {
11125
- if (runtime === "python" || runtime === "ipython") return buildStudioPythonControlSource(runtime, code, doneFile);
11126
- if (runtime === "julia") return buildStudioJuliaControlSource(code, doneFile);
11127
- if (runtime === "r") return buildStudioRControlSource(code, doneFile);
11128
- if (runtime === "ghci") return `${code.replace(/\r/g, "").trimEnd()}\n:! touch ${shellQuote(doneFile)}\n`;
11129
- if (runtime === "clojure") return buildStudioClojureControlSource(code, doneFile);
11382
+ function buildStudioShellControlSource(code: string, display: ReplSubmissionDisplay): string {
11383
+ if (!display.enabled) return `${code.replace(/\r/g, "").trimEnd()}\n`;
11384
+ return [
11385
+ ...display.prefixLines.map((line) => `command printf '%s\\n' ${shellQuote(line)}`),
11386
+ code.replace(/\r/g, "").trimEnd(),
11387
+ "__pi_studio_repl_status=$?",
11388
+ `command printf '%s\\n' ${shellQuote(display.endMarker)}`,
11389
+ "return \"$__pi_studio_repl_status\"",
11390
+ ].filter(Boolean).join("\n") + "\n";
11391
+ }
11392
+
11393
+ function buildStudioReplControlSource(runtime: StudioReplRuntime, code: string, doneFile: string, display: ReplSubmissionDisplay): string | null {
11394
+ if (runtime === "python" || runtime === "ipython") return buildStudioPythonControlSource(runtime, code, doneFile, display);
11395
+ if (runtime === "julia") return buildStudioJuliaControlSource(code, doneFile, display);
11396
+ if (runtime === "r") return buildStudioRControlSource(code, doneFile, display);
11397
+ if (runtime === "ghci") return buildStudioGhciControlSource(code, display);
11398
+ if (runtime === "clojure") return buildStudioClojureControlSource(code, doneFile, display);
11399
+ if (runtime === "shell") return buildStudioShellControlSource(code, display);
11130
11400
  return null;
11131
11401
  }
11132
11402
 
11133
- function buildStudioReplSubmissionLine(runtime: StudioReplRuntime, sourceFile: string): string {
11403
+ function buildStudioReplSubmissionLine(runtime: StudioReplRuntime, sourceFile: string, doneFile: string): string {
11134
11404
  const quotedPath = JSON.stringify(sourceFile);
11135
11405
  if (runtime === "julia") return `include(${quotedPath})`;
11136
11406
  if (runtime === "r") return `source(${quotedPath}, local=.GlobalEnv)`;
11137
11407
  if (runtime === "ghci") return `:script ${quotedPath}`;
11138
11408
  if (runtime === "clojure") return `(do (load-file ${quotedPath}) :pi-studio/silent)`;
11409
+ if (runtime === "shell") return `. ${shellQuote(sourceFile)}; touch ${shellQuote(doneFile)}`;
11139
11410
  return `exec(open(${quotedPath}, encoding="utf-8").read(), globals())`;
11140
11411
  }
11141
11412
 
11142
- function prepareStudioReplSubmission(sessionName: string, source: string): StudioReplPreparedSubmission {
11413
+ function prepareStudioReplSubmission(
11414
+ sessionName: string,
11415
+ source: string,
11416
+ details: { submissionId: string; echoMode: ReplSubmissionEchoMode },
11417
+ runtimeHint?: StudioReplRuntime | "unknown",
11418
+ ): StudioReplPreparedSubmission {
11143
11419
  const normalizedSource = String(source || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
11144
- const runtime = inferStudioReplSessionRuntime(sessionName).runtime;
11145
- if (runtime !== "unknown" && runtime !== "shell") {
11146
- const controlFiles = getStudioReplControlFiles(sessionName, runtime);
11147
- const controlSource = buildStudioReplControlSource(runtime, normalizedSource, controlFiles.doneFile);
11148
- if (controlSource) {
11149
- mkdirSync(controlFiles.dir, { recursive: true });
11150
- try {
11151
- unlinkSync(controlFiles.doneFile);
11152
- } catch {
11153
- // Ignore stale done file cleanup failures.
11154
- }
11155
- writeFileSync(controlFiles.sourceFile, controlSource, "utf-8");
11156
- const submissionLine = buildStudioReplSubmissionLine(runtime, controlFiles.sourceFile);
11157
- return {
11158
- runtime,
11159
- usedControlFile: true,
11160
- controlFiles,
11161
- submissionText: submissionLine,
11162
- };
11163
- }
11420
+ const runtime = runtimeHint && runtimeHint !== "unknown" ? runtimeHint : inferStudioReplSessionRuntime(sessionName).runtime;
11421
+ if (runtime !== "unknown") {
11422
+ const display = createReplSubmissionDisplay({
11423
+ entryId: details.submissionId,
11424
+ origin: "pi-studio",
11425
+ code: normalizedSource,
11426
+ mode: details.echoMode,
11427
+ });
11428
+ const controlFiles: StudioReplControlFiles = createPrivateReplControlFiles({
11429
+ extension: getStudioReplControlExtension(runtime),
11430
+ buildSource: ({ doneFile }: StudioReplControlFiles) => {
11431
+ const source = buildStudioReplControlSource(runtime, normalizedSource, doneFile, display);
11432
+ if (source === null) throw new Error(`No control-file wrapper is available for ${runtime}.`);
11433
+ return source;
11434
+ },
11435
+ });
11436
+ const submissionLine = buildStudioReplSubmissionLine(runtime, controlFiles.sourceFile, controlFiles.doneFile);
11437
+ const completionLine = runtime === "ghci" ? buildStudioGhciCompletionLine(controlFiles.doneFile, display) : undefined;
11438
+ return {
11439
+ runtime,
11440
+ usedControlFile: true,
11441
+ controlFiles,
11442
+ display,
11443
+ completionLine,
11444
+ submissionText: [submissionLine, completionLine].filter(Boolean).join("\n"),
11445
+ };
11164
11446
  }
11165
11447
 
11166
11448
  return {
@@ -11170,39 +11452,60 @@ function prepareStudioReplSubmission(sessionName: string, source: string): Studi
11170
11452
  };
11171
11453
  }
11172
11454
 
11173
- function pasteTextToStudioReplPane(sessionName: string, text: string): { ok: true } | { ok: false; message: string } {
11455
+ function pasteTextToStudioReplPane(sessionName: string, text: string, paneTarget?: string): { ok: true } | { ok: false; message: string; submissionStarted: boolean } {
11174
11456
  const bufferName = `pi-studio-repl-${randomUUID().replace(/-/g, "")}`;
11175
- const target = getStudioReplPaneTarget(sessionName);
11457
+ const target = paneTarget || getStudioReplPaneTarget(sessionName);
11176
11458
  const loadResult = runStudioTmux(["load-buffer", "-b", bufferName, "-"], { input: text, timeout: 5_000 });
11177
- if (!loadResult.ok) return { ok: false, message: loadResult.message || "Failed to load text into tmux buffer." };
11459
+ if (!loadResult.ok) return { ok: false, message: loadResult.message || "Failed to load text into tmux buffer.", submissionStarted: false };
11178
11460
  try {
11179
11461
  const pasteResult = runStudioTmux(["paste-buffer", "-d", "-b", bufferName, "-t", target], { timeout: 5_000 });
11180
- if (!pasteResult.ok) return { ok: false, message: pasteResult.message || "Failed to paste text into REPL session." };
11462
+ if (!pasteResult.ok) return { ok: false, message: pasteResult.message || "Failed to paste text into REPL session.", submissionStarted: false };
11181
11463
  const enterResult = runStudioTmux(["send-keys", "-t", target, "C-m"], { timeout: 5_000 });
11182
- if (!enterResult.ok) return { ok: false, message: enterResult.message || "Failed to send Enter to REPL session." };
11464
+ if (!enterResult.ok) return { ok: false, message: enterResult.message || "Failed to send Enter to REPL session.", submissionStarted: true };
11183
11465
  return { ok: true };
11184
11466
  } finally {
11185
11467
  runStudioTmux(["delete-buffer", "-b", bufferName], { timeout: 2_000 });
11186
11468
  }
11187
11469
  }
11188
11470
 
11189
- function sendTextToStudioReplSession(sessionName: string, text: string): StudioReplSendSuccess | StudioReplSendFailure {
11471
+ function sendTextToStudioReplSession(
11472
+ sessionName: string,
11473
+ text: string,
11474
+ paneTarget?: string,
11475
+ runtimeHint?: StudioReplRuntime | "unknown",
11476
+ options: { submissionId?: string; echoMode?: string } = {},
11477
+ ): StudioReplSendSuccess | StudioReplSendFailure {
11190
11478
  if (!/^[-_.A-Za-z0-9]+$/.test(sessionName)) return { ok: false, message: "Invalid REPL session name." };
11191
11479
  const source = String(text || "");
11192
11480
  if (!source.trim()) return { ok: false, message: "Editor text is empty." };
11193
11481
  if (source.length > STUDIO_REPL_SEND_MAX_CHARS) {
11194
11482
  return { ok: false, message: `REPL input is too large (${source.length} chars; max ${STUDIO_REPL_SEND_MAX_CHARS}).` };
11195
11483
  }
11196
- const prepared = prepareStudioReplSubmission(sessionName, source);
11197
- const pasted = pasteTextToStudioReplPane(sessionName, prepared.submissionText);
11198
- if (!pasted.ok) return { ok: false, message: pasted.message };
11484
+ const prepared = prepareStudioReplSubmission(sessionName, source, {
11485
+ submissionId: options.submissionId || `pi-studio:local:${randomUUID()}`,
11486
+ echoMode: normalizeReplSubmissionEchoMode(options.echoMode, STUDIO_REPL_TOOL_ECHO_MODE) as ReplSubmissionEchoMode,
11487
+ }, runtimeHint);
11488
+ const pasted = pasteTextToStudioReplPane(sessionName, prepared.submissionText, paneTarget);
11489
+ if (!pasted.ok) return {
11490
+ ok: false,
11491
+ message: pasted.message,
11492
+ submissionStarted: pasted.submissionStarted,
11493
+ runtime: prepared.runtime,
11494
+ usedControlFile: prepared.usedControlFile,
11495
+ submissionText: prepared.submissionText,
11496
+ completionLine: prepared.completionLine,
11497
+ controlFiles: prepared.controlFiles,
11498
+ display: prepared.display,
11499
+ };
11199
11500
  return {
11200
11501
  ok: true,
11201
11502
  message: "Sent to REPL.",
11202
11503
  runtime: prepared.runtime,
11203
11504
  usedControlFile: prepared.usedControlFile,
11204
11505
  submissionText: prepared.submissionText,
11506
+ completionLine: prepared.completionLine,
11205
11507
  controlFiles: prepared.controlFiles,
11508
+ display: prepared.display,
11206
11509
  };
11207
11510
  }
11208
11511
 
@@ -11227,14 +11530,15 @@ function stripStudioReplSubmissionEcho(output: string): string {
11227
11530
  let value = String(output || "").replace(/^\s+/, "");
11228
11531
  // The raw tmux mirror should stay raw, but Studio/tool result output should not
11229
11532
  // expose the temp-file wrapper used to submit multiline snippets safely. The
11230
- // `pi-studio-re` fragment intentionally catches IPython's wrapped display of
11231
- // `pi-studio-repl/...` paths across continuation prompt lines.
11533
+ // The root fragment catches both legacy long Studio paths and compact private
11534
+ // control paths when IPython wraps them across continuation prompt lines.
11232
11535
  const submissionEchoPatterns = [
11233
- /^.*exec\(open\([\s\S]*?pi-studio-re[\s\S]*?globals\(\)\)\s*$/gm,
11234
- /^.*include\([\s\S]*?pi-studio-re[\s\S]*?\.jl"\)\s*$/gm,
11235
- /^.*source\([\s\S]*?pi-studio-re[\s\S]*?local\s*=\s*\.GlobalEnv\)\s*$/gm,
11236
- /^.*:script\s+[\s\S]*?pi-studio-re[\s\S]*?\.ghci"?\s*$/gm,
11237
- /^.*\(do\s+\(load-file\s+[\s\S]*?pi-studio-re[\s\S]*?:pi-studio\/silent\)\s*$/gm,
11536
+ /^.*exec\(open\([\s\S]*?(?:pi-studio-re|pi-rc-)[\s\S]*?globals\(\)\)\s*$/gm,
11537
+ /^.*include\([\s\S]*?(?:pi-studio-re|pi-rc-)[\s\S]*?\.jl"\)\s*$/gm,
11538
+ /^.*source\([\s\S]*?(?:pi-studio-re|pi-rc-)[\s\S]*?local\s*=\s*\.GlobalEnv\)\s*$/gm,
11539
+ /^.*:script\s+[\s\S]*?(?:pi-studio-re|pi-rc-)[\s\S]*?\.ghci"?\s*$/gm,
11540
+ /^.*\(do\s+\(load-file\s+[\s\S]*?(?:pi-studio-re|pi-rc-)[\s\S]*?:pi-studio\/silent\)\s*$/gm,
11541
+ /^.*\.\s+[\s\S]*?(?:pi-studio-re|pi-rc-)[\s\S]*?\.sh[\s\S]*?(?:done\.flag|[a-f0-9]{16}\.done).*$/gm,
11238
11542
  ];
11239
11543
  for (const pattern of submissionEchoPatterns) value = value.replace(pattern, "");
11240
11544
  return value.replace(/^(?:\s*\n)+/, "").replace(/[\t ]+$/gm, "").trimEnd();
@@ -11248,8 +11552,21 @@ function stripTrailingStudioReplPrompts(output: string): string {
11248
11552
  return lines.join("\n").trimEnd();
11249
11553
  }
11250
11554
 
11251
- function cleanStudioReplCapturedOutput(output: string): string {
11252
- return stripTrailingStudioReplPrompts(stripStudioReplSubmissionEcho(output));
11555
+ function stripStudioReplCompletionEcho(output: string, completionLine?: string): string {
11556
+ const value = String(output || "");
11557
+ if (!completionLine) return value;
11558
+ const completionIndex = value.lastIndexOf(completionLine);
11559
+ if (completionIndex < 0) return value;
11560
+ const lineStart = value.lastIndexOf("\n", completionIndex - 1) + 1;
11561
+ const newlineIndex = value.indexOf("\n", completionIndex + completionLine.length);
11562
+ const lineEnd = newlineIndex < 0 ? value.length : newlineIndex + 1;
11563
+ return value.slice(0, lineStart) + value.slice(lineEnd);
11564
+ }
11565
+
11566
+ function cleanStudioReplCapturedOutput(output: string, display?: ReplSubmissionDisplay, completionLine?: string): string {
11567
+ const completionCleaned = stripStudioReplCompletionEcho(output, completionLine);
11568
+ const displayCleaned = display ? stripReplSubmissionDisplay(completionCleaned, display) : completionCleaned;
11569
+ return stripTrailingStudioReplPrompts(stripStudioReplSubmissionEcho(displayCleaned));
11253
11570
  }
11254
11571
 
11255
11572
  function normalizeStudioReplJournalMode(mode: unknown): StudioReplJournalEntry["mode"] {
@@ -11257,18 +11574,27 @@ function normalizeStudioReplJournalMode(mode: unknown): StudioReplJournalEntry["
11257
11574
  }
11258
11575
 
11259
11576
  function normalizeStudioReplJournalStatus(status: unknown): StudioReplJournalEntry["status"] {
11260
- return status === "captured" || status === "timeout" || status === "error" || status === "note" ? status : "sent";
11577
+ return status === "sending"
11578
+ || status === "captured"
11579
+ || status === "timeout"
11580
+ || status === "error"
11581
+ || status === "note"
11582
+ ? status
11583
+ : "sent";
11261
11584
  }
11262
11585
 
11263
- function makeStudioReplJournalEntry(details: Partial<StudioReplJournalEntry> & { sessionName: string; code: string }): StudioReplJournalEntry {
11586
+ function makeStudioReplJournalEntry(details: Partial<StudioReplJournalEntry> & { sessionName: string; code?: string }): StudioReplJournalEntry {
11264
11587
  const now = Date.now();
11588
+ const createdAt = typeof details.createdAt === "number" && Number.isFinite(details.createdAt) ? details.createdAt : now;
11265
11589
  return {
11266
11590
  id: typeof details.id === "string" && details.id.trim() ? details.id.trim() : `repl-journal-${now.toString(36)}-${randomUUID().slice(0, 8)}`,
11267
11591
  requestId: typeof details.requestId === "string" ? details.requestId : "",
11268
- createdAt: typeof details.createdAt === "number" && Number.isFinite(details.createdAt) ? details.createdAt : now,
11592
+ createdAt,
11269
11593
  updatedAt: typeof details.updatedAt === "number" && Number.isFinite(details.updatedAt) ? details.updatedAt : now,
11594
+ completedAt: typeof details.completedAt === "number" && Number.isFinite(details.completedAt) ? details.completedAt : null,
11270
11595
  sessionName: String(details.sessionName || ""),
11271
- runtime: details.runtime || "unknown",
11596
+ runtime: normalizeStudioReplRuntime(details.runtime) || "unknown",
11597
+ origin: details.origin === "pi-repl" || details.origin === "pi-studio" ? details.origin : "unknown",
11272
11598
  label: typeof details.label === "string" && details.label.trim() ? details.label.trim() : "REPL send",
11273
11599
  mode: normalizeStudioReplJournalMode(details.mode),
11274
11600
  prose: typeof details.prose === "string" ? details.prose : "",
@@ -11276,6 +11602,10 @@ function makeStudioReplJournalEntry(details: Partial<StudioReplJournalEntry> & {
11276
11602
  output: typeof details.output === "string" ? details.output : "",
11277
11603
  status: normalizeStudioReplJournalStatus(details.status),
11278
11604
  skippedChunks: Math.max(0, Math.floor(Number(details.skippedChunks) || 0)),
11605
+ codeOmittedChars: Math.max(0, Math.floor(Number(details.codeOmittedChars) || 0)),
11606
+ proseOmittedChars: Math.max(0, Math.floor(Number(details.proseOmittedChars) || 0)),
11607
+ outputOmittedChars: Math.max(0, Math.floor(Number(details.outputOmittedChars) || 0)),
11608
+ sharedSynced: details.sharedSynced === true,
11279
11609
  };
11280
11610
  }
11281
11611
 
@@ -11298,47 +11628,206 @@ function upsertStudioReplJournalEntry(entry: StudioReplJournalEntry): StudioRepl
11298
11628
  studioReplJournalEntries = studioReplJournalEntries
11299
11629
  .sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0))
11300
11630
  .slice(-STUDIO_REPL_JOURNAL_MAX_ENTRIES);
11631
+ const retainedIds = new Set(studioReplJournalEntries.map((candidate) => candidate.id));
11632
+ for (const unsyncedId of studioReplUnsyncedJournalEntryIds) {
11633
+ if (!retainedIds.has(unsyncedId)) studioReplUnsyncedJournalEntryIds.delete(unsyncedId);
11634
+ }
11301
11635
  return studioReplJournalEntries.find((candidate) => candidate.id === entry.id || (entry.requestId && candidate.requestId === entry.requestId)) || entry;
11302
11636
  }
11303
11637
 
11304
- function recordStudioReplJournalEntry(details: Partial<StudioReplJournalEntry> & { sessionName: string; code: string }): StudioReplJournalEntry {
11305
- return upsertStudioReplJournalEntry(makeStudioReplJournalEntry(details));
11638
+ function getStudioReplSessionRecordIdentity(session: StudioReplSessionInfo): {
11639
+ sessionName: string;
11640
+ tmuxSessionId: string;
11641
+ tmuxSessionCreatedAt: number;
11642
+ runtime: StudioReplRuntime | "unknown";
11643
+ } {
11644
+ return {
11645
+ sessionName: session.sessionName,
11646
+ tmuxSessionId: session.tmuxSessionId,
11647
+ tmuxSessionCreatedAt: session.tmuxSessionCreatedAt,
11648
+ runtime: session.runtime,
11649
+ };
11650
+ }
11651
+
11652
+ function isSameStudioReplSessionLifetime(left: StudioReplSessionInfo, right: StudioReplSessionInfo): boolean {
11653
+ return left.sessionName === right.sessionName
11654
+ && left.tmuxSessionId === right.tmuxSessionId
11655
+ && left.tmuxSessionCreatedAt === right.tmuxSessionCreatedAt;
11656
+ }
11657
+
11658
+ function recordStudioReplJournalEntry(
11659
+ details: Partial<StudioReplJournalEntry> & { sessionName: string; code?: string },
11660
+ knownSession?: StudioReplSessionInfo | null,
11661
+ ): StudioReplJournalEntry {
11662
+ let entry = upsertStudioReplJournalEntry(makeStudioReplJournalEntry({
11663
+ ...details,
11664
+ origin: details.origin || "pi-studio",
11665
+ sharedSynced: false,
11666
+ }));
11667
+ const localEntryId = entry.id;
11668
+ studioReplUnsyncedJournalEntryIds.add(localEntryId);
11669
+ // A previously validated exact-lifetime identity lets completion/error updates
11670
+ // reach the same sidecar even if that tmux session disappears before capture.
11671
+ const session = knownSession || inspectStudioReplSession(entry.sessionName);
11672
+ if (!session?.recordId || session.recordWarning || session.sessionName !== entry.sessionName) return entry;
11673
+ try {
11674
+ const recorded = upsertReplSessionRecordEntry(
11675
+ session.recordId,
11676
+ getStudioReplSessionRecordIdentity(session),
11677
+ entry,
11678
+ { origin: entry.origin === "pi-repl" ? "pi-repl" : "pi-studio" },
11679
+ );
11680
+ entry = upsertStudioReplJournalEntry(makeStudioReplJournalEntry({
11681
+ ...(recorded.entry as Partial<StudioReplJournalEntry> & { sessionName: string }),
11682
+ sharedSynced: true,
11683
+ }));
11684
+ studioReplUnsyncedJournalEntryIds.delete(localEntryId);
11685
+ studioReplUnsyncedJournalEntryIds.delete(entry.id);
11686
+ } catch {
11687
+ // Retain the in-memory entry as a standalone fallback when shared state is unavailable.
11688
+ }
11689
+ return entry;
11306
11690
  }
11307
11691
 
11308
- function updateStudioReplJournalEntryOutput(requestId: string, sessionName: string, output: string, status: StudioReplJournalEntry["status"]): void {
11692
+ function getStudioReplJournalEntries(sessionName: string | null | undefined): StudioReplJournalEntry[] {
11693
+ const normalizedSessionName = String(sessionName || "").trim();
11694
+ const sessions = normalizedSessionName
11695
+ ? [inspectStudioReplSession(normalizedSessionName)].filter((session): session is StudioReplSessionInfo => Boolean(session))
11696
+ : listStudioReplSessions().sessions;
11697
+ for (const session of sessions) {
11698
+ if (!session.recordId || session.recordWarning) continue;
11699
+ try {
11700
+ const record = readReplSessionRecord(session.recordId, getStudioReplSessionRecordIdentity(session));
11701
+ if (record) {
11702
+ const unsynced = studioReplJournalEntries.filter((entry) => (
11703
+ entry.sessionName === session.sessionName && studioReplUnsyncedJournalEntryIds.has(entry.id)
11704
+ ));
11705
+ const sharedEntries = record.entries.map((sharedEntry: Partial<StudioReplJournalEntry> & { sessionName: string }) => (
11706
+ makeStudioReplJournalEntry({ ...sharedEntry, sharedSynced: true })
11707
+ ));
11708
+ for (const sharedEntry of sharedEntries) studioReplUnsyncedJournalEntryIds.delete(sharedEntry.id);
11709
+ studioReplJournalEntries = [
11710
+ ...studioReplJournalEntries.filter((entry) => entry.sessionName !== session.sessionName),
11711
+ ...sharedEntries,
11712
+ ...unsynced.filter((entry) => !sharedEntries.some((sharedEntry: StudioReplJournalEntry) => sharedEntry.id === entry.id)),
11713
+ ].sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0)).slice(-STUDIO_REPL_JOURNAL_MAX_ENTRIES);
11714
+ }
11715
+ } catch {
11716
+ // The raw tmux mirror and in-memory Studio record remain independently usable.
11717
+ }
11718
+ }
11719
+ const entries = normalizedSessionName
11720
+ ? studioReplJournalEntries.filter((entry) => entry.sessionName === normalizedSessionName)
11721
+ : studioReplJournalEntries;
11722
+ return entries.slice(-STUDIO_REPL_JOURNAL_MAX_ENTRIES).map((entry) => ({
11723
+ ...entry,
11724
+ sharedSynced: !studioReplUnsyncedJournalEntryIds.has(entry.id) && entry.sharedSynced === true,
11725
+ }));
11726
+ }
11727
+
11728
+ function updateStudioReplJournalEntryOutput(
11729
+ requestId: string,
11730
+ sessionName: string,
11731
+ output: string,
11732
+ status: StudioReplJournalEntry["status"],
11733
+ knownSession?: StudioReplSessionInfo | null,
11734
+ ): void {
11309
11735
  const normalizedRequestId = String(requestId || "");
11310
11736
  const normalizedSessionName = String(sessionName || "");
11311
- const existing = studioReplJournalEntries.find((entry) => (
11737
+ const existing = getStudioReplJournalEntries(normalizedSessionName).slice().reverse().find((entry) => (
11312
11738
  (normalizedRequestId && entry.requestId === normalizedRequestId)
11313
- || (!normalizedRequestId && normalizedSessionName && entry.sessionName === normalizedSessionName && entry.status === "sent")
11739
+ || (!normalizedRequestId && entry.status === "sent")
11314
11740
  ));
11315
11741
  if (!existing) return;
11316
- upsertStudioReplJournalEntry({
11742
+ recordStudioReplJournalEntry({
11317
11743
  ...existing,
11318
11744
  output: String(output || ""),
11319
11745
  status,
11746
+ completedAt: Date.now(),
11320
11747
  updatedAt: Date.now(),
11321
- });
11748
+ }, knownSession);
11322
11749
  }
11323
11750
 
11324
- function getStudioReplJournalEntries(sessionName: string | null | undefined): StudioReplJournalEntry[] {
11751
+ function clearStudioReplJournal(sessionName: string): void {
11325
11752
  const normalizedSessionName = String(sessionName || "").trim();
11326
- const entries = normalizedSessionName
11327
- ? studioReplJournalEntries.filter((entry) => entry.sessionName === normalizedSessionName)
11328
- : studioReplJournalEntries;
11329
- return entries.slice(-STUDIO_REPL_JOURNAL_MAX_ENTRIES).map((entry) => ({ ...entry }));
11753
+ if (!normalizedSessionName) return;
11754
+ const clearedIds = studioReplJournalEntries.filter((entry) => entry.sessionName === normalizedSessionName).map((entry) => entry.id);
11755
+ studioReplJournalEntries = studioReplJournalEntries.filter((entry) => entry.sessionName !== normalizedSessionName);
11756
+ for (const id of clearedIds) studioReplUnsyncedJournalEntryIds.delete(id);
11757
+ const session = inspectStudioReplSession(normalizedSessionName);
11758
+ if (!session?.recordId || session.recordWarning) return;
11759
+ clearReplSessionRecord(session.recordId, getStudioReplSessionRecordIdentity(session));
11330
11760
  }
11331
11761
 
11332
- async function waitForStudioReplDoneFile(doneFile: string | undefined, timeoutMs: number): Promise<boolean> {
11762
+ async function waitForStudioReplDoneFile(doneFile: string | undefined, timeoutMs: number, signal?: AbortSignal): Promise<boolean> {
11333
11763
  if (!doneFile) return false;
11334
11764
  const deadline = Date.now() + clampStudioReplSendTimeout(timeoutMs);
11335
11765
  while (Date.now() < deadline) {
11336
11766
  if (existsSync(doneFile)) return true;
11767
+ if (signal?.aborted) return false;
11337
11768
  await sleep(100);
11338
11769
  }
11339
11770
  return existsSync(doneFile);
11340
11771
  }
11341
11772
 
11773
+ function sleepWithoutKeepingStudioProcessAlive(ms: number): Promise<void> {
11774
+ return new Promise((resolveSleep) => {
11775
+ const timer = setTimeout(resolveSleep, ms);
11776
+ timer.unref?.();
11777
+ });
11778
+ }
11779
+
11780
+ function retainStudioReplSubmissionUntilSettled(
11781
+ session: StudioReplSessionInfo,
11782
+ controlFiles: StudioReplControlFiles,
11783
+ lease: Awaited<ReturnType<typeof acquireReplSessionSendLease>> | null,
11784
+ ): void {
11785
+ // A caller timeout or abort does not stop code already submitted to tmux.
11786
+ // Keep the private control files (and any shared lease) until the runtime
11787
+ // wrapper reports completion, or until this exact tmux lifetime disappears.
11788
+ void (async () => {
11789
+ let nextIdentityCheck = 0;
11790
+ let missingChecks = 0;
11791
+ try {
11792
+ while (!existsSync(controlFiles.doneFile)) {
11793
+ if (!existsSync(controlFiles.sourceFile)) return;
11794
+ if (Date.now() >= nextIdentityCheck) {
11795
+ try {
11796
+ const current = inspectStudioReplSession(session.sessionName);
11797
+ if (current && isSameStudioReplSessionLifetime(session, current)) {
11798
+ missingChecks = 0;
11799
+ } else {
11800
+ missingChecks += 1;
11801
+ if (missingChecks >= 3) return;
11802
+ }
11803
+ } catch {
11804
+ // A transient inspection failure must not make overlapping sends safe.
11805
+ missingChecks = 0;
11806
+ }
11807
+ nextIdentityCheck = Date.now() + 1_000;
11808
+ }
11809
+ await sleepWithoutKeepingStudioProcessAlive(100);
11810
+ }
11811
+ } finally {
11812
+ cleanupPrivateReplControlFiles(controlFiles);
11813
+ await lease?.release().catch(() => undefined);
11814
+ }
11815
+ })();
11816
+ }
11817
+
11818
+ async function releaseOrRetainStudioReplSubmission(
11819
+ lease: Awaited<ReturnType<typeof acquireReplSessionSendLease>> | null,
11820
+ session: StudioReplSessionInfo | null,
11821
+ controlFiles: StudioReplControlFiles | undefined,
11822
+ ): Promise<void> {
11823
+ if (session && controlFiles && !existsSync(controlFiles.doneFile)) {
11824
+ retainStudioReplSubmissionUntilSettled(session, controlFiles, lease);
11825
+ return;
11826
+ }
11827
+ cleanupPrivateReplControlFiles(controlFiles);
11828
+ await lease?.release().catch(() => undefined);
11829
+ }
11830
+
11342
11831
  function interruptStudioReplSession(sessionName: string): { ok: true; message: string } | { ok: false; message: string } {
11343
11832
  if (!/^[-_.A-Za-z0-9]+$/.test(sessionName)) return { ok: false, message: "Invalid REPL session name." };
11344
11833
  const result = runStudioTmux(["send-keys", "-t", getStudioReplPaneTarget(sessionName), "C-c"], { timeout: 5_000 });
@@ -11945,6 +12434,11 @@ ${cssVarsBlock}
11945
12434
  <option value="raw" selected>Send mode: Raw</option>
11946
12435
  <option value="literate">Send mode: Literate</option>
11947
12436
  </select>
12437
+ <select id="replEchoModeSelect" class="studio-flat-select" hidden aria-label="REPL submission echo" title="Choose how much submitted code Studio displays in the raw REPL pane.">
12438
+ <option value="off" selected>Pane echo: Off</option>
12439
+ <option value="summary">Pane echo: Summary</option>
12440
+ <option value="full">Pane echo: Full (raw code)</option>
12441
+ </select>
11948
12442
  </div>
11949
12443
  <div class="source-actions-row">
11950
12444
  <button id="copyDraftBtn" type="button" title="Copy the current editor text to the clipboard.">Copy</button>
@@ -12479,6 +12973,7 @@ export default function (pi: ExtensionAPI) {
12479
12973
  parameters: STUDIO_REPL_STATUS_TOOL_PARAMS,
12480
12974
  async execute(_toolCallId, params) {
12481
12975
  const selected = selectStudioReplSessionForTool({ sessionName: params.sessionName, target: params.target });
12976
+ const recordEntries = selected.session ? getStudioReplJournalEntries(selected.session.sessionName) : [];
12482
12977
  const lines = [
12483
12978
  `Active Studio REPL: ${studioReplActiveSessionName || "none"}`,
12484
12979
  `tmux sessions visible to Studio: ${selected.sessions.length}`,
@@ -12486,6 +12981,10 @@ export default function (pi: ExtensionAPI) {
12486
12981
  if (selected.error) lines.push(`Selection: ${selected.error}`);
12487
12982
  if (selected.session) {
12488
12983
  lines.push(`Selected: ${selected.session.sessionName} (${selected.session.runtime}, ${selected.session.source})`);
12984
+ if (selected.session.recordId && !selected.session.recordWarning) {
12985
+ lines.push(`Shared clean record: ${recordEntries.length} entries (${selected.session.recordId})`);
12986
+ }
12987
+ if (selected.session.recordWarning) lines.push(`Shared record warning: ${selected.session.recordWarning}`);
12489
12988
  }
12490
12989
  for (const session of selected.sessions) {
12491
12990
  lines.push(`- ${session.sessionName} | runtime=${session.runtime} | source=${session.source} | target=${session.target}`);
@@ -12495,6 +12994,7 @@ export default function (pi: ExtensionAPI) {
12495
12994
  details: {
12496
12995
  activeSessionName: studioReplActiveSessionName,
12497
12996
  selectedSession: selected.session,
12997
+ selectedRecordEntries: recordEntries,
12498
12998
  sessions: selected.sessions,
12499
12999
  } as Record<string, unknown>,
12500
13000
  };
@@ -12509,11 +13009,12 @@ export default function (pi: ExtensionAPI) {
12509
13009
  promptGuidelines: [
12510
13010
  "Use studio_repl_send when the user asks to run code in the active Studio REPL.",
12511
13011
  "Do not improvise tmux paste-buffer commands for Studio REPL code; studio_repl_send handles multiline quoting and runtime-specific submission.",
13012
+ "Submitted-code display is off by default; use echoMode='summary' or echoMode='full' only when the user asks to show code and alignment anchors in the raw pane.",
12512
13013
  "If several REPL sessions of the same runtime are running, use studio_repl_status first or pass the exact sessionName when known.",
12513
13014
  ],
12514
13015
  parameters: STUDIO_REPL_SEND_TOOL_PARAMS,
12515
13016
  executionMode: "sequential",
12516
- async execute(toolCallId, params) {
13017
+ async execute(toolCallId, params, signal) {
12517
13018
  const selected = selectStudioReplSessionForTool({ sessionName: params.sessionName, target: params.target });
12518
13019
  if (!selected.session) {
12519
13020
  return {
@@ -12522,75 +13023,152 @@ export default function (pi: ExtensionAPI) {
12522
13023
  };
12523
13024
  }
12524
13025
 
12525
- const before = captureStudioReplSession(selected.session.sessionName);
12526
- const beforeTranscript = before.ok ? before.transcript : "";
12527
- const sent = sendTextToStudioReplSession(selected.session.sessionName, params.code);
12528
- if (!sent.ok) {
12529
- return {
12530
- content: [{ type: "text", text: sent.message }],
12531
- details: { ok: false, error: sent.message, session: selected.session, sessions: selected.sessions } as Record<string, unknown>,
12532
- };
12533
- }
12534
- studioReplActiveSessionName = selected.session.sessionName;
12535
-
12536
13026
  const timeoutMs = clampStudioReplSendTimeout(params.timeoutMs);
12537
- let completed = false;
12538
- if (sent.controlFiles?.doneFile) {
12539
- completed = await waitForStudioReplDoneFile(sent.controlFiles.doneFile, timeoutMs);
12540
- } else {
12541
- await sleep(Math.min(750, timeoutMs));
12542
- }
12543
- const after = captureStudioReplSession(selected.session.sessionName);
12544
- const afterTranscript = after.ok ? after.transcript : "";
12545
- const rawOutput = extractStudioReplTranscriptDelta(beforeTranscript, afterTranscript);
12546
- const output = cleanStudioReplCapturedOutput(rawOutput);
12547
- const statusLine = sent.controlFiles?.doneFile
12548
- ? (completed ? "Completed." : `Timed out after ${timeoutMs} ms waiting for completion marker.`)
12549
- : "Submitted.";
12550
- const text = [
12551
- `${statusLine} ${sent.message}`,
12552
- output ? "" : undefined,
12553
- output || undefined,
12554
- ].filter(Boolean).join("\n");
12555
- recordStudioReplJournalEntry({
12556
- requestId: `tool:${toolCallId}`,
12557
- sessionName: selected.session.sessionName,
12558
- runtime: sent.runtime === "unknown" ? selected.session.runtime : sent.runtime,
12559
- label: "Pi",
12560
- mode: "agent",
12561
- code: params.code,
12562
- output,
12563
- status: sent.controlFiles?.doneFile && !completed ? "timeout" : (output.trim() ? "captured" : "sent"),
12564
- });
12565
- broadcastStudioReplToolSend({
12566
- toolCallId,
12567
- sessionName: selected.session.sessionName,
12568
- runtime: sent.runtime === "unknown" ? selected.session.runtime : sent.runtime,
12569
- code: params.code,
12570
- label: "Pi",
12571
- output,
12572
- completed,
12573
- timedOut: Boolean(sent.controlFiles?.doneFile && !completed),
12574
- transcript: afterTranscript,
12575
- capturedAt: Date.now(),
12576
- journalEntries: getStudioReplJournalEntries(selected.session.sessionName),
12577
- });
12578
- return {
12579
- content: [{ type: "text", text }],
12580
- details: {
12581
- ok: true,
13027
+ let lease: Awaited<ReturnType<typeof acquireReplSessionSendLease>> | null = null;
13028
+ let submittedSession: StudioReplSessionInfo | null = null;
13029
+ let submittedControlFiles: StudioReplControlFiles | undefined;
13030
+ let journalEntry: StudioReplJournalEntry | null = null;
13031
+ try {
13032
+ if (selected.session.recordId && !selected.session.recordWarning) {
13033
+ lease = await acquireReplSessionSendLease(selected.session.recordId, {
13034
+ owner: `pi-studio:tool:${toolCallId}`,
13035
+ waitMs: timeoutMs,
13036
+ signal,
13037
+ });
13038
+ }
13039
+ const currentSession = inspectStudioReplSession(selected.session.sessionName);
13040
+ if (!currentSession || !isSameStudioReplSessionLifetime(selected.session, currentSession)) {
13041
+ throw new Error(`REPL session ${selected.session.sessionName} changed while Studio was waiting to send.`);
13042
+ }
13043
+ if (lease && currentSession.recordId !== selected.session.recordId) {
13044
+ throw new Error(`The shared record for ${selected.session.sessionName} changed while Studio was waiting to send.`);
13045
+ }
13046
+ const before = captureStudioReplSession(currentSession.sessionName);
13047
+ if (!before.ok) throw new Error(`Could not capture ${currentSession.sessionName} before sending: ${before.message}`);
13048
+ if (!isSameStudioReplSessionLifetime(currentSession, before.session)) {
13049
+ throw new Error(`REPL session ${currentSession.sessionName} changed before Studio could capture it.`);
13050
+ }
13051
+ const beforeTranscript = before.transcript;
13052
+ journalEntry = recordStudioReplJournalEntry({
13053
+ id: `pi-studio:tool:${toolCallId}`,
13054
+ requestId: `tool:${toolCallId}`,
13055
+ sessionName: currentSession.sessionName,
13056
+ runtime: currentSession.runtime,
13057
+ origin: "pi-studio",
13058
+ label: "Pi",
13059
+ mode: "agent",
13060
+ code: params.code,
13061
+ status: "sending",
13062
+ }, currentSession);
13063
+ const sent = sendTextToStudioReplSession(currentSession.sessionName, params.code, currentSession.target, currentSession.runtime, {
13064
+ submissionId: journalEntry.id,
13065
+ echoMode: params.echoMode || STUDIO_REPL_TOOL_ECHO_MODE,
13066
+ });
13067
+ submittedControlFiles = sent.controlFiles;
13068
+ if (!sent.ok) {
13069
+ if (sent.submissionStarted) submittedSession = currentSession;
13070
+ journalEntry = recordStudioReplJournalEntry({
13071
+ ...journalEntry,
13072
+ output: sent.message,
13073
+ status: "error",
13074
+ completedAt: Date.now(),
13075
+ }, currentSession);
13076
+ return {
13077
+ content: [{ type: "text", text: sent.message }],
13078
+ details: { ok: false, error: sent.message, session: selected.session, sessions: selected.sessions, recordEntryId: journalEntry.id } as Record<string, unknown>,
13079
+ };
13080
+ }
13081
+ submittedSession = currentSession;
13082
+ studioReplActiveSessionName = selected.session.sessionName;
13083
+
13084
+ let completed = false;
13085
+ if (sent.controlFiles?.doneFile) {
13086
+ completed = await waitForStudioReplDoneFile(sent.controlFiles.doneFile, timeoutMs, signal);
13087
+ if (signal?.aborted && !completed) {
13088
+ throw new Error("studio_repl_send was aborted after submission; the shared session remains busy until the running code settles.");
13089
+ }
13090
+ } else {
13091
+ await sleep(Math.min(750, timeoutMs));
13092
+ }
13093
+ const after = captureStudioReplSession(currentSession.sessionName);
13094
+ if (!after.ok) throw new Error(`Could not capture ${currentSession.sessionName} after sending: ${after.message}`);
13095
+ if (!isSameStudioReplSessionLifetime(currentSession, after.session)) {
13096
+ throw new Error(`REPL session ${currentSession.sessionName} changed before Studio captured the result.`);
13097
+ }
13098
+ const afterTranscript = after.transcript;
13099
+ const rawOutput = extractStudioReplTranscriptDelta(beforeTranscript, afterTranscript);
13100
+ const output = cleanStudioReplCapturedOutput(rawOutput, sent.display, sent.completionLine);
13101
+ const status: StudioReplJournalEntry["status"] = sent.controlFiles?.doneFile
13102
+ ? (completed ? "captured" : "timeout")
13103
+ : (output.trim() ? "captured" : "sent");
13104
+ journalEntry = recordStudioReplJournalEntry({
13105
+ ...journalEntry,
13106
+ runtime: sent.runtime === "unknown" ? selected.session.runtime : sent.runtime,
13107
+ output,
13108
+ status,
13109
+ completedAt: Date.now(),
13110
+ }, currentSession);
13111
+ const statusLine = sent.controlFiles?.doneFile
13112
+ ? (completed ? "Completed." : `Timed out after ${timeoutMs} ms waiting for completion marker.`)
13113
+ : "Submitted.";
13114
+ const text = [
13115
+ `${statusLine} ${sent.message}`,
13116
+ output ? "" : undefined,
13117
+ output || undefined,
13118
+ ].filter(Boolean).join("\n");
13119
+ broadcastStudioReplToolSend({
13120
+ toolCallId,
13121
+ sessionName: selected.session.sessionName,
13122
+ runtime: sent.runtime === "unknown" ? selected.session.runtime : sent.runtime,
13123
+ code: params.code,
13124
+ label: "Pi",
13125
+ output,
13126
+ sharedSynced: journalEntry.sharedSynced === true,
12582
13127
  completed,
12583
13128
  timedOut: Boolean(sent.controlFiles?.doneFile && !completed),
12584
- timeoutMs,
12585
- session: selected.session,
12586
- sessions: selected.sessions,
12587
- runtime: sent.runtime,
12588
- usedControlFile: sent.usedControlFile,
12589
- submissionText: sent.submissionText,
12590
- controlFiles: sent.controlFiles,
12591
- output,
12592
- } as Record<string, unknown>,
12593
- };
13129
+ transcript: afterTranscript,
13130
+ capturedAt: Date.now(),
13131
+ journalEntries: getStudioReplJournalEntries(selected.session.sessionName),
13132
+ });
13133
+ return {
13134
+ content: [{ type: "text", text }],
13135
+ details: {
13136
+ ok: true,
13137
+ completed,
13138
+ timedOut: Boolean(sent.controlFiles?.doneFile && !completed),
13139
+ timeoutMs,
13140
+ session: selected.session,
13141
+ sessions: selected.sessions,
13142
+ runtime: sent.runtime,
13143
+ usedControlFile: sent.usedControlFile,
13144
+ submissionText: sent.submissionText,
13145
+ controlFiles: sent.controlFiles,
13146
+ echoMode: sent.display?.mode || "off",
13147
+ submissionAnchorId: sent.display?.enabled ? sent.display.anchorId : undefined,
13148
+ output,
13149
+ recordEntryId: journalEntry.id,
13150
+ recordPath: selected.session.recordPath,
13151
+ } as Record<string, unknown>,
13152
+ };
13153
+ } catch (error) {
13154
+ if (journalEntry) {
13155
+ try {
13156
+ recordStudioReplJournalEntry({
13157
+ ...journalEntry,
13158
+ output: error instanceof Error ? error.message : String(error),
13159
+ status: error instanceof Error && /timed out/i.test(error.message) ? "timeout" : "error",
13160
+ completedAt: Date.now(),
13161
+ }, submittedSession || selected.session);
13162
+ } catch {
13163
+ // Preserve the execution error when record maintenance also fails.
13164
+ }
13165
+ }
13166
+ throw error;
13167
+ } finally {
13168
+ await releaseOrRetainStudioReplSubmission(lease, submittedSession, submittedControlFiles);
13169
+ lease = null;
13170
+ submittedControlFiles = undefined;
13171
+ }
12594
13172
  },
12595
13173
  });
12596
13174
 
@@ -15453,33 +16031,65 @@ export default function (pi: ExtensionAPI) {
15453
16031
  sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
15454
16032
  return;
15455
16033
  }
15456
- const before = captureStudioReplSession(msg.sessionName);
15457
- const beforeTranscript = before.ok ? before.transcript : "";
15458
- const sent = sendTextToStudioReplSession(msg.sessionName, msg.text);
15459
- if (!sent.ok) {
15460
- sendToClient(client, { type: "error", requestId: msg.requestId, message: sent.message });
15461
- sendReplCaptureToClient(client, msg.sessionName, { requestId: msg.requestId, replError: sent.message });
15462
- return;
15463
- }
15464
- studioReplActiveSessionName = msg.sessionName;
15465
- recordStudioReplJournalEntry({
15466
- requestId: msg.requestId,
15467
- sessionName: msg.sessionName,
15468
- runtime: sent.runtime,
15469
- label: "Studio",
15470
- mode: "raw",
15471
- code: msg.text,
15472
- status: "sent",
15473
- });
15474
- sendToClient(client, {
15475
- type: "repl_send_ack",
15476
- requestId: msg.requestId,
15477
- sessionName: msg.sessionName,
15478
- message: sent.message,
15479
- journalEntries: getStudioReplJournalEntries(msg.sessionName),
15480
- });
15481
16034
  void (async () => {
16035
+ const session = inspectStudioReplSession(msg.sessionName);
16036
+ let journalEntry: StudioReplJournalEntry | null = null;
16037
+ let lease: Awaited<ReturnType<typeof acquireReplSessionSendLease>> | null = null;
16038
+ let submittedSession: StudioReplSessionInfo | null = null;
16039
+ let submittedControlFiles: StudioReplControlFiles | undefined;
15482
16040
  try {
16041
+ if (!session) throw new Error(`No tmux REPL session named ${msg.sessionName}.`);
16042
+ if (session.recordId && !session.recordWarning) {
16043
+ lease = await acquireReplSessionSendLease(session.recordId, {
16044
+ owner: `pi-studio:browser:${msg.requestId}`,
16045
+ waitMs: STUDIO_REPL_SEND_DEFAULT_TIMEOUT_MS,
16046
+ });
16047
+ }
16048
+ const currentSession = inspectStudioReplSession(msg.sessionName);
16049
+ if (!currentSession || !isSameStudioReplSessionLifetime(session, currentSession)) {
16050
+ throw new Error(`REPL session ${msg.sessionName} changed while Studio was waiting to send.`);
16051
+ }
16052
+ if (lease && currentSession.recordId !== session.recordId) {
16053
+ throw new Error(`The shared record for ${msg.sessionName} changed while Studio was waiting to send.`);
16054
+ }
16055
+ const before = captureStudioReplSession(msg.sessionName);
16056
+ if (!before.ok) throw new Error(`Could not capture ${msg.sessionName} before sending: ${before.message}`);
16057
+ if (!isSameStudioReplSessionLifetime(currentSession, before.session)) {
16058
+ throw new Error(`REPL session ${msg.sessionName} changed before Studio could capture it.`);
16059
+ }
16060
+ const beforeTranscript = before.transcript;
16061
+ journalEntry = recordStudioReplJournalEntry({
16062
+ id: msg.journalEntryId,
16063
+ requestId: msg.requestId,
16064
+ createdAt: msg.createdAt,
16065
+ sessionName: msg.sessionName,
16066
+ runtime: session.runtime,
16067
+ origin: "pi-studio",
16068
+ label: msg.label || "Studio",
16069
+ mode: msg.mode || "raw",
16070
+ prose: msg.prose || "",
16071
+ code: msg.text,
16072
+ status: "sending",
16073
+ skippedChunks: msg.skippedChunks,
16074
+ }, currentSession);
16075
+ const sent = sendTextToStudioReplSession(msg.sessionName, msg.text, currentSession.target, currentSession.runtime, {
16076
+ submissionId: journalEntry.id,
16077
+ echoMode: msg.echoMode,
16078
+ });
16079
+ submittedControlFiles = sent.controlFiles;
16080
+ if (!sent.ok) {
16081
+ if (sent.submissionStarted) submittedSession = currentSession;
16082
+ throw new Error(sent.message);
16083
+ }
16084
+ submittedSession = currentSession;
16085
+ studioReplActiveSessionName = msg.sessionName;
16086
+ sendToClient(client, {
16087
+ type: "repl_send_ack",
16088
+ requestId: msg.requestId,
16089
+ sessionName: msg.sessionName,
16090
+ message: sent.message,
16091
+ journalEntries: getStudioReplJournalEntries(msg.sessionName),
16092
+ });
15483
16093
  const timeoutMs = STUDIO_REPL_SEND_DEFAULT_TIMEOUT_MS;
15484
16094
  let completed = false;
15485
16095
  if (sent.controlFiles?.doneFile) {
@@ -15488,24 +16098,108 @@ export default function (pi: ExtensionAPI) {
15488
16098
  await sleep(Math.min(750, timeoutMs));
15489
16099
  }
15490
16100
  const after = captureStudioReplSession(msg.sessionName);
15491
- const afterTranscript = after.ok ? after.transcript : "";
16101
+ if (!after.ok) throw new Error(`Could not capture ${msg.sessionName} after sending: ${after.message}`);
16102
+ if (!isSameStudioReplSessionLifetime(currentSession, after.session)) {
16103
+ throw new Error(`REPL session ${msg.sessionName} changed before Studio captured the result.`);
16104
+ }
16105
+ const afterTranscript = after.transcript;
15492
16106
  const rawOutput = extractStudioReplTranscriptDelta(beforeTranscript, afterTranscript);
15493
- const output = cleanStudioReplCapturedOutput(rawOutput);
16107
+ const output = cleanStudioReplCapturedOutput(rawOutput, sent.display, sent.completionLine);
15494
16108
  updateStudioReplJournalEntryOutput(
15495
16109
  msg.requestId,
15496
16110
  msg.sessionName,
15497
16111
  output,
15498
- sent.controlFiles?.doneFile && !completed ? "timeout" : (output.trim() ? "captured" : "sent"),
16112
+ sent.controlFiles?.doneFile ? (completed ? "captured" : "timeout") : (output.trim() ? "captured" : "sent"),
16113
+ currentSession,
15499
16114
  );
15500
16115
  sendReplCaptureToClient(client, msg.sessionName, { requestId: msg.requestId });
15501
16116
  } catch (error) {
15502
- updateStudioReplJournalEntryOutput(msg.requestId, msg.sessionName, error instanceof Error ? error.message : String(error), "error");
15503
- sendReplCaptureToClient(client, msg.sessionName, { requestId: msg.requestId, replError: error instanceof Error ? error.message : String(error) });
16117
+ const message = error instanceof Error ? error.message : String(error);
16118
+ if (journalEntry) updateStudioReplJournalEntryOutput(
16119
+ msg.requestId,
16120
+ msg.sessionName,
16121
+ message,
16122
+ "error",
16123
+ submittedSession || session,
16124
+ );
16125
+ sendToClient(client, { type: "error", requestId: msg.requestId, message });
16126
+ sendReplCaptureToClient(client, msg.sessionName, { requestId: msg.requestId, replError: message });
16127
+ } finally {
16128
+ await releaseOrRetainStudioReplSubmission(lease, submittedSession, submittedControlFiles);
16129
+ lease = null;
16130
+ submittedControlFiles = undefined;
15504
16131
  }
15505
16132
  })();
15506
16133
  return;
15507
16134
  }
15508
16135
 
16136
+ if (msg.type === "repl_journal_upsert_request") {
16137
+ if (!isValidRequestId(msg.requestId)) {
16138
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
16139
+ return;
16140
+ }
16141
+ try {
16142
+ if (!inspectStudioReplSession(msg.sessionName)) throw new Error(`No tmux REPL session named ${msg.sessionName}.`);
16143
+ recordStudioReplJournalEntry({
16144
+ ...msg.entry,
16145
+ sessionName: msg.sessionName,
16146
+ origin: "pi-studio",
16147
+ });
16148
+ sendToClient(client, {
16149
+ type: "repl_journal_ack",
16150
+ requestId: msg.requestId,
16151
+ sessionName: msg.sessionName,
16152
+ journalEntries: getStudioReplJournalEntries(msg.sessionName),
16153
+ });
16154
+ } catch (error) {
16155
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: error instanceof Error ? error.message : String(error) });
16156
+ }
16157
+ return;
16158
+ }
16159
+
16160
+ if (msg.type === "repl_journal_import_request") {
16161
+ if (!isValidRequestId(msg.requestId)) {
16162
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
16163
+ return;
16164
+ }
16165
+ try {
16166
+ if (!inspectStudioReplSession(msg.sessionName)) throw new Error(`No tmux REPL session named ${msg.sessionName}.`);
16167
+ for (const entry of msg.entries) {
16168
+ recordStudioReplJournalEntry({ ...entry, sessionName: msg.sessionName, origin: "pi-studio" });
16169
+ }
16170
+ sendToClient(client, {
16171
+ type: "repl_journal_ack",
16172
+ requestId: msg.requestId,
16173
+ sessionName: msg.sessionName,
16174
+ journalEntries: getStudioReplJournalEntries(msg.sessionName),
16175
+ });
16176
+ } catch (error) {
16177
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: error instanceof Error ? error.message : String(error) });
16178
+ }
16179
+ return;
16180
+ }
16181
+
16182
+ if (msg.type === "repl_journal_clear_request") {
16183
+ if (!isValidRequestId(msg.requestId)) {
16184
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
16185
+ return;
16186
+ }
16187
+ try {
16188
+ if (!inspectStudioReplSession(msg.sessionName)) throw new Error(`No tmux REPL session named ${msg.sessionName}.`);
16189
+ clearStudioReplJournal(msg.sessionName);
16190
+ sendToClient(client, {
16191
+ type: "repl_journal_ack",
16192
+ requestId: msg.requestId,
16193
+ sessionName: msg.sessionName,
16194
+ journalEntries: [],
16195
+ cleared: true,
16196
+ });
16197
+ } catch (error) {
16198
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: error instanceof Error ? error.message : String(error) });
16199
+ }
16200
+ return;
16201
+ }
16202
+
15509
16203
  if (msg.type === "repl_interrupt_request") {
15510
16204
  if (!isValidRequestId(msg.requestId)) {
15511
16205
  sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });