pi-studio 0.9.54 → 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,
@@ -74,9 +87,11 @@ import {
74
87
  import {
75
88
  buildStudioSideQuestionFollowUpPrompt,
76
89
  buildStudioSideQuestionPrompt,
90
+ getStudioSideQuestionThinkingLevels,
77
91
  normalizeStudioSideQuestionFocusKind,
78
92
  normalizeStudioSideQuestionGatherScope,
79
93
  normalizeStudioSideQuestionThinking,
94
+ resolveStudioSideQuestionThinking,
80
95
  STUDIO_SIDE_QUESTION_FOCUS_MAX_CHARS,
81
96
  STUDIO_SIDE_QUESTION_QUESTION_MAX_CHARS,
82
97
  } from "./shared/studio-side-question.js";
@@ -120,7 +135,7 @@ type StudioQuartoPreviewStatus = "idle" | "starting" | "running" | "stopping" |
120
135
  type StudioShowMeSourceKind = "selection" | "response" | "editor" | "context";
121
136
  type StudioSideQuestionFocusKind = "selection" | "section" | "editor" | "response" | "none";
122
137
  type StudioSideQuestionGatherScope = "none" | "folder" | "repo" | "custom";
123
- type StudioSideQuestionThinking = "off" | "minimal" | "low" | "medium" | "high";
138
+ type StudioSideQuestionThinking = ModelThinkingLevel;
124
139
  type StudioSideQuestionStatus = "idle" | "running" | "error";
125
140
 
126
141
  interface StudioQuartoPreviewContext {
@@ -247,9 +262,14 @@ interface StudioContextUsageSnapshot {
247
262
  interface StudioReplSessionInfo {
248
263
  sessionName: string;
249
264
  target: string;
265
+ tmuxSessionId: string;
266
+ tmuxSessionCreatedAt: number;
250
267
  runtime: StudioReplRuntime | "unknown";
251
268
  label: string;
252
269
  source: "studio" | "pi-repl" | "tmux";
270
+ recordId?: string;
271
+ recordPath?: string;
272
+ recordWarning?: string;
253
273
  }
254
274
 
255
275
  interface StudioReplJournalEntry {
@@ -257,15 +277,22 @@ interface StudioReplJournalEntry {
257
277
  requestId: string;
258
278
  createdAt: number;
259
279
  updatedAt: number;
280
+ completedAt: number | null;
260
281
  sessionName: string;
261
282
  runtime: StudioReplRuntime | "unknown";
283
+ origin: "pi-studio" | "pi-repl" | "unknown";
262
284
  label: string;
263
285
  mode: "raw" | "literate" | "agent";
264
286
  prose: string;
265
287
  code: string;
266
288
  output: string;
267
- status: "sent" | "captured" | "timeout" | "error" | "note";
289
+ status: "sending" | "sent" | "captured" | "timeout" | "error" | "note";
268
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;
269
296
  }
270
297
 
271
298
  interface PreparedStudioPdfExport {
@@ -719,6 +746,32 @@ interface ReplSendRequestMessage {
719
746
  requestId: string;
720
747
  sessionName: string;
721
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;
722
775
  }
723
776
 
724
777
  interface ReplInterruptRequestMessage {
@@ -846,6 +899,9 @@ type IncomingStudioMessage =
846
899
  | ReplStartRequestMessage
847
900
  | ReplStopRequestMessage
848
901
  | ReplSendRequestMessage
902
+ | ReplJournalUpsertRequestMessage
903
+ | ReplJournalImportRequestMessage
904
+ | ReplJournalClearRequestMessage
849
905
  | ReplInterruptRequestMessage
850
906
  | CompactRequestMessage
851
907
  | SaveAsRequestMessage
@@ -911,6 +967,7 @@ const STUDIO_REPL_SEND_MAX_CHARS = 200_000;
911
967
  const STUDIO_REPL_SEND_DEFAULT_TIMEOUT_MS = 20_000;
912
968
  const STUDIO_REPL_SEND_MAX_TIMEOUT_MS = 120_000;
913
969
  const STUDIO_REPL_JOURNAL_MAX_ENTRIES = 300;
970
+ const STUDIO_REPL_RUNTIME_OPTION = "@pi_repl_runtime";
914
971
  const STUDIO_REPL_CONTROL_ROOT = join(tmpdir(), "pi-studio-repl");
915
972
  const STUDIO_SUBPROCESS_OUTPUT_MAX_BYTES = 2_000_000;
916
973
  const STUDIO_PANDOC_TIMEOUT_MS = readStudioPositiveEnvMs("PI_STUDIO_PANDOC_TIMEOUT_MS", 120_000, 5_000, 15 * 60_000);
@@ -1204,6 +1261,7 @@ let studioPersistentStateCache: StudioPersistentState | null = null;
1204
1261
  let studioPersistentStateQueue: Promise<void> = Promise.resolve();
1205
1262
  let transientStudioDocuments: Map<string, { document: InitialStudioDocument; createdAt: number }> = new Map();
1206
1263
  let studioReplJournalEntries: StudioReplJournalEntry[] = [];
1264
+ const studioReplUnsyncedJournalEntryIds = new Set<string>();
1207
1265
 
1208
1266
  function createEmptyStudioPersistentState(): StudioPersistentState {
1209
1267
  return {
@@ -8105,7 +8163,10 @@ async function respondLocalPreviewLinkJson(
8105
8163
  ...basePayload,
8106
8164
  converted,
8107
8165
  resourceDir: documentResourceDir,
8108
- relativeUrl: buildStudioRelativeUrl(serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true }),
8166
+ relativeUrl: buildStudioRelativeUrl(serverState.token, "editor-only", document, docId, {
8167
+ skipWorkspaceRestore: true,
8168
+ paneFocus: action === "watch-url" ? "right" : undefined,
8169
+ }),
8109
8170
  });
8110
8171
  }
8111
8172
 
@@ -9674,6 +9735,31 @@ function normalizeStudioQuizThinking(value: unknown): StudioQuizThinking {
9674
9735
  return "minimal";
9675
9736
  }
9676
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
+
9677
9763
  function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
9678
9764
  let parsed: unknown;
9679
9765
  try {
@@ -9978,9 +10064,29 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
9978
10064
  requestId: msg.requestId,
9979
10065
  sessionName: msg.sessionName,
9980
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))),
9981
10073
  };
9982
10074
  }
9983
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
+
9984
10090
  if (msg.type === "repl_interrupt_request" && typeof msg.requestId === "string" && typeof msg.sessionName === "string") {
9985
10091
  return {
9986
10092
  type: "repl_interrupt_request",
@@ -10872,9 +10978,111 @@ function runStudioTmux(args: string[], options?: { cwd?: string; input?: string;
10872
10978
  return { ok: true, stdout, stderr };
10873
10979
  }
10874
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
+
10875
11079
  function listStudioReplSessions(): { tmuxAvailable: boolean; sessions: StudioReplSessionInfo[]; error?: string } {
10876
11080
  if (!isTmuxAvailable()) return { tmuxAvailable: false, sessions: [], error: "tmux is not available." };
10877
- 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 });
10878
11086
  if (!result.ok) {
10879
11087
  const message = result.message.toLowerCase().includes("no server running") ? "No tmux sessions are running." : result.message;
10880
11088
  return { tmuxAvailable: true, sessions: [], error: message };
@@ -10883,30 +11091,18 @@ function listStudioReplSessions(): { tmuxAvailable: boolean; sessions: StudioRep
10883
11091
  .split(/\r?\n/)
10884
11092
  .map((line) => line.trim())
10885
11093
  .filter(Boolean)
10886
- .filter(shouldShowStudioReplTmuxSession)
10887
- .map((sessionName) => {
10888
- const inferred = inferStudioReplSessionRuntime(sessionName);
10889
- return {
10890
- sessionName,
10891
- target: getStudioReplPaneTarget(sessionName),
10892
- runtime: inferred.runtime,
10893
- label: formatStudioReplSessionLabel(sessionName, inferred.runtime, inferred.source),
10894
- source: inferred.source,
10895
- };
10896
- });
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
+ ));
10897
11099
  return { tmuxAvailable: true, sessions };
10898
11100
  }
10899
11101
 
10900
11102
  function captureStudioReplSession(sessionName: string): { ok: true; transcript: string; session: StudioReplSessionInfo } | { ok: false; message: string } {
10901
11103
  if (!/^[-_.A-Za-z0-9]+$/.test(sessionName)) return { ok: false, message: "Invalid REPL session name." };
10902
- const inferred = inferStudioReplSessionRuntime(sessionName);
10903
- const session: StudioReplSessionInfo = {
10904
- sessionName,
10905
- target: getStudioReplPaneTarget(sessionName),
10906
- runtime: inferred.runtime,
10907
- label: formatStudioReplSessionLabel(sessionName, inferred.runtime, inferred.source),
10908
- source: inferred.source,
10909
- };
11104
+ const session = inspectStudioReplSession(sessionName);
11105
+ if (!session) return { ok: false, message: `No tmux REPL session named ${sessionName}.` };
10910
11106
  const result = runStudioTmux(["capture-pane", "-J", "-p", "-t", session.target, "-S", `-${STUDIO_REPL_CAPTURE_LINES}`], { timeout: 3_000 });
10911
11107
  if (!result.ok) return { ok: false, message: result.message };
10912
11108
  return { ok: true, transcript: String(result.stdout || "").replace(/[\t ]+$/gm, "").trimEnd(), session };
@@ -10918,31 +11114,26 @@ function startStudioReplSession(runtime: StudioReplRuntime, cwd: string, options
10918
11114
  const sessionName = options?.newSession ? getNewStudioReplSessionName(runtime, commandOverride) : getStudioReplSessionName(runtime, commandOverride);
10919
11115
  const existing = runStudioTmux(["has-session", "-t", sessionName], { timeout: 3_000 });
10920
11116
  if (existing.ok) {
10921
- const inferred = inferStudioReplSessionRuntime(sessionName);
11117
+ const session = inspectStudioReplSession(sessionName);
11118
+ if (!session) return { ok: false, message: `Could not inspect existing REPL session ${sessionName}.` };
10922
11119
  return {
10923
11120
  ok: true,
10924
- session: {
10925
- sessionName,
10926
- target: getStudioReplPaneTarget(sessionName),
10927
- runtime: inferred.runtime,
10928
- label: formatStudioReplSessionLabel(sessionName, inferred.runtime, inferred.source),
10929
- source: inferred.source,
10930
- },
11121
+ session,
10931
11122
  message: `${STUDIO_REPL_RUNTIME_LABELS[runtime]} REPL is already running.`,
10932
11123
  };
10933
11124
  }
10934
11125
  const command = getStudioReplRuntimeCommand(runtime, commandOverride);
10935
11126
  const result = runStudioTmux(buildStudioReplTmuxStartArgs(sessionName, cwd || process.cwd(), command), { timeout: 5_000 });
10936
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
+ }
10937
11134
  return {
10938
11135
  ok: true,
10939
- session: {
10940
- sessionName,
10941
- target: getStudioReplPaneTarget(sessionName),
10942
- runtime,
10943
- label: formatStudioReplSessionLabel(sessionName, runtime, "studio"),
10944
- source: "studio",
10945
- },
11136
+ session,
10946
11137
  message: `Started ${options?.newSession ? "new " : ""}${STUDIO_REPL_RUNTIME_LABELS[runtime]} REPL${commandOverride ? ` with custom command: ${commandOverride}` : ""}.`,
10947
11138
  };
10948
11139
  }
@@ -10980,7 +11171,15 @@ type StudioReplSendSuccess = {
10980
11171
  controlFiles?: StudioReplControlFiles;
10981
11172
  };
10982
11173
 
10983
- 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
+ };
10984
11183
 
10985
11184
  function sleep(ms: number): Promise<void> {
10986
11185
  return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
@@ -11007,7 +11206,9 @@ function getStudioReplControlFiles(sessionName: string, runtime: StudioReplRunti
11007
11206
  ? "ghci"
11008
11207
  : runtime === "clojure"
11009
11208
  ? "clj"
11010
- : "py";
11209
+ : runtime === "shell"
11210
+ ? "sh"
11211
+ : "py";
11011
11212
  return {
11012
11213
  dir,
11013
11214
  sourceFile: join(dir, `studio-repl-${safeRuntime}.${extension}`),
@@ -11122,22 +11323,28 @@ function buildStudioReplControlSource(runtime: StudioReplRuntime, code: string,
11122
11323
  if (runtime === "r") return buildStudioRControlSource(code, doneFile);
11123
11324
  if (runtime === "ghci") return `${code.replace(/\r/g, "").trimEnd()}\n:! touch ${shellQuote(doneFile)}\n`;
11124
11325
  if (runtime === "clojure") return buildStudioClojureControlSource(code, doneFile);
11326
+ if (runtime === "shell") return `${code.replace(/\r/g, "").trimEnd()}\n`;
11125
11327
  return null;
11126
11328
  }
11127
11329
 
11128
- function buildStudioReplSubmissionLine(runtime: StudioReplRuntime, sourceFile: string): string {
11330
+ function buildStudioReplSubmissionLine(runtime: StudioReplRuntime, sourceFile: string, doneFile: string): string {
11129
11331
  const quotedPath = JSON.stringify(sourceFile);
11130
11332
  if (runtime === "julia") return `include(${quotedPath})`;
11131
11333
  if (runtime === "r") return `source(${quotedPath}, local=.GlobalEnv)`;
11132
11334
  if (runtime === "ghci") return `:script ${quotedPath}`;
11133
11335
  if (runtime === "clojure") return `(do (load-file ${quotedPath}) :pi-studio/silent)`;
11336
+ if (runtime === "shell") return `. ${shellQuote(sourceFile)}; touch ${shellQuote(doneFile)}`;
11134
11337
  return `exec(open(${quotedPath}, encoding="utf-8").read(), globals())`;
11135
11338
  }
11136
11339
 
11137
- function prepareStudioReplSubmission(sessionName: string, source: string): StudioReplPreparedSubmission {
11340
+ function prepareStudioReplSubmission(
11341
+ sessionName: string,
11342
+ source: string,
11343
+ runtimeHint?: StudioReplRuntime | "unknown",
11344
+ ): StudioReplPreparedSubmission {
11138
11345
  const normalizedSource = String(source || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
11139
- const runtime = inferStudioReplSessionRuntime(sessionName).runtime;
11140
- if (runtime !== "unknown" && runtime !== "shell") {
11346
+ const runtime = runtimeHint && runtimeHint !== "unknown" ? runtimeHint : inferStudioReplSessionRuntime(sessionName).runtime;
11347
+ if (runtime !== "unknown") {
11141
11348
  const controlFiles = getStudioReplControlFiles(sessionName, runtime);
11142
11349
  const controlSource = buildStudioReplControlSource(runtime, normalizedSource, controlFiles.doneFile);
11143
11350
  if (controlSource) {
@@ -11148,7 +11355,7 @@ function prepareStudioReplSubmission(sessionName: string, source: string): Studi
11148
11355
  // Ignore stale done file cleanup failures.
11149
11356
  }
11150
11357
  writeFileSync(controlFiles.sourceFile, controlSource, "utf-8");
11151
- const submissionLine = buildStudioReplSubmissionLine(runtime, controlFiles.sourceFile);
11358
+ const submissionLine = buildStudioReplSubmissionLine(runtime, controlFiles.sourceFile, controlFiles.doneFile);
11152
11359
  return {
11153
11360
  runtime,
11154
11361
  usedControlFile: true,
@@ -11165,32 +11372,45 @@ function prepareStudioReplSubmission(sessionName: string, source: string): Studi
11165
11372
  };
11166
11373
  }
11167
11374
 
11168
- 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 } {
11169
11376
  const bufferName = `pi-studio-repl-${randomUUID().replace(/-/g, "")}`;
11170
- const target = getStudioReplPaneTarget(sessionName);
11377
+ const target = paneTarget || getStudioReplPaneTarget(sessionName);
11171
11378
  const loadResult = runStudioTmux(["load-buffer", "-b", bufferName, "-"], { input: text, timeout: 5_000 });
11172
- 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 };
11173
11380
  try {
11174
11381
  const pasteResult = runStudioTmux(["paste-buffer", "-d", "-b", bufferName, "-t", target], { timeout: 5_000 });
11175
- 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 };
11176
11383
  const enterResult = runStudioTmux(["send-keys", "-t", target, "C-m"], { timeout: 5_000 });
11177
- 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 };
11178
11385
  return { ok: true };
11179
11386
  } finally {
11180
11387
  runStudioTmux(["delete-buffer", "-b", bufferName], { timeout: 2_000 });
11181
11388
  }
11182
11389
  }
11183
11390
 
11184
- 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 {
11185
11397
  if (!/^[-_.A-Za-z0-9]+$/.test(sessionName)) return { ok: false, message: "Invalid REPL session name." };
11186
11398
  const source = String(text || "");
11187
11399
  if (!source.trim()) return { ok: false, message: "Editor text is empty." };
11188
11400
  if (source.length > STUDIO_REPL_SEND_MAX_CHARS) {
11189
11401
  return { ok: false, message: `REPL input is too large (${source.length} chars; max ${STUDIO_REPL_SEND_MAX_CHARS}).` };
11190
11402
  }
11191
- const prepared = prepareStudioReplSubmission(sessionName, source);
11192
- const pasted = pasteTextToStudioReplPane(sessionName, prepared.submissionText);
11193
- 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
+ };
11194
11414
  return {
11195
11415
  ok: true,
11196
11416
  message: "Sent to REPL.",
@@ -11230,6 +11450,7 @@ function stripStudioReplSubmissionEcho(output: string): string {
11230
11450
  /^.*source\([\s\S]*?pi-studio-re[\s\S]*?local\s*=\s*\.GlobalEnv\)\s*$/gm,
11231
11451
  /^.*:script\s+[\s\S]*?pi-studio-re[\s\S]*?\.ghci"?\s*$/gm,
11232
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,
11233
11454
  ];
11234
11455
  for (const pattern of submissionEchoPatterns) value = value.replace(pattern, "");
11235
11456
  return value.replace(/^(?:\s*\n)+/, "").replace(/[\t ]+$/gm, "").trimEnd();
@@ -11252,18 +11473,27 @@ function normalizeStudioReplJournalMode(mode: unknown): StudioReplJournalEntry["
11252
11473
  }
11253
11474
 
11254
11475
  function normalizeStudioReplJournalStatus(status: unknown): StudioReplJournalEntry["status"] {
11255
- 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";
11256
11483
  }
11257
11484
 
11258
- function makeStudioReplJournalEntry(details: Partial<StudioReplJournalEntry> & { sessionName: string; code: string }): StudioReplJournalEntry {
11485
+ function makeStudioReplJournalEntry(details: Partial<StudioReplJournalEntry> & { sessionName: string; code?: string }): StudioReplJournalEntry {
11259
11486
  const now = Date.now();
11487
+ const createdAt = typeof details.createdAt === "number" && Number.isFinite(details.createdAt) ? details.createdAt : now;
11260
11488
  return {
11261
11489
  id: typeof details.id === "string" && details.id.trim() ? details.id.trim() : `repl-journal-${now.toString(36)}-${randomUUID().slice(0, 8)}`,
11262
11490
  requestId: typeof details.requestId === "string" ? details.requestId : "",
11263
- createdAt: typeof details.createdAt === "number" && Number.isFinite(details.createdAt) ? details.createdAt : now,
11491
+ createdAt,
11264
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,
11265
11494
  sessionName: String(details.sessionName || ""),
11266
- runtime: details.runtime || "unknown",
11495
+ runtime: normalizeStudioReplRuntime(details.runtime) || "unknown",
11496
+ origin: details.origin === "pi-repl" || details.origin === "pi-studio" ? details.origin : "unknown",
11267
11497
  label: typeof details.label === "string" && details.label.trim() ? details.label.trim() : "REPL send",
11268
11498
  mode: normalizeStudioReplJournalMode(details.mode),
11269
11499
  prose: typeof details.prose === "string" ? details.prose : "",
@@ -11271,6 +11501,10 @@ function makeStudioReplJournalEntry(details: Partial<StudioReplJournalEntry> & {
11271
11501
  output: typeof details.output === "string" ? details.output : "",
11272
11502
  status: normalizeStudioReplJournalStatus(details.status),
11273
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,
11274
11508
  };
11275
11509
  }
11276
11510
 
@@ -11293,47 +11527,192 @@ function upsertStudioReplJournalEntry(entry: StudioReplJournalEntry): StudioRepl
11293
11527
  studioReplJournalEntries = studioReplJournalEntries
11294
11528
  .sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0))
11295
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
+ }
11296
11534
  return studioReplJournalEntries.find((candidate) => candidate.id === entry.id || (entry.requestId && candidate.requestId === entry.requestId)) || entry;
11297
11535
  }
11298
11536
 
11299
- function recordStudioReplJournalEntry(details: Partial<StudioReplJournalEntry> & { sessionName: string; code: string }): StudioReplJournalEntry {
11300
- 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
+ }));
11301
11620
  }
11302
11621
 
11303
11622
  function updateStudioReplJournalEntryOutput(requestId: string, sessionName: string, output: string, status: StudioReplJournalEntry["status"]): void {
11304
11623
  const normalizedRequestId = String(requestId || "");
11305
11624
  const normalizedSessionName = String(sessionName || "");
11306
- const existing = studioReplJournalEntries.find((entry) => (
11625
+ const existing = getStudioReplJournalEntries(normalizedSessionName).slice().reverse().find((entry) => (
11307
11626
  (normalizedRequestId && entry.requestId === normalizedRequestId)
11308
- || (!normalizedRequestId && normalizedSessionName && entry.sessionName === normalizedSessionName && entry.status === "sent")
11627
+ || (!normalizedRequestId && entry.status === "sent")
11309
11628
  ));
11310
11629
  if (!existing) return;
11311
- upsertStudioReplJournalEntry({
11630
+ recordStudioReplJournalEntry({
11312
11631
  ...existing,
11313
11632
  output: String(output || ""),
11314
11633
  status,
11634
+ completedAt: Date.now(),
11315
11635
  updatedAt: Date.now(),
11316
11636
  });
11317
11637
  }
11318
11638
 
11319
- function getStudioReplJournalEntries(sessionName: string | null | undefined): StudioReplJournalEntry[] {
11639
+ function clearStudioReplJournal(sessionName: string): void {
11320
11640
  const normalizedSessionName = String(sessionName || "").trim();
11321
- const entries = normalizedSessionName
11322
- ? studioReplJournalEntries.filter((entry) => entry.sessionName === normalizedSessionName)
11323
- : studioReplJournalEntries;
11324
- 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));
11325
11648
  }
11326
11649
 
11327
- async function waitForStudioReplDoneFile(doneFile: string | undefined, timeoutMs: number): Promise<boolean> {
11650
+ async function waitForStudioReplDoneFile(doneFile: string | undefined, timeoutMs: number, signal?: AbortSignal): Promise<boolean> {
11328
11651
  if (!doneFile) return false;
11329
11652
  const deadline = Date.now() + clampStudioReplSendTimeout(timeoutMs);
11330
11653
  while (Date.now() < deadline) {
11331
11654
  if (existsSync(doneFile)) return true;
11655
+ if (signal?.aborted) return false;
11332
11656
  await sleep(100);
11333
11657
  }
11334
11658
  return existsSync(doneFile);
11335
11659
  }
11336
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
+
11337
11716
  function interruptStudioReplSession(sessionName: string): { ok: true; message: string } | { ok: false; message: string } {
11338
11717
  if (!/^[-_.A-Za-z0-9]+$/.test(sessionName)) return { ok: false, message: "Invalid REPL session name." };
11339
11718
  const result = runStudioTmux(["send-keys", "-t", getStudioReplPaneTarget(sessionName), "C-c"], { timeout: 5_000 });
@@ -11903,10 +12282,12 @@ ${cssVarsBlock}
11903
12282
  <section id="leftPane">
11904
12283
  <div id="leftSectionHeader" class="section-header">
11905
12284
  <div class="section-header-main">
11906
- <select id="editorViewSelect" aria-label="Editor view mode" title="Editor view mode. Shortcut: F7 when the editor pane is active; F6 switches panes.">
11907
- <option value="markdown" selected>Editor (Raw)</option>
11908
- <option value="preview">Editor (Preview)</option>
11909
- </select>
12285
+ <span id="editorViewSelectWrap" class="studio-header-select-wrap">
12286
+ <select id="editorViewSelect" aria-label="Editor view mode" title="Editor view mode. Shortcut: F7 when the editor pane is active; F6 switches panes.">
12287
+ <option value="markdown" selected>Editor (Raw)</option>
12288
+ <option value="preview">Editor (Preview)</option>
12289
+ </select>
12290
+ </span>
11910
12291
  </div>
11911
12292
  <div class="section-header-actions">
11912
12293
  <button id="leftFocusBtn" class="pane-focus-btn" type="button" title="Show only the editor pane. Shortcut: F10 or Cmd/Ctrl+Esc.">Focus pane</button>
@@ -11934,7 +12315,7 @@ ${cssVarsBlock}
11934
12315
  </div>
11935
12316
  <div class="source-actions-row repl-action-line" hidden>
11936
12317
  <button id="sendReplBtn" type="button" hidden title="Send the current selection, or the full editor text, to the active REPL session shown in the right pane.">Send to REPL</button>
11937
- <select id="replSendModeSelect" hidden aria-label="REPL send mode" title="Choose how Send to REPL interprets the editor text.">
12318
+ <select id="replSendModeSelect" class="studio-flat-select" hidden aria-label="REPL send mode" title="Choose how Send to REPL interprets the editor text.">
11938
12319
  <option value="raw" selected>Send mode: Raw</option>
11939
12320
  <option value="literate">Send mode: Literate</option>
11940
12321
  </select>
@@ -11943,11 +12324,11 @@ ${cssVarsBlock}
11943
12324
  <button id="copyDraftBtn" type="button" title="Copy the current editor text to the clipboard.">Copy</button>
11944
12325
  <button id="suggestCompletionBtn" type="button" title="Ask the current model for a short completion at the editor cursor. Shortcut: Option/Alt+Tab where available, or Cmd/Ctrl+Shift+Space from the editor.">Suggest</button>
11945
12326
  <button id="suggestCompletionOptionsBtn" type="button" hidden title="Suggestion context options">▾</button>
11946
- <select id="completionContextSelect" hidden aria-label="Suggestion context mode" title="Choose how much context Suggest includes.">
12327
+ <select id="completionContextSelect" class="studio-flat-select" hidden aria-label="Suggestion context mode" title="Choose how much context Suggest includes.">
11947
12328
  <option value="cursor" selected>Context: editor only</option>
11948
12329
  <option value="session">Context: editor + latest response</option>
11949
12330
  </select>
11950
- <select id="completionModelSelect" hidden aria-label="Suggestion model" title="Choose the model used for Suggest. Suggestions use direct completion with thinking off and do not change the main Pi model.">
12331
+ <select id="completionModelSelect" class="studio-flat-select" hidden aria-label="Suggestion model" title="Choose the model used for Suggest. Suggestions use direct completion with thinking off and do not change the main Pi model.">
11951
12332
  <option value="current" selected>Suggestion model: current Pi model</option>
11952
12333
  </select>
11953
12334
  <button id="openCompanionBtn" type="button" title="Open a blank editor-only Studio tab.">New editor tab</button>
@@ -11955,7 +12336,7 @@ ${cssVarsBlock}
11955
12336
  </div>
11956
12337
  <div class="source-actions-row">
11957
12338
  <button id="insertHeaderBtn" type="button" title="Insert annotated-reply protocol header (source metadata, [an: ...] syntax hint, precedence note, and end marker).">Annotation header</button>
11958
- <select id="annotationModeSelect" aria-label="Inline annotation visibility mode" title="On: keep and send [an: ...] markers. Hide: keep markers in the editor, hide them in preview, and strip before Run/Critique.">
12339
+ <select id="annotationModeSelect" class="studio-flat-select" aria-label="Inline annotation visibility mode" title="On: keep and send [an: ...] markers. Hide: keep markers in the editor, hide them in preview, and strip before Run/Critique.">
11959
12340
  <option value="on" selected>Inline annotations: On</option>
11960
12341
  <option value="off">Inline annotations: Hide</option>
11961
12342
  </select>
@@ -11963,7 +12344,7 @@ ${cssVarsBlock}
11963
12344
  <button id="saveAnnotatedBtn" type="button" title="Save full editor content (including [an: ...] markers) as a .annotated.md file.">Save .annotated.md</button>
11964
12345
  </div>
11965
12346
  <div class="source-actions-row">
11966
- <select id="lensSelect" aria-label="Critique focus">
12347
+ <select id="lensSelect" class="studio-flat-select" aria-label="Critique focus">
11967
12348
  <option value="auto" selected>Critique: Auto</option>
11968
12349
  <option value="writing">Critique: Writing</option>
11969
12350
  <option value="code">Critique: Code</option>
@@ -11973,7 +12354,7 @@ ${cssVarsBlock}
11973
12354
  <button id="showMeResponseBtn" type="button" hidden title="Explain the response displayed in the right pane using the smallest useful visual or structural representation.">Explain displayed response</button>
11974
12355
  <button id="askAsideBtn" type="button" title="Open a separate side-question thread without adding it to the main Pi conversation.">Side question</button>
11975
12356
  <button id="quizBtn" type="button" title="Open an active quiz for the current editor selection or document.">Quiz me</button>
11976
- <select id="highlightSelect" aria-label="Editor syntax highlighting">
12357
+ <select id="highlightSelect" class="studio-flat-select" aria-label="Editor syntax highlighting">
11977
12358
  <option value="off">Syntax highlight: Off</option>
11978
12359
  <option value="bash">Syntax highlight: Bash</option>
11979
12360
  <option value="c">Syntax highlight: C</option>
@@ -12003,11 +12384,11 @@ ${cssVarsBlock}
12003
12384
  <option value="xml">Syntax highlight: XML</option>
12004
12385
  <option value="yaml">Syntax highlight: YAML</option>
12005
12386
  </select>
12006
- <select id="lineNumbersSelect" aria-label="Editor line numbers">
12387
+ <select id="lineNumbersSelect" class="studio-flat-select" aria-label="Editor line numbers">
12007
12388
  <option value="off">Line numbers: Off</option>
12008
12389
  <option value="on" selected>Line numbers: On</option>
12009
12390
  </select>
12010
- <select id="editorFontSizeSelect" aria-label="Editor text size" title="Adjust raw editor text size.">
12391
+ <select id="editorFontSizeSelect" class="studio-flat-select" aria-label="Editor text size" title="Adjust raw editor text size.">
12011
12392
  <option value="10">Editor text: 10px</option>
12012
12393
  <option value="11">Editor text: 11px</option>
12013
12394
  <option value="12" selected>Editor text: 12px</option>
@@ -12105,17 +12486,19 @@ ${cssVarsBlock}
12105
12486
  <section id="rightPane">
12106
12487
  <div id="rightSectionHeader" class="section-header">
12107
12488
  <div class="section-header-main">
12108
- <select id="rightViewSelect" aria-label="Response view mode" title="Right pane view mode. F7 cycles when the right pane is active; Cmd/Ctrl+Alt+1–8 switches directly between all right-pane views. Cmd/Ctrl+Alt+P/E/W/F/Q keep mnemonic shortcuts for Preview, Editor Preview, Working, Files, and Side questions.">
12109
- <option value="markdown">Response (Raw)</option>
12110
- <option value="preview" selected>Response (Preview)</option>
12111
- <option value="editor-preview">Editor (Preview)</option>
12112
- <option value="editor-quarto-preview" hidden>Editor (Quarto Preview)</option>
12113
- <option value="trace">Working</option>
12114
- <option value="changes">Changes</option>
12115
- <option value="files">Files</option>
12116
- <option value="repl">REPL</option>
12117
- <option value="side-questions">Side questions</option>
12118
- </select>
12489
+ <span id="rightViewSelectWrap" class="studio-header-select-wrap">
12490
+ <select id="rightViewSelect" aria-label="Response view mode" title="Right pane view mode. F7 cycles when the right pane is active; Cmd/Ctrl+Alt+1–8 switches directly between all right-pane views. Cmd/Ctrl+Alt+P/E/W/F/Q keep mnemonic shortcuts for Preview, Editor Preview, Working, Files, and Side questions.">
12491
+ <option value="markdown">Response (Raw)</option>
12492
+ <option value="preview" selected>Response (Preview)</option>
12493
+ <option value="editor-preview">Editor (Preview)</option>
12494
+ <option value="editor-quarto-preview" hidden>Editor (Quarto Preview)</option>
12495
+ <option value="trace">Working</option>
12496
+ <option value="changes">Changes</option>
12497
+ <option value="files">Files</option>
12498
+ <option value="repl">REPL</option>
12499
+ <option value="side-questions">Side questions</option>
12500
+ </select>
12501
+ </span>
12119
12502
  </div>
12120
12503
  <div class="section-header-actions">
12121
12504
  <button id="rightFocusBtn" class="pane-focus-btn" type="button" title="Show only the response pane. Shortcut: F10 or Cmd/Ctrl+Esc.">Focus pane</button>
@@ -12142,28 +12525,34 @@ ${cssVarsBlock}
12142
12525
  <div class="response-wrap">
12143
12526
  <div id="responseActions" class="response-actions">
12144
12527
  <div class="response-actions-row response-options-row">
12145
- <select id="followSelect" aria-label="Auto-update response">
12146
- <option value="on" selected>Auto-update response: On</option>
12147
- <option value="off">Auto-update response: Off</option>
12148
- </select>
12149
- <select id="responseHighlightSelect" aria-label="Response markdown highlighting">
12150
- <option value="off">Syntax highlight: Off</option>
12151
- <option value="on" selected>Syntax highlight: On</option>
12152
- </select>
12153
- <select id="responseFontSizeSelect" aria-label="Response text size" title="Adjust right-pane response, preview, and working text size.">
12154
- <option value="11">Response text: 11px</option>
12155
- <option value="12">Response text: 12px</option>
12156
- <option value="12.5">Response text: 12.5px</option>
12157
- <option value="13">Response text: 13px</option>
12158
- <option value="13.5" selected>Response text: 13.5px</option>
12159
- <option value="14">Response text: 14px</option>
12160
- <option value="14.5">Response text: 14.5px</option>
12161
- <option value="15">Response text: 15px</option>
12162
- <option value="15.5">Response text: 15.5px</option>
12163
- <option value="16">Response text: 16px</option>
12164
- <option value="18">Response text: 18px</option>
12165
- <option value="20">Response text: 20px</option>
12166
- </select>
12528
+ <span id="followSelectWrap" class="studio-menu-select-wrap response-option-select-wrap">
12529
+ <select id="followSelect" aria-label="Auto-update response">
12530
+ <option value="on" selected>Auto-update response: On</option>
12531
+ <option value="off">Auto-update response: Off</option>
12532
+ </select>
12533
+ </span>
12534
+ <span id="responseHighlightSelectWrap" class="studio-menu-select-wrap response-option-select-wrap">
12535
+ <select id="responseHighlightSelect" aria-label="Response markdown highlighting">
12536
+ <option value="off">Syntax highlight: Off</option>
12537
+ <option value="on" selected>Syntax highlight: On</option>
12538
+ </select>
12539
+ </span>
12540
+ <span id="responseFontSizeSelectWrap" class="studio-menu-select-wrap response-option-select-wrap">
12541
+ <select id="responseFontSizeSelect" aria-label="Response text size" title="Adjust right-pane response, preview, and working text size.">
12542
+ <option value="11">Response text: 11px</option>
12543
+ <option value="12">Response text: 12px</option>
12544
+ <option value="12.5">Response text: 12.5px</option>
12545
+ <option value="13">Response text: 13px</option>
12546
+ <option value="13.5" selected>Response text: 13.5px</option>
12547
+ <option value="14">Response text: 14px</option>
12548
+ <option value="14.5">Response text: 14.5px</option>
12549
+ <option value="15">Response text: 15px</option>
12550
+ <option value="15.5">Response text: 15.5px</option>
12551
+ <option value="16">Response text: 16px</option>
12552
+ <option value="18">Response text: 18px</option>
12553
+ <option value="20">Response text: 20px</option>
12554
+ </select>
12555
+ </span>
12167
12556
  </div>
12168
12557
  <div class="response-actions-row history-row">
12169
12558
  <button id="pullLatestBtn" type="button" title="Fetch the latest assistant response when auto-update is off.">Fetch latest response</button>
@@ -12464,6 +12853,7 @@ export default function (pi: ExtensionAPI) {
12464
12853
  parameters: STUDIO_REPL_STATUS_TOOL_PARAMS,
12465
12854
  async execute(_toolCallId, params) {
12466
12855
  const selected = selectStudioReplSessionForTool({ sessionName: params.sessionName, target: params.target });
12856
+ const recordEntries = selected.session ? getStudioReplJournalEntries(selected.session.sessionName) : [];
12467
12857
  const lines = [
12468
12858
  `Active Studio REPL: ${studioReplActiveSessionName || "none"}`,
12469
12859
  `tmux sessions visible to Studio: ${selected.sessions.length}`,
@@ -12471,6 +12861,10 @@ export default function (pi: ExtensionAPI) {
12471
12861
  if (selected.error) lines.push(`Selection: ${selected.error}`);
12472
12862
  if (selected.session) {
12473
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}`);
12474
12868
  }
12475
12869
  for (const session of selected.sessions) {
12476
12870
  lines.push(`- ${session.sessionName} | runtime=${session.runtime} | source=${session.source} | target=${session.target}`);
@@ -12480,6 +12874,7 @@ export default function (pi: ExtensionAPI) {
12480
12874
  details: {
12481
12875
  activeSessionName: studioReplActiveSessionName,
12482
12876
  selectedSession: selected.session,
12877
+ selectedRecordEntries: recordEntries,
12483
12878
  sessions: selected.sessions,
12484
12879
  } as Record<string, unknown>,
12485
12880
  };
@@ -12498,7 +12893,7 @@ export default function (pi: ExtensionAPI) {
12498
12893
  ],
12499
12894
  parameters: STUDIO_REPL_SEND_TOOL_PARAMS,
12500
12895
  executionMode: "sequential",
12501
- async execute(toolCallId, params) {
12896
+ async execute(toolCallId, params, signal) {
12502
12897
  const selected = selectStudioReplSessionForTool({ sessionName: params.sessionName, target: params.target });
12503
12898
  if (!selected.session) {
12504
12899
  return {
@@ -12507,75 +12902,151 @@ export default function (pi: ExtensionAPI) {
12507
12902
  };
12508
12903
  }
12509
12904
 
12510
- const before = captureStudioReplSession(selected.session.sessionName);
12511
- const beforeTranscript = before.ok ? before.transcript : "";
12512
- const sent = sendTextToStudioReplSession(selected.session.sessionName, params.code);
12513
- if (!sent.ok) {
12514
- return {
12515
- content: [{ type: "text", text: sent.message }],
12516
- details: { ok: false, error: sent.message, session: selected.session, sessions: selected.sessions } as Record<string, unknown>,
12517
- };
12518
- }
12519
- studioReplActiveSessionName = selected.session.sessionName;
12520
-
12521
12905
  const timeoutMs = clampStudioReplSendTimeout(params.timeoutMs);
12522
- let completed = false;
12523
- if (sent.controlFiles?.doneFile) {
12524
- completed = await waitForStudioReplDoneFile(sent.controlFiles.doneFile, timeoutMs);
12525
- } else {
12526
- await sleep(Math.min(750, timeoutMs));
12527
- }
12528
- const after = captureStudioReplSession(selected.session.sessionName);
12529
- const afterTranscript = after.ok ? after.transcript : "";
12530
- const rawOutput = extractStudioReplTranscriptDelta(beforeTranscript, afterTranscript);
12531
- const output = cleanStudioReplCapturedOutput(rawOutput);
12532
- const statusLine = sent.controlFiles?.doneFile
12533
- ? (completed ? "Completed." : `Timed out after ${timeoutMs} ms waiting for completion marker.`)
12534
- : "Submitted.";
12535
- const text = [
12536
- `${statusLine} ${sent.message}`,
12537
- output ? "" : undefined,
12538
- output || undefined,
12539
- ].filter(Boolean).join("\n");
12540
- recordStudioReplJournalEntry({
12541
- requestId: `tool:${toolCallId}`,
12542
- sessionName: selected.session.sessionName,
12543
- runtime: sent.runtime === "unknown" ? selected.session.runtime : sent.runtime,
12544
- label: "Pi",
12545
- mode: "agent",
12546
- code: params.code,
12547
- output,
12548
- status: sent.controlFiles?.doneFile && !completed ? "timeout" : (output.trim() ? "captured" : "sent"),
12549
- });
12550
- broadcastStudioReplToolSend({
12551
- toolCallId,
12552
- sessionName: selected.session.sessionName,
12553
- runtime: sent.runtime === "unknown" ? selected.session.runtime : sent.runtime,
12554
- code: params.code,
12555
- label: "Pi",
12556
- output,
12557
- completed,
12558
- timedOut: Boolean(sent.controlFiles?.doneFile && !completed),
12559
- transcript: afterTranscript,
12560
- capturedAt: Date.now(),
12561
- journalEntries: getStudioReplJournalEntries(selected.session.sessionName),
12562
- });
12563
- return {
12564
- content: [{ type: "text", text }],
12565
- details: {
12566
- 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,
12567
13006
  completed,
12568
13007
  timedOut: Boolean(sent.controlFiles?.doneFile && !completed),
12569
- timeoutMs,
12570
- session: selected.session,
12571
- sessions: selected.sessions,
12572
- runtime: sent.runtime,
12573
- usedControlFile: sent.usedControlFile,
12574
- submissionText: sent.submissionText,
12575
- controlFiles: sent.controlFiles,
12576
- output,
12577
- } as Record<string, unknown>,
12578
- };
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
+ }
12579
13050
  },
12580
13051
  });
12581
13052
 
@@ -13692,6 +14163,9 @@ export default function (pi: ExtensionAPI) {
13692
14163
  }
13693
14164
  : null;
13694
14165
 
14166
+ const getCurrentStudioSideQuestionThinkingLevels = (): StudioSideQuestionThinking[] =>
14167
+ getStudioSideQuestionThinkingLevels(latestModelRequestCtx?.model ?? lastCommandCtx?.model) as StudioSideQuestionThinking[];
14168
+
13695
14169
  const getStudioThemeOptions = () => {
13696
14170
  try {
13697
14171
  const themes = lastCommandCtx?.ui?.getAllThemes?.() ?? [];
@@ -13727,6 +14201,7 @@ export default function (pi: ExtensionAPI) {
13727
14201
  modelLabel: currentModelLabel,
13728
14202
  currentModel: getCurrentStudioModelDescriptor(),
13729
14203
  thinkingLevel: getThinkingLevelSafe() ?? "off",
14204
+ sideQuestionThinkingLevels: getCurrentStudioSideQuestionThinkingLevels(),
13730
14205
  piModels: modelOptions,
13731
14206
  suggestionModels: modelOptions,
13732
14207
  currentTheme: getCurrentStudioThemeDescriptor(),
@@ -14033,6 +14508,7 @@ export default function (pi: ExtensionAPI) {
14033
14508
  const payload: Record<string, unknown> = {
14034
14509
  type: "side_question_state",
14035
14510
  state: getStudioSideQuestionPublicState(),
14511
+ sideQuestionThinkingLevels: getCurrentStudioSideQuestionThinkingLevels(),
14036
14512
  webSearchAvailable: Boolean(String(process.env.BRAVE_API_KEY || "").trim()),
14037
14513
  };
14038
14514
  if (client) payload.availablePiTools = getPublicStudioSideQuestionToolCatalog();
@@ -14254,6 +14730,7 @@ export default function (pi: ExtensionAPI) {
14254
14730
  const model = latestModelRequestCtx?.model ?? ctx.model;
14255
14731
  if (!model) throw new Error("No active Pi model is available for side questions.");
14256
14732
  await resolveStudioModelRequestAuth({ model, modelRegistry: ctx.modelRegistry }, model);
14733
+ const thinking = resolveStudioSideQuestionThinking(model, context.thinking) as StudioSideQuestionThinking;
14257
14734
  const gitSnapshot = context.gitContext
14258
14735
  ? await captureStudioSideQuestionGitSnapshot(contextRoot, { runGit: runStudioSideQuestionGitCommand }) as StudioSideQuestionGitSnapshot
14259
14736
  : null;
@@ -14292,7 +14769,7 @@ export default function (pi: ExtensionAPI) {
14292
14769
  customTools: tools,
14293
14770
  sessionManager: sideSessionManager,
14294
14771
  model,
14295
- thinking: context.thinking,
14772
+ thinking,
14296
14773
  inheritedPrompt: stripStudioDynamicSystemPromptFooter(ctx.getSystemPrompt()),
14297
14774
  });
14298
14775
  session = agentRuntime.session;
@@ -14301,7 +14778,7 @@ export default function (pi: ExtensionAPI) {
14301
14778
  cwd: workingDirectory,
14302
14779
  sessionManager: sideSessionManager,
14303
14780
  model,
14304
- thinkingLevel: context.thinking,
14781
+ thinkingLevel: thinking,
14305
14782
  tools: activeToolNames,
14306
14783
  customTools: tools,
14307
14784
  resourceLoader: createStudioSideQuestionResourceLoader(ctx),
@@ -14334,7 +14811,7 @@ export default function (pi: ExtensionAPI) {
14334
14811
  tools: selectedPiTools,
14335
14812
  },
14336
14813
  modelLabel: formatStudioModelOptionLabel(model),
14337
- thinking: context.thinking,
14814
+ thinking,
14338
14815
  messages: [],
14339
14816
  activity: [],
14340
14817
  error: context.webSearch && !webAvailable ? "Web search was requested but BRAVE_API_KEY is not configured; this thread will use local context only." : "",
@@ -14638,6 +15115,7 @@ export default function (pi: ExtensionAPI) {
14638
15115
  modelLabel: currentModelLabel,
14639
15116
  currentModel: getCurrentStudioModelDescriptor(),
14640
15117
  thinkingLevel: getThinkingLevelSafe() ?? "off",
15118
+ sideQuestionThinkingLevels: getCurrentStudioSideQuestionThinkingLevels(),
14641
15119
  piModels: getStudioModelOptions(),
14642
15120
  suggestionModels: getStudioModelOptions(),
14643
15121
  currentTheme: getCurrentStudioThemeDescriptor(),
@@ -15431,33 +15909,65 @@ export default function (pi: ExtensionAPI) {
15431
15909
  sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
15432
15910
  return;
15433
15911
  }
15434
- const before = captureStudioReplSession(msg.sessionName);
15435
- const beforeTranscript = before.ok ? before.transcript : "";
15436
- const sent = sendTextToStudioReplSession(msg.sessionName, msg.text);
15437
- if (!sent.ok) {
15438
- sendToClient(client, { type: "error", requestId: msg.requestId, message: sent.message });
15439
- sendReplCaptureToClient(client, msg.sessionName, { requestId: msg.requestId, replError: sent.message });
15440
- return;
15441
- }
15442
- studioReplActiveSessionName = msg.sessionName;
15443
- recordStudioReplJournalEntry({
15444
- requestId: msg.requestId,
15445
- sessionName: msg.sessionName,
15446
- runtime: sent.runtime,
15447
- label: "Studio",
15448
- mode: "raw",
15449
- code: msg.text,
15450
- status: "sent",
15451
- });
15452
- sendToClient(client, {
15453
- type: "repl_send_ack",
15454
- requestId: msg.requestId,
15455
- sessionName: msg.sessionName,
15456
- message: sent.message,
15457
- journalEntries: getStudioReplJournalEntries(msg.sessionName),
15458
- });
15459
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;
15460
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
+ });
15461
15971
  const timeoutMs = STUDIO_REPL_SEND_DEFAULT_TIMEOUT_MS;
15462
15972
  let completed = false;
15463
15973
  if (sent.controlFiles?.doneFile) {
@@ -15466,24 +15976,107 @@ export default function (pi: ExtensionAPI) {
15466
15976
  await sleep(Math.min(750, timeoutMs));
15467
15977
  }
15468
15978
  const after = captureStudioReplSession(msg.sessionName);
15469
- 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;
15470
15984
  const rawOutput = extractStudioReplTranscriptDelta(beforeTranscript, afterTranscript);
15471
15985
  const output = cleanStudioReplCapturedOutput(rawOutput);
15472
15986
  updateStudioReplJournalEntryOutput(
15473
15987
  msg.requestId,
15474
15988
  msg.sessionName,
15475
15989
  output,
15476
- sent.controlFiles?.doneFile && !completed ? "timeout" : (output.trim() ? "captured" : "sent"),
15990
+ sent.controlFiles?.doneFile ? (completed ? "captured" : "timeout") : (output.trim() ? "captured" : "sent"),
15477
15991
  );
15992
+ if (lease) {
15993
+ await releaseOrRetainStudioReplSendLease(lease, submittedSession, submittedDoneFile);
15994
+ lease = null;
15995
+ }
15478
15996
  sendReplCaptureToClient(client, msg.sessionName, { requestId: msg.requestId });
15479
15997
  } catch (error) {
15480
- updateStudioReplJournalEntryOutput(msg.requestId, msg.sessionName, error instanceof Error ? error.message : String(error), "error");
15481
- 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);
15482
16008
  }
15483
16009
  })();
15484
16010
  return;
15485
16011
  }
15486
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
+
15487
16080
  if (msg.type === "repl_interrupt_request") {
15488
16081
  if (!isValidRequestId(msg.requestId)) {
15489
16082
  sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });