pi-studio 0.9.55 → 0.9.56

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,19 @@ 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";
45
58
  import { buildStudioForwardingHint, buildStudioSshTunnelHint, isStudioSshSession as isSshSession } from "./shared/studio-ssh-hint.js";
46
59
  import {
47
60
  buildStudioPendingPage,
@@ -249,9 +262,14 @@ interface StudioContextUsageSnapshot {
249
262
  interface StudioReplSessionInfo {
250
263
  sessionName: string;
251
264
  target: string;
265
+ tmuxSessionId: string;
266
+ tmuxSessionCreatedAt: number;
252
267
  runtime: StudioReplRuntime | "unknown";
253
268
  label: string;
254
269
  source: "studio" | "pi-repl" | "tmux";
270
+ recordId?: string;
271
+ recordPath?: string;
272
+ recordWarning?: string;
255
273
  }
256
274
 
257
275
  interface StudioReplJournalEntry {
@@ -259,15 +277,22 @@ interface StudioReplJournalEntry {
259
277
  requestId: string;
260
278
  createdAt: number;
261
279
  updatedAt: number;
280
+ completedAt: number | null;
262
281
  sessionName: string;
263
282
  runtime: StudioReplRuntime | "unknown";
283
+ origin: "pi-studio" | "pi-repl" | "unknown";
264
284
  label: string;
265
285
  mode: "raw" | "literate" | "agent";
266
286
  prose: string;
267
287
  code: string;
268
288
  output: string;
269
- status: "sent" | "captured" | "timeout" | "error" | "note";
289
+ status: "sending" | "sent" | "captured" | "timeout" | "error" | "note";
270
290
  skippedChunks: number;
291
+ codeOmittedChars?: number;
292
+ proseOmittedChars?: number;
293
+ outputOmittedChars?: number;
294
+ /** Studio-local delivery state; not persisted in the shared protocol snapshot. */
295
+ sharedSynced?: boolean;
271
296
  }
272
297
 
273
298
  interface PreparedStudioPdfExport {
@@ -721,6 +746,32 @@ interface ReplSendRequestMessage {
721
746
  requestId: string;
722
747
  sessionName: string;
723
748
  text: string;
749
+ journalEntryId?: string;
750
+ createdAt?: number;
751
+ label?: string;
752
+ mode?: StudioReplJournalEntry["mode"];
753
+ prose?: string;
754
+ skippedChunks?: number;
755
+ }
756
+
757
+ interface ReplJournalUpsertRequestMessage {
758
+ type: "repl_journal_upsert_request";
759
+ requestId: string;
760
+ sessionName: string;
761
+ entry: Partial<StudioReplJournalEntry>;
762
+ }
763
+
764
+ interface ReplJournalImportRequestMessage {
765
+ type: "repl_journal_import_request";
766
+ requestId: string;
767
+ sessionName: string;
768
+ entries: Array<Partial<StudioReplJournalEntry>>;
769
+ }
770
+
771
+ interface ReplJournalClearRequestMessage {
772
+ type: "repl_journal_clear_request";
773
+ requestId: string;
774
+ sessionName: string;
724
775
  }
725
776
 
726
777
  interface ReplInterruptRequestMessage {
@@ -848,6 +899,9 @@ type IncomingStudioMessage =
848
899
  | ReplStartRequestMessage
849
900
  | ReplStopRequestMessage
850
901
  | ReplSendRequestMessage
902
+ | ReplJournalUpsertRequestMessage
903
+ | ReplJournalImportRequestMessage
904
+ | ReplJournalClearRequestMessage
851
905
  | ReplInterruptRequestMessage
852
906
  | CompactRequestMessage
853
907
  | SaveAsRequestMessage
@@ -913,6 +967,7 @@ const STUDIO_REPL_SEND_MAX_CHARS = 200_000;
913
967
  const STUDIO_REPL_SEND_DEFAULT_TIMEOUT_MS = 20_000;
914
968
  const STUDIO_REPL_SEND_MAX_TIMEOUT_MS = 120_000;
915
969
  const STUDIO_REPL_JOURNAL_MAX_ENTRIES = 300;
970
+ const STUDIO_REPL_RUNTIME_OPTION = "@pi_repl_runtime";
916
971
  const STUDIO_REPL_CONTROL_ROOT = join(tmpdir(), "pi-studio-repl");
917
972
  const STUDIO_SUBPROCESS_OUTPUT_MAX_BYTES = 2_000_000;
918
973
  const STUDIO_PANDOC_TIMEOUT_MS = readStudioPositiveEnvMs("PI_STUDIO_PANDOC_TIMEOUT_MS", 120_000, 5_000, 15 * 60_000);
@@ -1206,6 +1261,7 @@ let studioPersistentStateCache: StudioPersistentState | null = null;
1206
1261
  let studioPersistentStateQueue: Promise<void> = Promise.resolve();
1207
1262
  let transientStudioDocuments: Map<string, { document: InitialStudioDocument; createdAt: number }> = new Map();
1208
1263
  let studioReplJournalEntries: StudioReplJournalEntry[] = [];
1264
+ const studioReplUnsyncedJournalEntryIds = new Set<string>();
1209
1265
 
1210
1266
  function createEmptyStudioPersistentState(): StudioPersistentState {
1211
1267
  return {
@@ -9679,6 +9735,31 @@ function normalizeStudioQuizThinking(value: unknown): StudioQuizThinking {
9679
9735
  return "minimal";
9680
9736
  }
9681
9737
 
9738
+ function parseStudioReplJournalEntryInput(value: unknown): Partial<StudioReplJournalEntry> | null {
9739
+ if (!value || typeof value !== "object") return null;
9740
+ const entry = value as Record<string, unknown>;
9741
+ const code = typeof entry.code === "string" ? entry.code.slice(0, STUDIO_REPL_SEND_MAX_CHARS) : "";
9742
+ const prose = typeof entry.prose === "string" ? entry.prose.slice(0, 80_000) : "";
9743
+ const output = typeof entry.output === "string" ? entry.output.slice(0, 200_000) : "";
9744
+ if (!code.trim() && !prose.trim() && !output.trim()) return null;
9745
+ return {
9746
+ id: typeof entry.id === "string" ? entry.id.slice(0, 240) : undefined,
9747
+ requestId: typeof entry.requestId === "string" ? entry.requestId.slice(0, 300) : undefined,
9748
+ createdAt: typeof entry.createdAt === "number" && Number.isFinite(entry.createdAt) ? entry.createdAt : undefined,
9749
+ updatedAt: typeof entry.updatedAt === "number" && Number.isFinite(entry.updatedAt) ? entry.updatedAt : undefined,
9750
+ completedAt: typeof entry.completedAt === "number" && Number.isFinite(entry.completedAt) ? entry.completedAt : null,
9751
+ runtime: normalizeStudioReplRuntime(entry.runtime) || "unknown",
9752
+ origin: entry.origin === "pi-repl" ? "pi-repl" : "pi-studio",
9753
+ label: typeof entry.label === "string" ? entry.label.slice(0, 240) : undefined,
9754
+ mode: normalizeStudioReplJournalMode(entry.mode),
9755
+ prose,
9756
+ code,
9757
+ output,
9758
+ status: normalizeStudioReplJournalStatus(entry.status),
9759
+ skippedChunks: Math.max(0, Math.min(100_000, Math.floor(Number(entry.skippedChunks) || 0))),
9760
+ };
9761
+ }
9762
+
9682
9763
  function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
9683
9764
  let parsed: unknown;
9684
9765
  try {
@@ -9983,9 +10064,29 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
9983
10064
  requestId: msg.requestId,
9984
10065
  sessionName: msg.sessionName,
9985
10066
  text: msg.text,
10067
+ journalEntryId: typeof msg.journalEntryId === "string" ? msg.journalEntryId.slice(0, 240) : undefined,
10068
+ createdAt: typeof msg.createdAt === "number" && Number.isFinite(msg.createdAt) ? msg.createdAt : undefined,
10069
+ label: typeof msg.label === "string" ? msg.label.slice(0, 240) : undefined,
10070
+ mode: normalizeStudioReplJournalMode(msg.mode),
10071
+ prose: typeof msg.prose === "string" ? msg.prose.slice(0, 80_000) : undefined,
10072
+ skippedChunks: Math.max(0, Math.min(100_000, Math.floor(Number(msg.skippedChunks) || 0))),
9986
10073
  };
9987
10074
  }
9988
10075
 
10076
+ if (msg.type === "repl_journal_upsert_request" && typeof msg.requestId === "string" && typeof msg.sessionName === "string") {
10077
+ const entry = parseStudioReplJournalEntryInput(msg.entry);
10078
+ if (entry) return { type: "repl_journal_upsert_request", requestId: msg.requestId, sessionName: msg.sessionName, entry };
10079
+ }
10080
+
10081
+ if (msg.type === "repl_journal_import_request" && typeof msg.requestId === "string" && typeof msg.sessionName === "string" && Array.isArray(msg.entries)) {
10082
+ const entries = msg.entries.slice(0, 80).map(parseStudioReplJournalEntryInput).filter((entry): entry is Partial<StudioReplJournalEntry> => Boolean(entry));
10083
+ return { type: "repl_journal_import_request", requestId: msg.requestId, sessionName: msg.sessionName, entries };
10084
+ }
10085
+
10086
+ if (msg.type === "repl_journal_clear_request" && typeof msg.requestId === "string" && typeof msg.sessionName === "string") {
10087
+ return { type: "repl_journal_clear_request", requestId: msg.requestId, sessionName: msg.sessionName };
10088
+ }
10089
+
9989
10090
  if (msg.type === "repl_interrupt_request" && typeof msg.requestId === "string" && typeof msg.sessionName === "string") {
9990
10091
  return {
9991
10092
  type: "repl_interrupt_request",
@@ -10877,9 +10978,111 @@ function runStudioTmux(args: string[], options?: { cwd?: string; input?: string;
10877
10978
  return { ok: true, stdout, stderr };
10878
10979
  }
10879
10980
 
10981
+ function readStudioReplTmuxOption(sessionName: string, optionName: string): string | undefined {
10982
+ const result = runStudioTmux(["show-options", "-v", "-t", sessionName, optionName], { timeout: 3_000 });
10983
+ if (!result.ok) return undefined;
10984
+ const value = result.stdout.trim();
10985
+ return value || undefined;
10986
+ }
10987
+
10988
+ function setStudioReplTmuxOptionIfAbsent(sessionName: string, optionName: string, value: string): boolean {
10989
+ return runStudioTmux(["set-option", "-qo", "-t", sessionName, optionName, value], { timeout: 3_000 }).ok;
10990
+ }
10991
+
10992
+ function attachStudioReplSessionRecord(
10993
+ session: StudioReplSessionInfo,
10994
+ recordIdHint?: string,
10995
+ versionHint?: string,
10996
+ ): StudioReplSessionInfo {
10997
+ let recordId = recordIdHint || readStudioReplTmuxOption(session.sessionName, REPL_SESSION_RECORD_ID_OPTION);
10998
+ let version = versionHint || readStudioReplTmuxOption(session.sessionName, REPL_SESSION_RECORD_VERSION_OPTION);
10999
+ if (recordId && !isValidReplSessionRecordId(recordId)) {
11000
+ return { ...session, recordWarning: "Invalid shared REPL record metadata; Studio left it untouched." };
11001
+ }
11002
+ if (!recordId) {
11003
+ const candidate = createReplSessionRecordId();
11004
+ if (!setStudioReplTmuxOptionIfAbsent(session.sessionName, REPL_SESSION_RECORD_ID_OPTION, candidate)) {
11005
+ return { ...session, recordWarning: "Studio could not attach shared record metadata to this tmux session." };
11006
+ }
11007
+ recordId = readStudioReplTmuxOption(session.sessionName, REPL_SESSION_RECORD_ID_OPTION);
11008
+ }
11009
+ if (!recordId || !isValidReplSessionRecordId(recordId)) {
11010
+ return { ...session, recordWarning: "Studio could not read valid shared record metadata from this tmux session." };
11011
+ }
11012
+ if (!version) {
11013
+ setStudioReplTmuxOptionIfAbsent(session.sessionName, REPL_SESSION_RECORD_VERSION_OPTION, String(REPL_SESSION_RECORD_VERSION));
11014
+ version = readStudioReplTmuxOption(session.sessionName, REPL_SESSION_RECORD_VERSION_OPTION);
11015
+ }
11016
+ const recordPath = getReplSessionRecordPath(recordId);
11017
+ if (version !== String(REPL_SESSION_RECORD_VERSION)) {
11018
+ return {
11019
+ ...session,
11020
+ recordId,
11021
+ recordPath,
11022
+ recordWarning: `Shared REPL record version ${version || "unknown"} is not supported by this Studio version.`,
11023
+ };
11024
+ }
11025
+ try {
11026
+ ensureReplSessionRecord(recordId, {
11027
+ sessionName: session.sessionName,
11028
+ tmuxSessionId: session.tmuxSessionId,
11029
+ tmuxSessionCreatedAt: session.tmuxSessionCreatedAt,
11030
+ runtime: session.runtime,
11031
+ });
11032
+ return { ...session, recordId, recordPath };
11033
+ } catch (error) {
11034
+ return {
11035
+ ...session,
11036
+ recordId,
11037
+ recordPath,
11038
+ recordWarning: error instanceof Error ? error.message : String(error),
11039
+ };
11040
+ }
11041
+ }
11042
+
11043
+ function makeStudioReplSessionInfo(
11044
+ sessionName: string,
11045
+ tmuxSessionId = "",
11046
+ tmuxSessionCreatedRaw = "0",
11047
+ runtimeMetadata = "",
11048
+ recordIdHint = "",
11049
+ versionHint = "",
11050
+ ): StudioReplSessionInfo {
11051
+ const inferred = inferStudioReplSessionRuntime(sessionName);
11052
+ const metadataRuntime = normalizeStudioReplRuntime(runtimeMetadata);
11053
+ const runtime = metadataRuntime || inferred.runtime;
11054
+ return attachStudioReplSessionRecord({
11055
+ sessionName,
11056
+ target: getStudioReplPaneTarget(tmuxSessionId || sessionName),
11057
+ tmuxSessionId,
11058
+ tmuxSessionCreatedAt: Math.max(0, Math.floor(Number(tmuxSessionCreatedRaw) || 0)),
11059
+ runtime,
11060
+ label: formatStudioReplSessionLabel(sessionName, runtime, inferred.source),
11061
+ source: inferred.source,
11062
+ }, recordIdHint, versionHint);
11063
+ }
11064
+
11065
+ function inspectStudioReplSession(sessionName: string): StudioReplSessionInfo | null {
11066
+ if (!/^[-_.A-Za-z0-9]+$/.test(sessionName)) return null;
11067
+ const result = runStudioTmux([
11068
+ "display-message",
11069
+ "-p",
11070
+ "-t",
11071
+ getStudioReplPaneTarget(sessionName),
11072
+ "#{session_name}\t#{session_id}\t#{session_created}\t#{@pi_repl_runtime}\t#{@pi_repl_record_id}\t#{@pi_repl_record_version}",
11073
+ ], { timeout: 3_000 });
11074
+ if (!result.ok) return null;
11075
+ const [resolvedName, tmuxSessionId, createdAt, runtime, recordId, version] = result.stdout.trim().split("\t");
11076
+ return makeStudioReplSessionInfo(resolvedName || sessionName, tmuxSessionId, createdAt, runtime, recordId, version);
11077
+ }
11078
+
10880
11079
  function listStudioReplSessions(): { tmuxAvailable: boolean; sessions: StudioReplSessionInfo[]; error?: string } {
10881
11080
  if (!isTmuxAvailable()) return { tmuxAvailable: false, sessions: [], error: "tmux is not available." };
10882
- const result = runStudioTmux(["list-sessions", "-F", "#{session_name}"], { timeout: 3_000 });
11081
+ const result = runStudioTmux([
11082
+ "list-sessions",
11083
+ "-F",
11084
+ "#{session_name}\t#{session_id}\t#{session_created}\t#{@pi_repl_runtime}\t#{@pi_repl_record_id}\t#{@pi_repl_record_version}",
11085
+ ], { timeout: 3_000 });
10883
11086
  if (!result.ok) {
10884
11087
  const message = result.message.toLowerCase().includes("no server running") ? "No tmux sessions are running." : result.message;
10885
11088
  return { tmuxAvailable: true, sessions: [], error: message };
@@ -10888,30 +11091,18 @@ function listStudioReplSessions(): { tmuxAvailable: boolean; sessions: StudioRep
10888
11091
  .split(/\r?\n/)
10889
11092
  .map((line) => line.trim())
10890
11093
  .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
- });
11094
+ .map((line) => line.split("\t"))
11095
+ .filter(([sessionName]) => Boolean(sessionName && shouldShowStudioReplTmuxSession(sessionName)))
11096
+ .map(([sessionName = "", tmuxSessionId = "", createdAt = "0", runtime = "", recordId = "", version = ""]) => (
11097
+ makeStudioReplSessionInfo(sessionName, tmuxSessionId, createdAt, runtime, recordId, version)
11098
+ ));
10902
11099
  return { tmuxAvailable: true, sessions };
10903
11100
  }
10904
11101
 
10905
11102
  function captureStudioReplSession(sessionName: string): { ok: true; transcript: string; session: StudioReplSessionInfo } | { ok: false; message: string } {
10906
11103
  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
- };
11104
+ const session = inspectStudioReplSession(sessionName);
11105
+ if (!session) return { ok: false, message: `No tmux REPL session named ${sessionName}.` };
10915
11106
  const result = runStudioTmux(["capture-pane", "-J", "-p", "-t", session.target, "-S", `-${STUDIO_REPL_CAPTURE_LINES}`], { timeout: 3_000 });
10916
11107
  if (!result.ok) return { ok: false, message: result.message };
10917
11108
  return { ok: true, transcript: String(result.stdout || "").replace(/[\t ]+$/gm, "").trimEnd(), session };
@@ -10923,31 +11114,26 @@ function startStudioReplSession(runtime: StudioReplRuntime, cwd: string, options
10923
11114
  const sessionName = options?.newSession ? getNewStudioReplSessionName(runtime, commandOverride) : getStudioReplSessionName(runtime, commandOverride);
10924
11115
  const existing = runStudioTmux(["has-session", "-t", sessionName], { timeout: 3_000 });
10925
11116
  if (existing.ok) {
10926
- const inferred = inferStudioReplSessionRuntime(sessionName);
11117
+ const session = inspectStudioReplSession(sessionName);
11118
+ if (!session) return { ok: false, message: `Could not inspect existing REPL session ${sessionName}.` };
10927
11119
  return {
10928
11120
  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
- },
11121
+ session,
10936
11122
  message: `${STUDIO_REPL_RUNTIME_LABELS[runtime]} REPL is already running.`,
10937
11123
  };
10938
11124
  }
10939
11125
  const command = getStudioReplRuntimeCommand(runtime, commandOverride);
10940
11126
  const result = runStudioTmux(buildStudioReplTmuxStartArgs(sessionName, cwd || process.cwd(), command), { timeout: 5_000 });
10941
11127
  if (!result.ok) return { ok: false, message: result.message || `Failed to start ${STUDIO_REPL_RUNTIME_LABELS[runtime]} REPL.` };
11128
+ runStudioTmux(["set-option", "-q", "-t", sessionName, STUDIO_REPL_RUNTIME_OPTION, runtime], { timeout: 3_000 });
11129
+ const session = inspectStudioReplSession(sessionName);
11130
+ if (!session) {
11131
+ runStudioTmux(["kill-session", "-t", sessionName], { timeout: 3_000 });
11132
+ return { ok: false, message: `Started ${sessionName}, but could not initialize its shared session metadata.` };
11133
+ }
10942
11134
  return {
10943
11135
  ok: true,
10944
- session: {
10945
- sessionName,
10946
- target: getStudioReplPaneTarget(sessionName),
10947
- runtime,
10948
- label: formatStudioReplSessionLabel(sessionName, runtime, "studio"),
10949
- source: "studio",
10950
- },
11136
+ session,
10951
11137
  message: `Started ${options?.newSession ? "new " : ""}${STUDIO_REPL_RUNTIME_LABELS[runtime]} REPL${commandOverride ? ` with custom command: ${commandOverride}` : ""}.`,
10952
11138
  };
10953
11139
  }
@@ -10985,7 +11171,15 @@ type StudioReplSendSuccess = {
10985
11171
  controlFiles?: StudioReplControlFiles;
10986
11172
  };
10987
11173
 
10988
- type StudioReplSendFailure = { ok: false; message: string };
11174
+ type StudioReplSendFailure = {
11175
+ ok: false;
11176
+ message: string;
11177
+ submissionStarted?: boolean;
11178
+ runtime?: StudioReplRuntime | "unknown";
11179
+ usedControlFile?: boolean;
11180
+ submissionText?: string;
11181
+ controlFiles?: StudioReplControlFiles;
11182
+ };
10989
11183
 
10990
11184
  function sleep(ms: number): Promise<void> {
10991
11185
  return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
@@ -11012,7 +11206,9 @@ function getStudioReplControlFiles(sessionName: string, runtime: StudioReplRunti
11012
11206
  ? "ghci"
11013
11207
  : runtime === "clojure"
11014
11208
  ? "clj"
11015
- : "py";
11209
+ : runtime === "shell"
11210
+ ? "sh"
11211
+ : "py";
11016
11212
  return {
11017
11213
  dir,
11018
11214
  sourceFile: join(dir, `studio-repl-${safeRuntime}.${extension}`),
@@ -11127,22 +11323,28 @@ function buildStudioReplControlSource(runtime: StudioReplRuntime, code: string,
11127
11323
  if (runtime === "r") return buildStudioRControlSource(code, doneFile);
11128
11324
  if (runtime === "ghci") return `${code.replace(/\r/g, "").trimEnd()}\n:! touch ${shellQuote(doneFile)}\n`;
11129
11325
  if (runtime === "clojure") return buildStudioClojureControlSource(code, doneFile);
11326
+ if (runtime === "shell") return `${code.replace(/\r/g, "").trimEnd()}\n`;
11130
11327
  return null;
11131
11328
  }
11132
11329
 
11133
- function buildStudioReplSubmissionLine(runtime: StudioReplRuntime, sourceFile: string): string {
11330
+ function buildStudioReplSubmissionLine(runtime: StudioReplRuntime, sourceFile: string, doneFile: string): string {
11134
11331
  const quotedPath = JSON.stringify(sourceFile);
11135
11332
  if (runtime === "julia") return `include(${quotedPath})`;
11136
11333
  if (runtime === "r") return `source(${quotedPath}, local=.GlobalEnv)`;
11137
11334
  if (runtime === "ghci") return `:script ${quotedPath}`;
11138
11335
  if (runtime === "clojure") return `(do (load-file ${quotedPath}) :pi-studio/silent)`;
11336
+ if (runtime === "shell") return `. ${shellQuote(sourceFile)}; touch ${shellQuote(doneFile)}`;
11139
11337
  return `exec(open(${quotedPath}, encoding="utf-8").read(), globals())`;
11140
11338
  }
11141
11339
 
11142
- function prepareStudioReplSubmission(sessionName: string, source: string): StudioReplPreparedSubmission {
11340
+ function prepareStudioReplSubmission(
11341
+ sessionName: string,
11342
+ source: string,
11343
+ runtimeHint?: StudioReplRuntime | "unknown",
11344
+ ): StudioReplPreparedSubmission {
11143
11345
  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") {
11346
+ const runtime = runtimeHint && runtimeHint !== "unknown" ? runtimeHint : inferStudioReplSessionRuntime(sessionName).runtime;
11347
+ if (runtime !== "unknown") {
11146
11348
  const controlFiles = getStudioReplControlFiles(sessionName, runtime);
11147
11349
  const controlSource = buildStudioReplControlSource(runtime, normalizedSource, controlFiles.doneFile);
11148
11350
  if (controlSource) {
@@ -11153,7 +11355,7 @@ function prepareStudioReplSubmission(sessionName: string, source: string): Studi
11153
11355
  // Ignore stale done file cleanup failures.
11154
11356
  }
11155
11357
  writeFileSync(controlFiles.sourceFile, controlSource, "utf-8");
11156
- const submissionLine = buildStudioReplSubmissionLine(runtime, controlFiles.sourceFile);
11358
+ const submissionLine = buildStudioReplSubmissionLine(runtime, controlFiles.sourceFile, controlFiles.doneFile);
11157
11359
  return {
11158
11360
  runtime,
11159
11361
  usedControlFile: true,
@@ -11170,32 +11372,45 @@ function prepareStudioReplSubmission(sessionName: string, source: string): Studi
11170
11372
  };
11171
11373
  }
11172
11374
 
11173
- function pasteTextToStudioReplPane(sessionName: string, text: string): { ok: true } | { ok: false; message: string } {
11375
+ function pasteTextToStudioReplPane(sessionName: string, text: string, paneTarget?: string): { ok: true } | { ok: false; message: string; submissionStarted: boolean } {
11174
11376
  const bufferName = `pi-studio-repl-${randomUUID().replace(/-/g, "")}`;
11175
- const target = getStudioReplPaneTarget(sessionName);
11377
+ const target = paneTarget || getStudioReplPaneTarget(sessionName);
11176
11378
  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." };
11379
+ if (!loadResult.ok) return { ok: false, message: loadResult.message || "Failed to load text into tmux buffer.", submissionStarted: false };
11178
11380
  try {
11179
11381
  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." };
11382
+ if (!pasteResult.ok) return { ok: false, message: pasteResult.message || "Failed to paste text into REPL session.", submissionStarted: false };
11181
11383
  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." };
11384
+ if (!enterResult.ok) return { ok: false, message: enterResult.message || "Failed to send Enter to REPL session.", submissionStarted: true };
11183
11385
  return { ok: true };
11184
11386
  } finally {
11185
11387
  runStudioTmux(["delete-buffer", "-b", bufferName], { timeout: 2_000 });
11186
11388
  }
11187
11389
  }
11188
11390
 
11189
- function sendTextToStudioReplSession(sessionName: string, text: string): StudioReplSendSuccess | StudioReplSendFailure {
11391
+ function sendTextToStudioReplSession(
11392
+ sessionName: string,
11393
+ text: string,
11394
+ paneTarget?: string,
11395
+ runtimeHint?: StudioReplRuntime | "unknown",
11396
+ ): StudioReplSendSuccess | StudioReplSendFailure {
11190
11397
  if (!/^[-_.A-Za-z0-9]+$/.test(sessionName)) return { ok: false, message: "Invalid REPL session name." };
11191
11398
  const source = String(text || "");
11192
11399
  if (!source.trim()) return { ok: false, message: "Editor text is empty." };
11193
11400
  if (source.length > STUDIO_REPL_SEND_MAX_CHARS) {
11194
11401
  return { ok: false, message: `REPL input is too large (${source.length} chars; max ${STUDIO_REPL_SEND_MAX_CHARS}).` };
11195
11402
  }
11196
- const prepared = prepareStudioReplSubmission(sessionName, source);
11197
- const pasted = pasteTextToStudioReplPane(sessionName, prepared.submissionText);
11198
- if (!pasted.ok) return { ok: false, message: pasted.message };
11403
+ const prepared = prepareStudioReplSubmission(sessionName, source, runtimeHint);
11404
+ const pasted = pasteTextToStudioReplPane(sessionName, prepared.submissionText, paneTarget);
11405
+ if (!pasted.ok) return {
11406
+ ok: false,
11407
+ message: pasted.message,
11408
+ submissionStarted: pasted.submissionStarted,
11409
+ runtime: prepared.runtime,
11410
+ usedControlFile: prepared.usedControlFile,
11411
+ submissionText: prepared.submissionText,
11412
+ controlFiles: prepared.controlFiles,
11413
+ };
11199
11414
  return {
11200
11415
  ok: true,
11201
11416
  message: "Sent to REPL.",
@@ -11235,6 +11450,7 @@ function stripStudioReplSubmissionEcho(output: string): string {
11235
11450
  /^.*source\([\s\S]*?pi-studio-re[\s\S]*?local\s*=\s*\.GlobalEnv\)\s*$/gm,
11236
11451
  /^.*:script\s+[\s\S]*?pi-studio-re[\s\S]*?\.ghci"?\s*$/gm,
11237
11452
  /^.*\(do\s+\(load-file\s+[\s\S]*?pi-studio-re[\s\S]*?:pi-studio\/silent\)\s*$/gm,
11453
+ /^.*\.\s+[\s\S]*?pi-studio-re[\s\S]*?\.sh[\s\S]*?done\.flag.*$/gm,
11238
11454
  ];
11239
11455
  for (const pattern of submissionEchoPatterns) value = value.replace(pattern, "");
11240
11456
  return value.replace(/^(?:\s*\n)+/, "").replace(/[\t ]+$/gm, "").trimEnd();
@@ -11257,18 +11473,27 @@ function normalizeStudioReplJournalMode(mode: unknown): StudioReplJournalEntry["
11257
11473
  }
11258
11474
 
11259
11475
  function normalizeStudioReplJournalStatus(status: unknown): StudioReplJournalEntry["status"] {
11260
- return status === "captured" || status === "timeout" || status === "error" || status === "note" ? status : "sent";
11476
+ return status === "sending"
11477
+ || status === "captured"
11478
+ || status === "timeout"
11479
+ || status === "error"
11480
+ || status === "note"
11481
+ ? status
11482
+ : "sent";
11261
11483
  }
11262
11484
 
11263
- function makeStudioReplJournalEntry(details: Partial<StudioReplJournalEntry> & { sessionName: string; code: string }): StudioReplJournalEntry {
11485
+ function makeStudioReplJournalEntry(details: Partial<StudioReplJournalEntry> & { sessionName: string; code?: string }): StudioReplJournalEntry {
11264
11486
  const now = Date.now();
11487
+ const createdAt = typeof details.createdAt === "number" && Number.isFinite(details.createdAt) ? details.createdAt : now;
11265
11488
  return {
11266
11489
  id: typeof details.id === "string" && details.id.trim() ? details.id.trim() : `repl-journal-${now.toString(36)}-${randomUUID().slice(0, 8)}`,
11267
11490
  requestId: typeof details.requestId === "string" ? details.requestId : "",
11268
- createdAt: typeof details.createdAt === "number" && Number.isFinite(details.createdAt) ? details.createdAt : now,
11491
+ createdAt,
11269
11492
  updatedAt: typeof details.updatedAt === "number" && Number.isFinite(details.updatedAt) ? details.updatedAt : now,
11493
+ completedAt: typeof details.completedAt === "number" && Number.isFinite(details.completedAt) ? details.completedAt : null,
11270
11494
  sessionName: String(details.sessionName || ""),
11271
- runtime: details.runtime || "unknown",
11495
+ runtime: normalizeStudioReplRuntime(details.runtime) || "unknown",
11496
+ origin: details.origin === "pi-repl" || details.origin === "pi-studio" ? details.origin : "unknown",
11272
11497
  label: typeof details.label === "string" && details.label.trim() ? details.label.trim() : "REPL send",
11273
11498
  mode: normalizeStudioReplJournalMode(details.mode),
11274
11499
  prose: typeof details.prose === "string" ? details.prose : "",
@@ -11276,6 +11501,10 @@ function makeStudioReplJournalEntry(details: Partial<StudioReplJournalEntry> & {
11276
11501
  output: typeof details.output === "string" ? details.output : "",
11277
11502
  status: normalizeStudioReplJournalStatus(details.status),
11278
11503
  skippedChunks: Math.max(0, Math.floor(Number(details.skippedChunks) || 0)),
11504
+ codeOmittedChars: Math.max(0, Math.floor(Number(details.codeOmittedChars) || 0)),
11505
+ proseOmittedChars: Math.max(0, Math.floor(Number(details.proseOmittedChars) || 0)),
11506
+ outputOmittedChars: Math.max(0, Math.floor(Number(details.outputOmittedChars) || 0)),
11507
+ sharedSynced: details.sharedSynced === true,
11279
11508
  };
11280
11509
  }
11281
11510
 
@@ -11298,47 +11527,192 @@ function upsertStudioReplJournalEntry(entry: StudioReplJournalEntry): StudioRepl
11298
11527
  studioReplJournalEntries = studioReplJournalEntries
11299
11528
  .sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0))
11300
11529
  .slice(-STUDIO_REPL_JOURNAL_MAX_ENTRIES);
11530
+ const retainedIds = new Set(studioReplJournalEntries.map((candidate) => candidate.id));
11531
+ for (const unsyncedId of studioReplUnsyncedJournalEntryIds) {
11532
+ if (!retainedIds.has(unsyncedId)) studioReplUnsyncedJournalEntryIds.delete(unsyncedId);
11533
+ }
11301
11534
  return studioReplJournalEntries.find((candidate) => candidate.id === entry.id || (entry.requestId && candidate.requestId === entry.requestId)) || entry;
11302
11535
  }
11303
11536
 
11304
- function recordStudioReplJournalEntry(details: Partial<StudioReplJournalEntry> & { sessionName: string; code: string }): StudioReplJournalEntry {
11305
- return upsertStudioReplJournalEntry(makeStudioReplJournalEntry(details));
11537
+ function getStudioReplSessionRecordIdentity(session: StudioReplSessionInfo): {
11538
+ sessionName: string;
11539
+ tmuxSessionId: string;
11540
+ tmuxSessionCreatedAt: number;
11541
+ runtime: StudioReplRuntime | "unknown";
11542
+ } {
11543
+ return {
11544
+ sessionName: session.sessionName,
11545
+ tmuxSessionId: session.tmuxSessionId,
11546
+ tmuxSessionCreatedAt: session.tmuxSessionCreatedAt,
11547
+ runtime: session.runtime,
11548
+ };
11549
+ }
11550
+
11551
+ function isSameStudioReplSessionLifetime(left: StudioReplSessionInfo, right: StudioReplSessionInfo): boolean {
11552
+ return left.sessionName === right.sessionName
11553
+ && left.tmuxSessionId === right.tmuxSessionId
11554
+ && left.tmuxSessionCreatedAt === right.tmuxSessionCreatedAt;
11555
+ }
11556
+
11557
+ function recordStudioReplJournalEntry(details: Partial<StudioReplJournalEntry> & { sessionName: string; code?: string }): StudioReplJournalEntry {
11558
+ let entry = upsertStudioReplJournalEntry(makeStudioReplJournalEntry({
11559
+ ...details,
11560
+ origin: details.origin || "pi-studio",
11561
+ sharedSynced: false,
11562
+ }));
11563
+ const localEntryId = entry.id;
11564
+ studioReplUnsyncedJournalEntryIds.add(localEntryId);
11565
+ const session = inspectStudioReplSession(entry.sessionName);
11566
+ if (!session?.recordId || session.recordWarning) return entry;
11567
+ try {
11568
+ const recorded = upsertReplSessionRecordEntry(
11569
+ session.recordId,
11570
+ getStudioReplSessionRecordIdentity(session),
11571
+ entry,
11572
+ { origin: entry.origin === "pi-repl" ? "pi-repl" : "pi-studio" },
11573
+ );
11574
+ entry = upsertStudioReplJournalEntry(makeStudioReplJournalEntry({
11575
+ ...(recorded.entry as Partial<StudioReplJournalEntry> & { sessionName: string }),
11576
+ sharedSynced: true,
11577
+ }));
11578
+ studioReplUnsyncedJournalEntryIds.delete(localEntryId);
11579
+ studioReplUnsyncedJournalEntryIds.delete(entry.id);
11580
+ } catch {
11581
+ // Retain the in-memory entry as a standalone fallback when shared state is unavailable.
11582
+ }
11583
+ return entry;
11584
+ }
11585
+
11586
+ function getStudioReplJournalEntries(sessionName: string | null | undefined): StudioReplJournalEntry[] {
11587
+ const normalizedSessionName = String(sessionName || "").trim();
11588
+ const sessions = normalizedSessionName
11589
+ ? [inspectStudioReplSession(normalizedSessionName)].filter((session): session is StudioReplSessionInfo => Boolean(session))
11590
+ : listStudioReplSessions().sessions;
11591
+ for (const session of sessions) {
11592
+ if (!session.recordId || session.recordWarning) continue;
11593
+ try {
11594
+ const record = readReplSessionRecord(session.recordId, getStudioReplSessionRecordIdentity(session));
11595
+ if (record) {
11596
+ const unsynced = studioReplJournalEntries.filter((entry) => (
11597
+ entry.sessionName === session.sessionName && studioReplUnsyncedJournalEntryIds.has(entry.id)
11598
+ ));
11599
+ const sharedEntries = record.entries.map((sharedEntry: Partial<StudioReplJournalEntry> & { sessionName: string }) => (
11600
+ makeStudioReplJournalEntry({ ...sharedEntry, sharedSynced: true })
11601
+ ));
11602
+ for (const sharedEntry of sharedEntries) studioReplUnsyncedJournalEntryIds.delete(sharedEntry.id);
11603
+ studioReplJournalEntries = [
11604
+ ...studioReplJournalEntries.filter((entry) => entry.sessionName !== session.sessionName),
11605
+ ...sharedEntries,
11606
+ ...unsynced.filter((entry) => !sharedEntries.some((sharedEntry: StudioReplJournalEntry) => sharedEntry.id === entry.id)),
11607
+ ].sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0)).slice(-STUDIO_REPL_JOURNAL_MAX_ENTRIES);
11608
+ }
11609
+ } catch {
11610
+ // The raw tmux mirror and in-memory Studio record remain independently usable.
11611
+ }
11612
+ }
11613
+ const entries = normalizedSessionName
11614
+ ? studioReplJournalEntries.filter((entry) => entry.sessionName === normalizedSessionName)
11615
+ : studioReplJournalEntries;
11616
+ return entries.slice(-STUDIO_REPL_JOURNAL_MAX_ENTRIES).map((entry) => ({
11617
+ ...entry,
11618
+ sharedSynced: !studioReplUnsyncedJournalEntryIds.has(entry.id) && entry.sharedSynced === true,
11619
+ }));
11306
11620
  }
11307
11621
 
11308
11622
  function updateStudioReplJournalEntryOutput(requestId: string, sessionName: string, output: string, status: StudioReplJournalEntry["status"]): void {
11309
11623
  const normalizedRequestId = String(requestId || "");
11310
11624
  const normalizedSessionName = String(sessionName || "");
11311
- const existing = studioReplJournalEntries.find((entry) => (
11625
+ const existing = getStudioReplJournalEntries(normalizedSessionName).slice().reverse().find((entry) => (
11312
11626
  (normalizedRequestId && entry.requestId === normalizedRequestId)
11313
- || (!normalizedRequestId && normalizedSessionName && entry.sessionName === normalizedSessionName && entry.status === "sent")
11627
+ || (!normalizedRequestId && entry.status === "sent")
11314
11628
  ));
11315
11629
  if (!existing) return;
11316
- upsertStudioReplJournalEntry({
11630
+ recordStudioReplJournalEntry({
11317
11631
  ...existing,
11318
11632
  output: String(output || ""),
11319
11633
  status,
11634
+ completedAt: Date.now(),
11320
11635
  updatedAt: Date.now(),
11321
11636
  });
11322
11637
  }
11323
11638
 
11324
- function getStudioReplJournalEntries(sessionName: string | null | undefined): StudioReplJournalEntry[] {
11639
+ function clearStudioReplJournal(sessionName: string): void {
11325
11640
  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 }));
11641
+ if (!normalizedSessionName) return;
11642
+ const clearedIds = studioReplJournalEntries.filter((entry) => entry.sessionName === normalizedSessionName).map((entry) => entry.id);
11643
+ studioReplJournalEntries = studioReplJournalEntries.filter((entry) => entry.sessionName !== normalizedSessionName);
11644
+ for (const id of clearedIds) studioReplUnsyncedJournalEntryIds.delete(id);
11645
+ const session = inspectStudioReplSession(normalizedSessionName);
11646
+ if (!session?.recordId || session.recordWarning) return;
11647
+ clearReplSessionRecord(session.recordId, getStudioReplSessionRecordIdentity(session));
11330
11648
  }
11331
11649
 
11332
- async function waitForStudioReplDoneFile(doneFile: string | undefined, timeoutMs: number): Promise<boolean> {
11650
+ async function waitForStudioReplDoneFile(doneFile: string | undefined, timeoutMs: number, signal?: AbortSignal): Promise<boolean> {
11333
11651
  if (!doneFile) return false;
11334
11652
  const deadline = Date.now() + clampStudioReplSendTimeout(timeoutMs);
11335
11653
  while (Date.now() < deadline) {
11336
11654
  if (existsSync(doneFile)) return true;
11655
+ if (signal?.aborted) return false;
11337
11656
  await sleep(100);
11338
11657
  }
11339
11658
  return existsSync(doneFile);
11340
11659
  }
11341
11660
 
11661
+ function sleepWithoutKeepingStudioProcessAlive(ms: number): Promise<void> {
11662
+ return new Promise((resolveSleep) => {
11663
+ const timer = setTimeout(resolveSleep, ms);
11664
+ timer.unref?.();
11665
+ });
11666
+ }
11667
+
11668
+ function retainStudioReplSendLeaseUntilSubmissionSettles(
11669
+ session: StudioReplSessionInfo,
11670
+ doneFile: string,
11671
+ lease: Awaited<ReturnType<typeof acquireReplSessionSendLease>>,
11672
+ ): void {
11673
+ // A caller timeout or abort does not stop code already submitted to tmux.
11674
+ // Continue heartbeating the shared lease until the runtime wrapper reports
11675
+ // completion, or until this exact tmux session lifetime disappears.
11676
+ void (async () => {
11677
+ let nextIdentityCheck = 0;
11678
+ let missingChecks = 0;
11679
+ try {
11680
+ while (!existsSync(doneFile)) {
11681
+ if (Date.now() >= nextIdentityCheck) {
11682
+ try {
11683
+ const current = inspectStudioReplSession(session.sessionName);
11684
+ if (current && isSameStudioReplSessionLifetime(session, current)) {
11685
+ missingChecks = 0;
11686
+ } else {
11687
+ missingChecks += 1;
11688
+ if (missingChecks >= 3) return;
11689
+ }
11690
+ } catch {
11691
+ // A transient inspection failure must not make overlapping sends safe.
11692
+ missingChecks = 0;
11693
+ }
11694
+ nextIdentityCheck = Date.now() + 1_000;
11695
+ }
11696
+ await sleepWithoutKeepingStudioProcessAlive(100);
11697
+ }
11698
+ } finally {
11699
+ await lease.release().catch(() => undefined);
11700
+ }
11701
+ })();
11702
+ }
11703
+
11704
+ async function releaseOrRetainStudioReplSendLease(
11705
+ lease: Awaited<ReturnType<typeof acquireReplSessionSendLease>>,
11706
+ session: StudioReplSessionInfo | null,
11707
+ doneFile: string | undefined,
11708
+ ): Promise<void> {
11709
+ if (session && doneFile && !existsSync(doneFile)) {
11710
+ retainStudioReplSendLeaseUntilSubmissionSettles(session, doneFile, lease);
11711
+ return;
11712
+ }
11713
+ await lease.release().catch(() => undefined);
11714
+ }
11715
+
11342
11716
  function interruptStudioReplSession(sessionName: string): { ok: true; message: string } | { ok: false; message: string } {
11343
11717
  if (!/^[-_.A-Za-z0-9]+$/.test(sessionName)) return { ok: false, message: "Invalid REPL session name." };
11344
11718
  const result = runStudioTmux(["send-keys", "-t", getStudioReplPaneTarget(sessionName), "C-c"], { timeout: 5_000 });
@@ -12479,6 +12853,7 @@ export default function (pi: ExtensionAPI) {
12479
12853
  parameters: STUDIO_REPL_STATUS_TOOL_PARAMS,
12480
12854
  async execute(_toolCallId, params) {
12481
12855
  const selected = selectStudioReplSessionForTool({ sessionName: params.sessionName, target: params.target });
12856
+ const recordEntries = selected.session ? getStudioReplJournalEntries(selected.session.sessionName) : [];
12482
12857
  const lines = [
12483
12858
  `Active Studio REPL: ${studioReplActiveSessionName || "none"}`,
12484
12859
  `tmux sessions visible to Studio: ${selected.sessions.length}`,
@@ -12486,6 +12861,10 @@ export default function (pi: ExtensionAPI) {
12486
12861
  if (selected.error) lines.push(`Selection: ${selected.error}`);
12487
12862
  if (selected.session) {
12488
12863
  lines.push(`Selected: ${selected.session.sessionName} (${selected.session.runtime}, ${selected.session.source})`);
12864
+ if (selected.session.recordId && !selected.session.recordWarning) {
12865
+ lines.push(`Shared clean record: ${recordEntries.length} entries (${selected.session.recordId})`);
12866
+ }
12867
+ if (selected.session.recordWarning) lines.push(`Shared record warning: ${selected.session.recordWarning}`);
12489
12868
  }
12490
12869
  for (const session of selected.sessions) {
12491
12870
  lines.push(`- ${session.sessionName} | runtime=${session.runtime} | source=${session.source} | target=${session.target}`);
@@ -12495,6 +12874,7 @@ export default function (pi: ExtensionAPI) {
12495
12874
  details: {
12496
12875
  activeSessionName: studioReplActiveSessionName,
12497
12876
  selectedSession: selected.session,
12877
+ selectedRecordEntries: recordEntries,
12498
12878
  sessions: selected.sessions,
12499
12879
  } as Record<string, unknown>,
12500
12880
  };
@@ -12513,7 +12893,7 @@ export default function (pi: ExtensionAPI) {
12513
12893
  ],
12514
12894
  parameters: STUDIO_REPL_SEND_TOOL_PARAMS,
12515
12895
  executionMode: "sequential",
12516
- async execute(toolCallId, params) {
12896
+ async execute(toolCallId, params, signal) {
12517
12897
  const selected = selectStudioReplSessionForTool({ sessionName: params.sessionName, target: params.target });
12518
12898
  if (!selected.session) {
12519
12899
  return {
@@ -12522,75 +12902,151 @@ export default function (pi: ExtensionAPI) {
12522
12902
  };
12523
12903
  }
12524
12904
 
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
12905
  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,
12906
+ let lease: Awaited<ReturnType<typeof acquireReplSessionSendLease>> | null = null;
12907
+ let submittedSession: StudioReplSessionInfo | null = null;
12908
+ let submittedDoneFile: string | undefined;
12909
+ let journalEntry: StudioReplJournalEntry | null = null;
12910
+ try {
12911
+ if (selected.session.recordId && !selected.session.recordWarning) {
12912
+ lease = await acquireReplSessionSendLease(selected.session.recordId, {
12913
+ owner: `pi-studio:tool:${toolCallId}`,
12914
+ waitMs: timeoutMs,
12915
+ signal,
12916
+ });
12917
+ }
12918
+ const currentSession = inspectStudioReplSession(selected.session.sessionName);
12919
+ if (!currentSession || !isSameStudioReplSessionLifetime(selected.session, currentSession)) {
12920
+ throw new Error(`REPL session ${selected.session.sessionName} changed while Studio was waiting to send.`);
12921
+ }
12922
+ if (lease && currentSession.recordId !== selected.session.recordId) {
12923
+ throw new Error(`The shared record for ${selected.session.sessionName} changed while Studio was waiting to send.`);
12924
+ }
12925
+ const before = captureStudioReplSession(currentSession.sessionName);
12926
+ if (!before.ok) throw new Error(`Could not capture ${currentSession.sessionName} before sending: ${before.message}`);
12927
+ if (!isSameStudioReplSessionLifetime(currentSession, before.session)) {
12928
+ throw new Error(`REPL session ${currentSession.sessionName} changed before Studio could capture it.`);
12929
+ }
12930
+ const beforeTranscript = before.transcript;
12931
+ journalEntry = recordStudioReplJournalEntry({
12932
+ id: `pi-studio:tool:${toolCallId}`,
12933
+ requestId: `tool:${toolCallId}`,
12934
+ sessionName: currentSession.sessionName,
12935
+ runtime: currentSession.runtime,
12936
+ origin: "pi-studio",
12937
+ label: "Pi",
12938
+ mode: "agent",
12939
+ code: params.code,
12940
+ status: "sending",
12941
+ });
12942
+ const sent = sendTextToStudioReplSession(currentSession.sessionName, params.code, currentSession.target, currentSession.runtime);
12943
+ if (!sent.ok) {
12944
+ if (sent.submissionStarted) {
12945
+ submittedSession = currentSession;
12946
+ submittedDoneFile = sent.controlFiles?.doneFile;
12947
+ }
12948
+ journalEntry = recordStudioReplJournalEntry({
12949
+ ...journalEntry,
12950
+ output: sent.message,
12951
+ status: "error",
12952
+ completedAt: Date.now(),
12953
+ });
12954
+ return {
12955
+ content: [{ type: "text", text: sent.message }],
12956
+ details: { ok: false, error: sent.message, session: selected.session, sessions: selected.sessions, recordEntryId: journalEntry.id } as Record<string, unknown>,
12957
+ };
12958
+ }
12959
+ submittedSession = currentSession;
12960
+ submittedDoneFile = sent.controlFiles?.doneFile;
12961
+ studioReplActiveSessionName = selected.session.sessionName;
12962
+
12963
+ let completed = false;
12964
+ if (sent.controlFiles?.doneFile) {
12965
+ completed = await waitForStudioReplDoneFile(sent.controlFiles.doneFile, timeoutMs, signal);
12966
+ if (signal?.aborted && !completed) {
12967
+ throw new Error("studio_repl_send was aborted after submission; the shared session remains busy until the running code settles.");
12968
+ }
12969
+ } else {
12970
+ await sleep(Math.min(750, timeoutMs));
12971
+ }
12972
+ const after = captureStudioReplSession(currentSession.sessionName);
12973
+ if (!after.ok) throw new Error(`Could not capture ${currentSession.sessionName} after sending: ${after.message}`);
12974
+ if (!isSameStudioReplSessionLifetime(currentSession, after.session)) {
12975
+ throw new Error(`REPL session ${currentSession.sessionName} changed before Studio captured the result.`);
12976
+ }
12977
+ const afterTranscript = after.transcript;
12978
+ const rawOutput = extractStudioReplTranscriptDelta(beforeTranscript, afterTranscript);
12979
+ const output = cleanStudioReplCapturedOutput(rawOutput);
12980
+ const status: StudioReplJournalEntry["status"] = sent.controlFiles?.doneFile
12981
+ ? (completed ? "captured" : "timeout")
12982
+ : (output.trim() ? "captured" : "sent");
12983
+ journalEntry = recordStudioReplJournalEntry({
12984
+ ...journalEntry,
12985
+ runtime: sent.runtime === "unknown" ? selected.session.runtime : sent.runtime,
12986
+ output,
12987
+ status,
12988
+ completedAt: Date.now(),
12989
+ });
12990
+ const statusLine = sent.controlFiles?.doneFile
12991
+ ? (completed ? "Completed." : `Timed out after ${timeoutMs} ms waiting for completion marker.`)
12992
+ : "Submitted.";
12993
+ const text = [
12994
+ `${statusLine} ${sent.message}`,
12995
+ output ? "" : undefined,
12996
+ output || undefined,
12997
+ ].filter(Boolean).join("\n");
12998
+ broadcastStudioReplToolSend({
12999
+ toolCallId,
13000
+ sessionName: selected.session.sessionName,
13001
+ runtime: sent.runtime === "unknown" ? selected.session.runtime : sent.runtime,
13002
+ code: params.code,
13003
+ label: "Pi",
13004
+ output,
13005
+ sharedSynced: journalEntry.sharedSynced === true,
12582
13006
  completed,
12583
13007
  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
- };
13008
+ transcript: afterTranscript,
13009
+ capturedAt: Date.now(),
13010
+ journalEntries: getStudioReplJournalEntries(selected.session.sessionName),
13011
+ });
13012
+ return {
13013
+ content: [{ type: "text", text }],
13014
+ details: {
13015
+ ok: true,
13016
+ completed,
13017
+ timedOut: Boolean(sent.controlFiles?.doneFile && !completed),
13018
+ timeoutMs,
13019
+ session: selected.session,
13020
+ sessions: selected.sessions,
13021
+ runtime: sent.runtime,
13022
+ usedControlFile: sent.usedControlFile,
13023
+ submissionText: sent.submissionText,
13024
+ controlFiles: sent.controlFiles,
13025
+ output,
13026
+ recordEntryId: journalEntry.id,
13027
+ recordPath: selected.session.recordPath,
13028
+ } as Record<string, unknown>,
13029
+ };
13030
+ } catch (error) {
13031
+ if (journalEntry) {
13032
+ try {
13033
+ recordStudioReplJournalEntry({
13034
+ ...journalEntry,
13035
+ output: error instanceof Error ? error.message : String(error),
13036
+ status: error instanceof Error && /timed out/i.test(error.message) ? "timeout" : "error",
13037
+ completedAt: Date.now(),
13038
+ });
13039
+ } catch {
13040
+ // Preserve the execution error when record maintenance also fails.
13041
+ }
13042
+ }
13043
+ throw error;
13044
+ } finally {
13045
+ if (lease) {
13046
+ await releaseOrRetainStudioReplSendLease(lease, submittedSession, submittedDoneFile);
13047
+ lease = null;
13048
+ }
13049
+ }
12594
13050
  },
12595
13051
  });
12596
13052
 
@@ -15453,33 +15909,65 @@ export default function (pi: ExtensionAPI) {
15453
15909
  sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
15454
15910
  return;
15455
15911
  }
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
15912
  void (async () => {
15913
+ const session = inspectStudioReplSession(msg.sessionName);
15914
+ let journalEntry: StudioReplJournalEntry | null = null;
15915
+ let lease: Awaited<ReturnType<typeof acquireReplSessionSendLease>> | null = null;
15916
+ let submittedSession: StudioReplSessionInfo | null = null;
15917
+ let submittedDoneFile: string | undefined;
15482
15918
  try {
15919
+ if (!session) throw new Error(`No tmux REPL session named ${msg.sessionName}.`);
15920
+ if (session.recordId && !session.recordWarning) {
15921
+ lease = await acquireReplSessionSendLease(session.recordId, {
15922
+ owner: `pi-studio:browser:${msg.requestId}`,
15923
+ waitMs: STUDIO_REPL_SEND_DEFAULT_TIMEOUT_MS,
15924
+ });
15925
+ }
15926
+ const currentSession = inspectStudioReplSession(msg.sessionName);
15927
+ if (!currentSession || !isSameStudioReplSessionLifetime(session, currentSession)) {
15928
+ throw new Error(`REPL session ${msg.sessionName} changed while Studio was waiting to send.`);
15929
+ }
15930
+ if (lease && currentSession.recordId !== session.recordId) {
15931
+ throw new Error(`The shared record for ${msg.sessionName} changed while Studio was waiting to send.`);
15932
+ }
15933
+ const before = captureStudioReplSession(msg.sessionName);
15934
+ if (!before.ok) throw new Error(`Could not capture ${msg.sessionName} before sending: ${before.message}`);
15935
+ if (!isSameStudioReplSessionLifetime(currentSession, before.session)) {
15936
+ throw new Error(`REPL session ${msg.sessionName} changed before Studio could capture it.`);
15937
+ }
15938
+ const beforeTranscript = before.transcript;
15939
+ journalEntry = recordStudioReplJournalEntry({
15940
+ id: msg.journalEntryId,
15941
+ requestId: msg.requestId,
15942
+ createdAt: msg.createdAt,
15943
+ sessionName: msg.sessionName,
15944
+ runtime: session.runtime,
15945
+ origin: "pi-studio",
15946
+ label: msg.label || "Studio",
15947
+ mode: msg.mode || "raw",
15948
+ prose: msg.prose || "",
15949
+ code: msg.text,
15950
+ status: "sending",
15951
+ skippedChunks: msg.skippedChunks,
15952
+ });
15953
+ const sent = sendTextToStudioReplSession(msg.sessionName, msg.text, currentSession.target, currentSession.runtime);
15954
+ if (!sent.ok) {
15955
+ if (sent.submissionStarted) {
15956
+ submittedSession = currentSession;
15957
+ submittedDoneFile = sent.controlFiles?.doneFile;
15958
+ }
15959
+ throw new Error(sent.message);
15960
+ }
15961
+ submittedSession = currentSession;
15962
+ submittedDoneFile = sent.controlFiles?.doneFile;
15963
+ studioReplActiveSessionName = msg.sessionName;
15964
+ sendToClient(client, {
15965
+ type: "repl_send_ack",
15966
+ requestId: msg.requestId,
15967
+ sessionName: msg.sessionName,
15968
+ message: sent.message,
15969
+ journalEntries: getStudioReplJournalEntries(msg.sessionName),
15970
+ });
15483
15971
  const timeoutMs = STUDIO_REPL_SEND_DEFAULT_TIMEOUT_MS;
15484
15972
  let completed = false;
15485
15973
  if (sent.controlFiles?.doneFile) {
@@ -15488,24 +15976,107 @@ export default function (pi: ExtensionAPI) {
15488
15976
  await sleep(Math.min(750, timeoutMs));
15489
15977
  }
15490
15978
  const after = captureStudioReplSession(msg.sessionName);
15491
- const afterTranscript = after.ok ? after.transcript : "";
15979
+ if (!after.ok) throw new Error(`Could not capture ${msg.sessionName} after sending: ${after.message}`);
15980
+ if (!isSameStudioReplSessionLifetime(currentSession, after.session)) {
15981
+ throw new Error(`REPL session ${msg.sessionName} changed before Studio captured the result.`);
15982
+ }
15983
+ const afterTranscript = after.transcript;
15492
15984
  const rawOutput = extractStudioReplTranscriptDelta(beforeTranscript, afterTranscript);
15493
15985
  const output = cleanStudioReplCapturedOutput(rawOutput);
15494
15986
  updateStudioReplJournalEntryOutput(
15495
15987
  msg.requestId,
15496
15988
  msg.sessionName,
15497
15989
  output,
15498
- sent.controlFiles?.doneFile && !completed ? "timeout" : (output.trim() ? "captured" : "sent"),
15990
+ sent.controlFiles?.doneFile ? (completed ? "captured" : "timeout") : (output.trim() ? "captured" : "sent"),
15499
15991
  );
15992
+ if (lease) {
15993
+ await releaseOrRetainStudioReplSendLease(lease, submittedSession, submittedDoneFile);
15994
+ lease = null;
15995
+ }
15500
15996
  sendReplCaptureToClient(client, msg.sessionName, { requestId: msg.requestId });
15501
15997
  } 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) });
15998
+ const message = error instanceof Error ? error.message : String(error);
15999
+ if (journalEntry) updateStudioReplJournalEntryOutput(msg.requestId, msg.sessionName, message, "error");
16000
+ if (lease) {
16001
+ await releaseOrRetainStudioReplSendLease(lease, submittedSession, submittedDoneFile);
16002
+ lease = null;
16003
+ }
16004
+ sendToClient(client, { type: "error", requestId: msg.requestId, message });
16005
+ sendReplCaptureToClient(client, msg.sessionName, { requestId: msg.requestId, replError: message });
16006
+ } finally {
16007
+ await lease?.release().catch(() => undefined);
15504
16008
  }
15505
16009
  })();
15506
16010
  return;
15507
16011
  }
15508
16012
 
16013
+ if (msg.type === "repl_journal_upsert_request") {
16014
+ if (!isValidRequestId(msg.requestId)) {
16015
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
16016
+ return;
16017
+ }
16018
+ try {
16019
+ if (!inspectStudioReplSession(msg.sessionName)) throw new Error(`No tmux REPL session named ${msg.sessionName}.`);
16020
+ recordStudioReplJournalEntry({
16021
+ ...msg.entry,
16022
+ sessionName: msg.sessionName,
16023
+ origin: "pi-studio",
16024
+ });
16025
+ sendToClient(client, {
16026
+ type: "repl_journal_ack",
16027
+ requestId: msg.requestId,
16028
+ sessionName: msg.sessionName,
16029
+ journalEntries: getStudioReplJournalEntries(msg.sessionName),
16030
+ });
16031
+ } catch (error) {
16032
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: error instanceof Error ? error.message : String(error) });
16033
+ }
16034
+ return;
16035
+ }
16036
+
16037
+ if (msg.type === "repl_journal_import_request") {
16038
+ if (!isValidRequestId(msg.requestId)) {
16039
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
16040
+ return;
16041
+ }
16042
+ try {
16043
+ if (!inspectStudioReplSession(msg.sessionName)) throw new Error(`No tmux REPL session named ${msg.sessionName}.`);
16044
+ for (const entry of msg.entries) {
16045
+ recordStudioReplJournalEntry({ ...entry, sessionName: msg.sessionName, origin: "pi-studio" });
16046
+ }
16047
+ sendToClient(client, {
16048
+ type: "repl_journal_ack",
16049
+ requestId: msg.requestId,
16050
+ sessionName: msg.sessionName,
16051
+ journalEntries: getStudioReplJournalEntries(msg.sessionName),
16052
+ });
16053
+ } catch (error) {
16054
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: error instanceof Error ? error.message : String(error) });
16055
+ }
16056
+ return;
16057
+ }
16058
+
16059
+ if (msg.type === "repl_journal_clear_request") {
16060
+ if (!isValidRequestId(msg.requestId)) {
16061
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
16062
+ return;
16063
+ }
16064
+ try {
16065
+ if (!inspectStudioReplSession(msg.sessionName)) throw new Error(`No tmux REPL session named ${msg.sessionName}.`);
16066
+ clearStudioReplJournal(msg.sessionName);
16067
+ sendToClient(client, {
16068
+ type: "repl_journal_ack",
16069
+ requestId: msg.requestId,
16070
+ sessionName: msg.sessionName,
16071
+ journalEntries: [],
16072
+ cleared: true,
16073
+ });
16074
+ } catch (error) {
16075
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: error instanceof Error ? error.message : String(error) });
16076
+ }
16077
+ return;
16078
+ }
16079
+
15509
16080
  if (msg.type === "repl_interrupt_request") {
15510
16081
  if (!isValidRequestId(msg.requestId)) {
15511
16082
  sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });