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/CHANGELOG.md +13 -0
- package/README.md +14 -2
- package/ROADMAP.md +16 -1
- package/client/studio-client.js +163 -43
- package/index.ts +734 -163
- package/package.json +1 -1
- package/shared/REPL_SESSION_RECORD_PROTOCOL.md +70 -0
- package/shared/repl-session-record.js +623 -0
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([
|
|
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
|
-
.
|
|
10892
|
-
.
|
|
10893
|
-
|
|
10894
|
-
|
|
10895
|
-
|
|
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
|
|
10908
|
-
|
|
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
|
|
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 = {
|
|
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
|
-
: "
|
|
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(
|
|
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"
|
|
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(
|
|
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 {
|
|
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 === "
|
|
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
|
|
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
|
|
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
|
|
11305
|
-
|
|
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 =
|
|
11625
|
+
const existing = getStudioReplJournalEntries(normalizedSessionName).slice().reverse().find((entry) => (
|
|
11312
11626
|
(normalizedRequestId && entry.requestId === normalizedRequestId)
|
|
11313
|
-
|| (!normalizedRequestId &&
|
|
11627
|
+
|| (!normalizedRequestId && entry.status === "sent")
|
|
11314
11628
|
));
|
|
11315
11629
|
if (!existing) return;
|
|
11316
|
-
|
|
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
|
|
11639
|
+
function clearStudioReplJournal(sessionName: string): void {
|
|
11325
11640
|
const normalizedSessionName = String(sessionName || "").trim();
|
|
11326
|
-
|
|
11327
|
-
|
|
11328
|
-
|
|
11329
|
-
|
|
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
|
|
12538
|
-
|
|
12539
|
-
|
|
12540
|
-
|
|
12541
|
-
|
|
12542
|
-
|
|
12543
|
-
|
|
12544
|
-
|
|
12545
|
-
|
|
12546
|
-
|
|
12547
|
-
|
|
12548
|
-
|
|
12549
|
-
|
|
12550
|
-
|
|
12551
|
-
|
|
12552
|
-
|
|
12553
|
-
|
|
12554
|
-
|
|
12555
|
-
|
|
12556
|
-
|
|
12557
|
-
sessionName:
|
|
12558
|
-
|
|
12559
|
-
|
|
12560
|
-
|
|
12561
|
-
|
|
12562
|
-
|
|
12563
|
-
|
|
12564
|
-
|
|
12565
|
-
|
|
12566
|
-
|
|
12567
|
-
|
|
12568
|
-
|
|
12569
|
-
|
|
12570
|
-
|
|
12571
|
-
|
|
12572
|
-
|
|
12573
|
-
|
|
12574
|
-
|
|
12575
|
-
|
|
12576
|
-
|
|
12577
|
-
|
|
12578
|
-
|
|
12579
|
-
|
|
12580
|
-
|
|
12581
|
-
|
|
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
|
-
|
|
12585
|
-
|
|
12586
|
-
|
|
12587
|
-
|
|
12588
|
-
|
|
12589
|
-
|
|
12590
|
-
|
|
12591
|
-
|
|
12592
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
15503
|
-
|
|
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." });
|