pi-studio 0.9.63 → 0.10.1
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/BUFFER-DESIGN.md +197 -0
- package/CHANGELOG.md +31 -0
- package/PRERELEASE-CHECKLIST.md +100 -0
- package/README.md +110 -193
- package/ROADMAP.md +63 -6
- package/WORKFLOW.md +32 -2
- package/assets/screenshots/dark-workspace.png +0 -0
- package/assets/screenshots/light-workspace.png +0 -0
- package/assets/screenshots/theme-zen-code.png +0 -0
- package/client/studio-client.js +6694 -942
- package/client/studio-editor-draft-helpers.js +162 -0
- package/client/studio-navigation-helpers.js +31 -5
- package/client/studio.css +460 -12
- package/index.ts +1178 -79
- package/package.json +5 -3
- package/shared/STUDIO_BUFFER_FOUNDATION.md +164 -0
- package/shared/studio-buffer-client.js +663 -0
- package/shared/studio-buffer-decisions.js +129 -0
- package/shared/studio-buffer-lineage.js +48 -0
- package/shared/studio-buffer-recovery-panel.js +132 -0
- package/shared/studio-buffer-recovery.js +181 -0
- package/shared/studio-buffer-server.js +700 -0
- package/shared/studio-buffer-store.js +227 -0
- package/shared/studio-buffer-switching.js +53 -0
- package/shared/studio-disk-revisions.js +15 -0
- package/shared/studio-document-entrypoints.js +38 -0
- package/shared/studio-document-escrow.js +147 -0
- package/shared/studio-document-file-claims.js +215 -0
- package/shared/studio-document-host-client.js +268 -0
- package/shared/studio-document-hosting.js +319 -0
- package/shared/studio-document-launch-client.js +153 -0
- package/shared/studio-document-open-client.js +96 -0
- package/shared/studio-document-open-storage.js +21 -0
- package/shared/studio-document-paths.js +29 -0
- package/shared/studio-editor-mode.js +48 -0
- package/shared/studio-linked-reader-ui.js +232 -0
- package/shared/studio-linked-reader.js +110 -0
- package/shared/studio-metadata-write-order.js +35 -0
- package/shared/studio-read-only-media.js +24 -0
- package/shared/studio-resource-grants.js +3 -0
- package/shared/studio-run-lifecycle.js +47 -0
- package/shared/studio-system-theme.js +9 -0
- package/assets/screenshots/dark-code-mode.png +0 -0
- package/assets/screenshots/dark-critique.png +0 -0
- package/assets/screenshots/dark-fenced-code.png +0 -0
- package/assets/screenshots/dark-focus-mode.png +0 -0
- package/assets/screenshots/dark-julia-fenced.png +0 -0
- package/assets/screenshots/dark-math.png +0 -0
- package/assets/screenshots/dark-mermaid.png +0 -0
- package/assets/screenshots/dark-zen.png +0 -0
- package/assets/screenshots/light-code-mode.png +0 -0
- package/assets/screenshots/light-fenced-code.png +0 -0
- package/assets/screenshots/light-math.png +0 -0
package/index.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { type ModelThinkingLevel, type ThinkingLevel } from "@earendil-works/pi-
|
|
|
4
4
|
import { completeSimple } from "@earendil-works/pi-ai/compat";
|
|
5
5
|
import { Type } from "@sinclair/typebox";
|
|
6
6
|
import { spawn, spawnSync } from "node:child_process";
|
|
7
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
+
import { createHash, randomUUID, randomBytes } from "node:crypto";
|
|
8
8
|
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
10
10
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from "./shared/studio-annotation-scanner.js";
|
|
25
25
|
import { stripStudioMarkdownHtmlComments } from "./shared/studio-markdown-html-comments.js";
|
|
26
26
|
import { createStudioFileWatcher } from "./shared/studio-file-watcher.js";
|
|
27
|
+
import { createStudioRunLifecycle } from "./shared/studio-run-lifecycle.js";
|
|
27
28
|
import {
|
|
28
29
|
readStudioDiskFileSnapshot,
|
|
29
30
|
saveStudioDiskFileAs,
|
|
@@ -83,6 +84,13 @@ import {
|
|
|
83
84
|
isValidStudioLaunchId,
|
|
84
85
|
normalizeStudioPendingKind,
|
|
85
86
|
} from "./shared/studio-tab-launcher.js";
|
|
87
|
+
import { buildStudioHostingBootstrapState, createStudioBufferServerStore, STUDIO_BUFFER_REQUEST_MAX_BYTES } from "./shared/studio-buffer-server.js";
|
|
88
|
+
import { createStudioTurns, readStudioEditorMode, resolveStudioEditorFlags, writeStudioEditorMode } from "./shared/studio-editor-mode.js";
|
|
89
|
+
import { canonicalStudioDocumentPath, studioDocumentPathsReferToSameFile } from "./shared/studio-document-paths.js";
|
|
90
|
+
import { resolveStudioHostingPage, studioHostingCommandError, studioHostingLocalLinkError } from "./shared/studio-document-entrypoints.js";
|
|
91
|
+
import { systemThemeLacksTrueColor } from "./shared/studio-system-theme.js";
|
|
92
|
+
import { buildStudioReadOnlyMediaUrl, buildStudioReadOnlyImagePage } from "./shared/studio-read-only-media.js";
|
|
93
|
+
import { createStudioMetadataWriteOrderTracker } from "./shared/studio-metadata-write-order.js";
|
|
86
94
|
import {
|
|
87
95
|
createStudioWorkspaceStateStore,
|
|
88
96
|
isValidStudioTabStateId,
|
|
@@ -198,6 +206,37 @@ const STUDIO_SHOW_ME_HELPERS_URL = new URL("./client/studio-show-me-helpers.js",
|
|
|
198
206
|
const STUDIO_SIDE_QUESTION_HELPERS_URL = new URL("./client/studio-side-question-helpers.js", import.meta.url);
|
|
199
207
|
const STUDIO_EDITOR_DRAFT_HELPERS_URL = new URL("./client/studio-editor-draft-helpers.js", import.meta.url);
|
|
200
208
|
const STUDIO_CLIENT_URL = new URL("./client/studio-client.js", import.meta.url);
|
|
209
|
+
// Dual editor mode (Prompt and Document) unless this Pi profile chose single editor mode.
|
|
210
|
+
// The development flags, when set, still decide. Changes only while Studio's server is
|
|
211
|
+
// stopped, so one server lifetime sees one mode.
|
|
212
|
+
const STUDIO_EDITOR_MODE_PATH = join(getAgentDir(), "pi-studio", "editor-mode.json");
|
|
213
|
+
let STUDIO_EDITOR_FLAGS = resolveStudioEditorFlags(process.env, readStudioEditorMode(STUDIO_EDITOR_MODE_PATH));
|
|
214
|
+
let STUDIO_BUFFER_RECOVERY_ENABLED = STUDIO_EDITOR_FLAGS.recovery;
|
|
215
|
+
let STUDIO_BUFFER_SWITCHING_ENABLED = STUDIO_EDITOR_FLAGS.switching;
|
|
216
|
+
let STUDIO_DOCUMENT_HOSTING_ENABLED = STUDIO_EDITOR_FLAGS.hosting;
|
|
217
|
+
function applyStudioEditorMode(mode: "single" | "dual") {
|
|
218
|
+
STUDIO_EDITOR_FLAGS = resolveStudioEditorFlags(process.env, mode);
|
|
219
|
+
STUDIO_BUFFER_RECOVERY_ENABLED = STUDIO_EDITOR_FLAGS.recovery;
|
|
220
|
+
STUDIO_BUFFER_SWITCHING_ENABLED = STUDIO_EDITOR_FLAGS.switching;
|
|
221
|
+
STUDIO_DOCUMENT_HOSTING_ENABLED = STUDIO_EDITOR_FLAGS.hosting;
|
|
222
|
+
}
|
|
223
|
+
const STUDIO_BUFFER_MODULE_URLS = new Map([
|
|
224
|
+
["studio-buffer-store.js", new URL("./shared/studio-buffer-store.js", import.meta.url)],
|
|
225
|
+
["studio-buffer-recovery.js", new URL("./shared/studio-buffer-recovery.js", import.meta.url)],
|
|
226
|
+
["studio-buffer-client.js", new URL("./shared/studio-buffer-client.js", import.meta.url)],
|
|
227
|
+
["studio-buffer-lineage.js", new URL("./shared/studio-buffer-lineage.js", import.meta.url)],
|
|
228
|
+
["studio-document-escrow.js", new URL("./shared/studio-document-escrow.js", import.meta.url)],
|
|
229
|
+
["studio-document-host-client.js", new URL("./shared/studio-document-host-client.js", import.meta.url)],
|
|
230
|
+
["studio-document-launch-client.js", new URL("./shared/studio-document-launch-client.js", import.meta.url)],
|
|
231
|
+
["studio-document-open-client.js", new URL("./shared/studio-document-open-client.js", import.meta.url)],
|
|
232
|
+
["studio-document-open-storage.js", new URL("./shared/studio-document-open-storage.js", import.meta.url)],
|
|
233
|
+
["studio-linked-reader.js", new URL("./shared/studio-linked-reader.js", import.meta.url)],
|
|
234
|
+
["studio-linked-reader-ui.js", new URL("./shared/studio-linked-reader-ui.js", import.meta.url)],
|
|
235
|
+
["studio-document-hosting.js", new URL("./shared/studio-document-hosting.js", import.meta.url)],
|
|
236
|
+
["studio-buffer-switching.js", new URL("./shared/studio-buffer-switching.js", import.meta.url)],
|
|
237
|
+
["studio-buffer-decisions.js", new URL("./shared/studio-buffer-decisions.js", import.meta.url)],
|
|
238
|
+
["studio-buffer-recovery-panel.js", new URL("./shared/studio-buffer-recovery-panel.js", import.meta.url)],
|
|
239
|
+
]);
|
|
201
240
|
|
|
202
241
|
interface StudioServerState {
|
|
203
242
|
server: Server;
|
|
@@ -256,6 +295,7 @@ interface StudioTraceSnapshotSummary {
|
|
|
256
295
|
|
|
257
296
|
interface StudioResponseHistoryItem extends StudioPromptDescriptor {
|
|
258
297
|
id: string;
|
|
298
|
+
studioRunId?: string | null;
|
|
259
299
|
markdown: string;
|
|
260
300
|
thinking: string | null;
|
|
261
301
|
timestamp: number;
|
|
@@ -339,6 +379,7 @@ interface PreparedStudioHtmlExport {
|
|
|
339
379
|
filePath?: string;
|
|
340
380
|
tempDirPath?: string;
|
|
341
381
|
persistent?: boolean;
|
|
382
|
+
documentExport?: { workspaceId: string; bufferId: string; documentEpoch: number; expectedRevision: string; resourceDir: string; diskRevision: string | null };
|
|
342
383
|
}
|
|
343
384
|
|
|
344
385
|
interface StudioHtmlAnnotationPlaceholder {
|
|
@@ -401,6 +442,7 @@ interface StudioLaunchSelection {
|
|
|
401
442
|
interface StudioUrlOptions {
|
|
402
443
|
skipWorkspaceRestore?: boolean;
|
|
403
444
|
paneFocus?: "left" | "right";
|
|
445
|
+
hostedWorkspaceId?: string;
|
|
404
446
|
}
|
|
405
447
|
|
|
406
448
|
type PersistedStudioReviewNoteAnchorKind = "source" | "html-selection" | "html-element" | "html-page";
|
|
@@ -435,7 +477,8 @@ interface StudioPersistentState {
|
|
|
435
477
|
reviewNotesByDocument: Record<string, PersistedStudioReviewNote[]>;
|
|
436
478
|
}
|
|
437
479
|
|
|
438
|
-
type StudioTraceRunStatus = "idle" | "running" | "complete";
|
|
480
|
+
type StudioTraceRunStatus = "idle" | "running" | "complete" | "stopped" | "error";
|
|
481
|
+
type StudioRunOutcome = "completed" | "stopped" | "failed";
|
|
439
482
|
type StudioTraceEntryStatus = "streaming" | "pending" | "complete" | "error";
|
|
440
483
|
|
|
441
484
|
interface StudioTraceAssistantEntry {
|
|
@@ -901,6 +944,7 @@ interface OpenEditorOnlyRequestMessage {
|
|
|
901
944
|
interface CancelRequestMessage {
|
|
902
945
|
type: "cancel_request";
|
|
903
946
|
requestId: string;
|
|
947
|
+
runId?: string;
|
|
904
948
|
}
|
|
905
949
|
|
|
906
950
|
interface WorkspaceStateUpdateMessage {
|
|
@@ -909,6 +953,20 @@ interface WorkspaceStateUpdateMessage {
|
|
|
909
953
|
state: ReturnType<typeof normalizeStudioWorkspaceRecoveryState>;
|
|
910
954
|
}
|
|
911
955
|
|
|
956
|
+
interface HostingCleanLeaveMessage {
|
|
957
|
+
type: "hosting_clean_leave";
|
|
958
|
+
generation: number;
|
|
959
|
+
revision: string;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
interface EditorModeSwitchRequestMessage {
|
|
963
|
+
type: "editor_mode_switch_request";
|
|
964
|
+
requestId: string;
|
|
965
|
+
mode: "single" | "dual";
|
|
966
|
+
text: string;
|
|
967
|
+
sideQuestions: boolean;
|
|
968
|
+
}
|
|
969
|
+
|
|
912
970
|
type IncomingStudioMessage =
|
|
913
971
|
| HelloMessage
|
|
914
972
|
| PingMessage
|
|
@@ -955,10 +1013,13 @@ type IncomingStudioMessage =
|
|
|
955
1013
|
| GitChangesRequestMessage
|
|
956
1014
|
| OpenEditorOnlyRequestMessage
|
|
957
1015
|
| CancelRequestMessage
|
|
958
|
-
| WorkspaceStateUpdateMessage
|
|
1016
|
+
| WorkspaceStateUpdateMessage
|
|
1017
|
+
| HostingCleanLeaveMessage
|
|
1018
|
+
| EditorModeSwitchRequestMessage;
|
|
959
1019
|
|
|
960
1020
|
const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
|
|
961
1021
|
const PREVIEW_RENDER_MAX_CHARS = 400_000;
|
|
1022
|
+
const STUDIO_EDITOR_MODE_CARRY_MAX_CHARS = 4_000_000;
|
|
962
1023
|
const STUDIO_WATCHED_FILE_MAX_CLIENTS = 16;
|
|
963
1024
|
const STUDIO_COMPLETION_MAX_TEXT_CHARS = 250_000;
|
|
964
1025
|
const STUDIO_COMPLETION_MAX_CONTEXT_CHARS = 12_000;
|
|
@@ -1301,7 +1362,12 @@ $body$
|
|
|
1301
1362
|
`;
|
|
1302
1363
|
|
|
1303
1364
|
let studioPersistentStateCache: StudioPersistentState | null = null;
|
|
1365
|
+
let studioPersistentStateRevision: string | null = null;
|
|
1366
|
+
let studioPersistentStateReadFailed = false;
|
|
1304
1367
|
let studioPersistentStateQueue: Promise<void> = Promise.resolve();
|
|
1368
|
+
const studioMetadataWriteOrder = createStudioMetadataWriteOrderTracker();
|
|
1369
|
+
type StudioMetadataWriteOwnership = { writerId: string; writeVersion: number };
|
|
1370
|
+
type StudioMetadataWriteOrderClaim = { commit(): void };
|
|
1305
1371
|
let transientStudioDocuments: Map<string, { document: InitialStudioDocument; createdAt: number }> = new Map();
|
|
1306
1372
|
let studioReplJournalEntries: StudioReplJournalEntry[] = [];
|
|
1307
1373
|
const studioReplUnsyncedJournalEntryIds = new Set<string>();
|
|
@@ -1410,14 +1476,31 @@ function normalizeStudioPersistentState(value: unknown): StudioPersistentState {
|
|
|
1410
1476
|
};
|
|
1411
1477
|
}
|
|
1412
1478
|
|
|
1479
|
+
// Adding default fields is harmless; dropping unknown fields, records, or
|
|
1480
|
+
// changing supplied values is not an authorized metadata transfer/migration.
|
|
1481
|
+
function studioMetadataNormalizationPreserves(value: unknown, normalized: unknown): boolean {
|
|
1482
|
+
if (Array.isArray(value)) return Array.isArray(normalized) && value.length === normalized.length
|
|
1483
|
+
&& value.every((item, index) => studioMetadataNormalizationPreserves(item, normalized[index]));
|
|
1484
|
+
if (value && typeof value === "object") return Boolean(normalized && typeof normalized === "object" && !Array.isArray(normalized)
|
|
1485
|
+
&& Object.entries(value).every(([key, item]) => Object.prototype.hasOwnProperty.call(normalized, key)
|
|
1486
|
+
&& studioMetadataNormalizationPreserves(item, (normalized as Record<string, unknown>)[key])));
|
|
1487
|
+
return Object.is(value, normalized);
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1413
1490
|
async function loadStudioPersistentState(): Promise<StudioPersistentState> {
|
|
1414
1491
|
if (studioPersistentStateCache) return studioPersistentStateCache;
|
|
1415
1492
|
try {
|
|
1416
1493
|
const raw = await readFile(STUDIO_PERSISTENT_STATE_PATH, "utf-8");
|
|
1417
|
-
|
|
1494
|
+
const parsed: unknown = JSON.parse(raw);
|
|
1495
|
+
studioPersistentStateCache = normalizeStudioPersistentState(parsed);
|
|
1496
|
+
if (!parsed || typeof parsed !== "object" || (parsed as { version?: unknown }).version !== 2
|
|
1497
|
+
|| !studioMetadataNormalizationPreserves(parsed, studioPersistentStateCache)) studioPersistentStateReadFailed = true;
|
|
1498
|
+
studioPersistentStateRevision = "sha256:" + createHash("sha256").update(raw).digest("hex");
|
|
1418
1499
|
} catch (error) {
|
|
1419
1500
|
if (!(error && typeof error === "object" && "code" in error && (error as { code?: unknown }).code === "ENOENT")) {
|
|
1420
|
-
//
|
|
1501
|
+
// Legacy callers retain their fallback; hosted transfer must not overwrite
|
|
1502
|
+
// an unreadable blob using that empty fallback as authoritative metadata.
|
|
1503
|
+
studioPersistentStateReadFailed = true;
|
|
1421
1504
|
}
|
|
1422
1505
|
studioPersistentStateCache = createEmptyStudioPersistentState();
|
|
1423
1506
|
}
|
|
@@ -1426,15 +1509,63 @@ async function loadStudioPersistentState(): Promise<StudioPersistentState> {
|
|
|
1426
1509
|
|
|
1427
1510
|
async function saveStudioPersistentState(state: StudioPersistentState): Promise<void> {
|
|
1428
1511
|
await mkdir(STUDIO_PERSISTENT_STATE_DIR, { recursive: true });
|
|
1429
|
-
|
|
1512
|
+
const content = `${JSON.stringify(state, null, 2)}\n`;
|
|
1513
|
+
await writeFile(STUDIO_PERSISTENT_STATE_PATH, content, "utf-8");
|
|
1514
|
+
studioPersistentStateRevision = "sha256:" + createHash("sha256").update(content).digest("hex");
|
|
1515
|
+
studioPersistentStateCache = state;
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
async function saveStudioTransferredMetadataAtomically(state: StudioPersistentState): Promise<void> {
|
|
1519
|
+
if (studioPersistentStateReadFailed) throw new Error("Existing metadata could not be read; transfer refused.");
|
|
1520
|
+
await mkdir(STUDIO_PERSISTENT_STATE_DIR, { recursive: true });
|
|
1521
|
+
const content = `${JSON.stringify(state, null, 2)}\n`;
|
|
1522
|
+
const saved = saveStudioDiskFileAs({ path: STUDIO_PERSISTENT_STATE_PATH, content, overwrite: true, expectedRevision: studioPersistentStateRevision });
|
|
1523
|
+
if (!saved.ok) throw new Error(saved.message || "Metadata changed or could not be atomically saved.");
|
|
1524
|
+
studioPersistentStateRevision = "sha256:" + createHash("sha256").update(content).digest("hex");
|
|
1430
1525
|
studioPersistentStateCache = state;
|
|
1431
1526
|
}
|
|
1432
1527
|
|
|
1433
|
-
|
|
1528
|
+
type StudioSavedMetadataStep = { persist?: boolean; result: { ok: boolean; [key: string]: unknown }; committed?: () => void };
|
|
1529
|
+
async function runStudioSavedMetadataTransaction(resolveMetadata: (state: StudioPersistentState) => StudioSavedMetadataStep) {
|
|
1530
|
+
let step: StudioSavedMetadataStep | undefined;
|
|
1531
|
+
await mutateStudioPersistentState(state => {
|
|
1532
|
+
if (studioPersistentStateReadFailed) throw new Error("Existing metadata could not be read; transfer refused.");
|
|
1533
|
+
let diskRevision: string | null = null;
|
|
1534
|
+
try { diskRevision = readStudioDiskFileSnapshot(STUDIO_PERSISTENT_STATE_PATH).revision; }
|
|
1535
|
+
catch (error) { if (!(error && typeof error === "object" && "code" in error && error.code === "ENOENT")) throw error; }
|
|
1536
|
+
if (diskRevision !== studioPersistentStateRevision) throw new Error("Metadata changed on disk; keep both copies and recheck before transfer.");
|
|
1537
|
+
step = resolveMetadata(state);
|
|
1538
|
+
if (!step.persist) { step.committed?.(); return false; }
|
|
1539
|
+
return step.committed;
|
|
1540
|
+
}, saveStudioTransferredMetadataAtomically);
|
|
1541
|
+
if (!step) throw new Error("Metadata transaction was not evaluated.");
|
|
1542
|
+
return step.result;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
function parseStudioMetadataWriteOwnership(value: unknown): { ok: true; ownership: StudioMetadataWriteOwnership | null } | { ok: false } {
|
|
1546
|
+
if (!value || typeof value !== "object") return { ok: true, ownership: null };
|
|
1547
|
+
const candidate = value as { metadataWriterId?: unknown; metadataWriteVersion?: unknown };
|
|
1548
|
+
const hasWriter = Object.prototype.hasOwnProperty.call(candidate, "metadataWriterId");
|
|
1549
|
+
const hasVersion = Object.prototype.hasOwnProperty.call(candidate, "metadataWriteVersion");
|
|
1550
|
+
if (!hasWriter && !hasVersion) return { ok: true, ownership: null };
|
|
1551
|
+
if (!hasWriter || !hasVersion || typeof candidate.metadataWriterId !== "string"
|
|
1552
|
+
|| !isValidStudioTabStateId(candidate.metadataWriterId)
|
|
1553
|
+
|| !Number.isSafeInteger(candidate.metadataWriteVersion)
|
|
1554
|
+
|| Number(candidate.metadataWriteVersion) <= 0) return { ok: false };
|
|
1555
|
+
return { ok: true, ownership: { writerId: candidate.metadataWriterId, writeVersion: Number(candidate.metadataWriteVersion) } };
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
function prepareStudioMetadataWrite(kind: "scratchpad" | "review-notes", documentKey: string, ownership: StudioMetadataWriteOwnership | null): StudioMetadataWriteOrderClaim | null {
|
|
1559
|
+
return studioMetadataWriteOrder.prepare(kind, documentKey, ownership);
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
async function mutateStudioPersistentState(mutator: (state: StudioPersistentState) => void | false | (() => void), persist = saveStudioPersistentState): Promise<void> {
|
|
1434
1563
|
const run = studioPersistentStateQueue.catch(() => undefined).then(async () => {
|
|
1435
1564
|
const state = normalizeStudioPersistentState(await loadStudioPersistentState());
|
|
1436
|
-
mutator(state);
|
|
1437
|
-
|
|
1565
|
+
const onPersisted = mutator(state);
|
|
1566
|
+
if (onPersisted === false) return;
|
|
1567
|
+
await persist(state);
|
|
1568
|
+
if (typeof onPersisted === "function") onPersisted();
|
|
1438
1569
|
});
|
|
1439
1570
|
studioPersistentStateQueue = run.then(() => undefined, () => undefined);
|
|
1440
1571
|
await run;
|
|
@@ -1476,15 +1607,19 @@ async function listRecentPersistedStudioScratchpads(limit = 20): Promise<Array<{
|
|
|
1476
1607
|
.slice(0, Math.max(1, Math.min(100, Math.floor(limit) || 20)));
|
|
1477
1608
|
}
|
|
1478
1609
|
|
|
1479
|
-
async function writePersistedStudioScratchpadText(documentKey: string, text: string, label?: string): Promise<void> {
|
|
1610
|
+
async function writePersistedStudioScratchpadText(documentKey: string, text: string, label?: string, ownership: StudioMetadataWriteOwnership | null = null, authorize?: () => void): Promise<void> {
|
|
1480
1611
|
const key = String(documentKey ?? "").trim();
|
|
1481
1612
|
if (!key) return;
|
|
1482
1613
|
await mutateStudioPersistentState((state) => {
|
|
1614
|
+
authorize?.();
|
|
1615
|
+
if (authorize && studioPersistentStateReadFailed) throw new Error("Existing metadata could not be read; keep the unsynced browser copy.");
|
|
1616
|
+
const orderClaim = prepareStudioMetadataWrite("scratchpad", key, ownership);
|
|
1617
|
+
if (!orderClaim) return false;
|
|
1483
1618
|
const normalized = String(text ?? "");
|
|
1484
1619
|
if (normalized.length === 0) {
|
|
1485
1620
|
delete state.scratchpadsByDocument[key];
|
|
1486
1621
|
delete state.scratchpadMetadataByDocument[key];
|
|
1487
|
-
return;
|
|
1622
|
+
return () => orderClaim.commit();
|
|
1488
1623
|
}
|
|
1489
1624
|
state.scratchpadsByDocument[key] = normalized;
|
|
1490
1625
|
state.scratchpadMetadataByDocument[key] = {
|
|
@@ -1492,6 +1627,7 @@ async function writePersistedStudioScratchpadText(documentKey: string, text: str
|
|
|
1492
1627
|
label: typeof label === "string" && label.trim() ? label.trim() : state.scratchpadMetadataByDocument[key]?.label,
|
|
1493
1628
|
updatedAt: Date.now(),
|
|
1494
1629
|
};
|
|
1630
|
+
return () => orderClaim.commit();
|
|
1495
1631
|
});
|
|
1496
1632
|
}
|
|
1497
1633
|
|
|
@@ -1507,7 +1643,7 @@ async function readPersistedStudioReviewNotes(documentKey: string): Promise<Pers
|
|
|
1507
1643
|
return Array.isArray(notes) ? clonePersistedStudioReviewNotes(notes) : [];
|
|
1508
1644
|
}
|
|
1509
1645
|
|
|
1510
|
-
async function writePersistedStudioReviewNotes(documentKey: string, notes: PersistedStudioReviewNote[]): Promise<void> {
|
|
1646
|
+
async function writePersistedStudioReviewNotes(documentKey: string, notes: PersistedStudioReviewNote[], ownership: StudioMetadataWriteOwnership | null = null, authorize?: () => void): Promise<void> {
|
|
1511
1647
|
const key = String(documentKey ?? "").trim();
|
|
1512
1648
|
if (!key) return;
|
|
1513
1649
|
const normalizedNotes = Array.isArray(notes)
|
|
@@ -1516,11 +1652,16 @@ async function writePersistedStudioReviewNotes(documentKey: string, notes: Persi
|
|
|
1516
1652
|
.filter((note): note is PersistedStudioReviewNote => Boolean(note))
|
|
1517
1653
|
: [];
|
|
1518
1654
|
await mutateStudioPersistentState((state) => {
|
|
1655
|
+
authorize?.();
|
|
1656
|
+
if (authorize && studioPersistentStateReadFailed) throw new Error("Existing metadata could not be read; keep the unsynced browser copy.");
|
|
1657
|
+
const orderClaim = prepareStudioMetadataWrite("review-notes", key, ownership);
|
|
1658
|
+
if (!orderClaim) return false;
|
|
1519
1659
|
if (normalizedNotes.length === 0) {
|
|
1520
1660
|
delete state.reviewNotesByDocument[key];
|
|
1521
|
-
return;
|
|
1661
|
+
return () => orderClaim.commit();
|
|
1522
1662
|
}
|
|
1523
1663
|
state.reviewNotesByDocument[key] = clonePersistedStudioReviewNotes(normalizedNotes);
|
|
1664
|
+
return () => orderClaim.commit();
|
|
1524
1665
|
});
|
|
1525
1666
|
}
|
|
1526
1667
|
|
|
@@ -2362,7 +2503,7 @@ function getStudioThemeStyle(theme?: Theme): StudioThemeStyle {
|
|
|
2362
2503
|
const mode = getStudioThemeMode(theme);
|
|
2363
2504
|
const fallback = mode === "light" ? LIGHT_STUDIO_PALETTE : DARK_STUDIO_PALETTE;
|
|
2364
2505
|
|
|
2365
|
-
if (!theme) {
|
|
2506
|
+
if (!theme || systemThemeLacksTrueColor(theme)) {
|
|
2366
2507
|
return {
|
|
2367
2508
|
mode,
|
|
2368
2509
|
palette: fallback,
|
|
@@ -2999,11 +3140,69 @@ function writeStudioPreviewExportFile(path: string | null, data: Buffer): { file
|
|
|
2999
3140
|
}
|
|
3000
3141
|
}
|
|
3001
3142
|
|
|
3002
|
-
|
|
3143
|
+
// Classic HTML still replaces unowned files, but in hosting mode it uses the
|
|
3144
|
+
// existing canonical/revision-checked atomic writer and a final owner/claim fence.
|
|
3145
|
+
// This helper is never used by flags-off writers or by PDF export.
|
|
3146
|
+
function writeStudioClassicHtmlExportFile(
|
|
3147
|
+
path: string | null, data: Buffer, cwd: string,
|
|
3148
|
+
authorizeCommit: ((path: string) => { ok: boolean; message?: string }) | undefined,
|
|
3149
|
+
createParents = false,
|
|
3150
|
+
): { filePath: string | null; error: string | null } {
|
|
3151
|
+
if (!path || path !== path.trim() || typeof authorizeCommit !== "function") {
|
|
3152
|
+
return { filePath: null, error: "An exact HTML target and synchronous Document ownership check are required." };
|
|
3153
|
+
}
|
|
3154
|
+
try {
|
|
3155
|
+
const requestedPath = resolve(cwd, path);
|
|
3156
|
+
const check = (canonical: string) => {
|
|
3157
|
+
const result = authorizeCommit(canonical);
|
|
3158
|
+
return result?.ok === true ? { ok: true } : { ok: false, message: result?.message || "The HTML export target's Document ownership could not be verified; nothing was written." };
|
|
3159
|
+
};
|
|
3160
|
+
const initial = check(requestedPath);
|
|
3161
|
+
if (!initial.ok) return { filePath: null, error: initial.message || "Document ownership could not be verified." };
|
|
3162
|
+
if (createParents) mkdirSync(dirname(requestedPath), { recursive: true });
|
|
3163
|
+
let expectedRevision: string | undefined;
|
|
3164
|
+
try { expectedRevision = readStudioDiskFileSnapshot(requestedPath).revision; }
|
|
3165
|
+
catch (error) { if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") throw error; }
|
|
3166
|
+
const result = saveStudioDiskFileAs({ path: requestedPath, cwd, content: data, overwrite: true, expectedRevision, authorizeCommit: check });
|
|
3167
|
+
return result.ok === true ? { filePath: result.path, error: null }
|
|
3168
|
+
: { filePath: null, error: result.message || "HTML project writing was refused; existing work was kept." };
|
|
3169
|
+
} catch (error) {
|
|
3170
|
+
return { filePath: null, error: error instanceof Error ? error.message : String(error) };
|
|
3171
|
+
}
|
|
3172
|
+
}
|
|
3173
|
+
|
|
3174
|
+
// Hosted HTML-to-Document exports must never overwrite an existing target.
|
|
3175
|
+
// The registered bridge supplies a synchronous final owner/claim check; absent
|
|
3176
|
+
// or ambiguous authority keeps the generated bytes unsaved instead of guessing.
|
|
3177
|
+
function writeStudioHostedHtmlDocumentExport(
|
|
3178
|
+
path: string | null,
|
|
3179
|
+
data: Buffer,
|
|
3180
|
+
cwd: string,
|
|
3181
|
+
authorizeCommit: (path: string) => { ok: boolean; message?: string },
|
|
3182
|
+
): { filePath: string | null; diskRevision: string | null; error: string | null } {
|
|
3183
|
+
if (!path || typeof authorizeCommit !== "function") {
|
|
3184
|
+
return { filePath: null, diskRevision: null, error: "A resolved export target and current Document authority are required." };
|
|
3185
|
+
}
|
|
3186
|
+
try {
|
|
3187
|
+
const result = saveStudioDiskFileAs({
|
|
3188
|
+
path, cwd, content: data, overwrite: false,
|
|
3189
|
+
authorizeCommit: (canonical: string) => {
|
|
3190
|
+
const authority = authorizeCommit(canonical);
|
|
3191
|
+
return authority?.ok === true ? { ok: true } : { ok: false, message: authority?.message || "The export target's Document authority could not be verified." };
|
|
3192
|
+
},
|
|
3193
|
+
});
|
|
3194
|
+
if (result.ok === false) return { filePath: null, diskRevision: null, error: result.message || "Project writing failed; generated HTML was kept." };
|
|
3195
|
+
return { filePath: result.path, diskRevision: result.revision, error: null };
|
|
3196
|
+
} catch (error) {
|
|
3197
|
+
return { filePath: null, diskRevision: null, error: error instanceof Error ? error.message : String(error) };
|
|
3198
|
+
}
|
|
3199
|
+
}
|
|
3200
|
+
|
|
3201
|
+
function writeStudioFile(pathArg: string, cwd: string, content: string, overwrite = false, expectedRevision?: string, authorizeCommit?: (path: string) => { ok: boolean; message?: string }):
|
|
3003
3202
|
| { ok: true; label: string; resolvedPath: string; diskRevision: string }
|
|
3004
3203
|
| { ok: false; conflict?: boolean; reason?: string; path?: string; currentRevision?: string | null; message: string } {
|
|
3005
3204
|
const normalizedLabel = normalizePathInput(pathArg);
|
|
3006
|
-
const result = saveStudioDiskFileAs({ path: pathArg, cwd, content, overwrite, expectedRevision }) as StudioDiskWriteResult;
|
|
3205
|
+
const result = saveStudioDiskFileAs({ path: pathArg, cwd, content, overwrite, expectedRevision, authorizeCommit }) as StudioDiskWriteResult;
|
|
3007
3206
|
if (result.ok === false) return result;
|
|
3008
3207
|
return {
|
|
3009
3208
|
ok: true,
|
|
@@ -3243,6 +3442,7 @@ const STUDIO_HTML_PREVIEW_MEDIA_MIME_BY_EXT = new Map<string, string>([
|
|
|
3243
3442
|
[".jpeg", "image/jpeg"],
|
|
3244
3443
|
[".gif", "image/gif"],
|
|
3245
3444
|
[".webp", "image/webp"],
|
|
3445
|
+
[".svg", "image/svg+xml"],
|
|
3246
3446
|
[".pdf", "application/pdf"],
|
|
3247
3447
|
]);
|
|
3248
3448
|
const STUDIO_LOCAL_LINK_TEXT_EXTENSIONS = new Set([
|
|
@@ -3253,7 +3453,7 @@ const STUDIO_LOCAL_LINK_TEXT_EXTENSIONS = new Set([
|
|
|
3253
3453
|
".jl", ".f90", ".f95", ".f03", ".f", ".for", ".r", ".m", ".java", ".go", ".rb", ".swift", ".lua",
|
|
3254
3454
|
".diff", ".patch",
|
|
3255
3455
|
]);
|
|
3256
|
-
const STUDIO_LOCAL_LINK_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
|
|
3456
|
+
const STUDIO_LOCAL_LINK_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]);
|
|
3257
3457
|
const STUDIO_LOCAL_LINK_OFFICE_EXTENSIONS = new Set([".docx", ".odt"]);
|
|
3258
3458
|
const STUDIO_LOCAL_LINK_TEXT_FILENAMES = new Set([
|
|
3259
3459
|
".dockerignore", ".editorconfig", ".env", ".env.example", ".eslintignore", ".gitattributes",
|
|
@@ -7972,6 +8172,7 @@ function respondPdfFile(req: IncomingMessage, res: ServerResponse, filePath: str
|
|
|
7972
8172
|
const commonHeaders = {
|
|
7973
8173
|
"Content-Type": "application/pdf",
|
|
7974
8174
|
"Content-Disposition": `inline; filename="${basename(filePath).replace(/["\\]/g, "") || "document.pdf"}"`,
|
|
8175
|
+
"Referrer-Policy": "no-referrer",
|
|
7975
8176
|
"Cache-Control": "no-store",
|
|
7976
8177
|
"X-Content-Type-Options": "nosniff",
|
|
7977
8178
|
"Cross-Origin-Resource-Policy": "same-origin",
|
|
@@ -8006,6 +8207,20 @@ function respondHtmlPreviewResourceJson(req: IncomingMessage, res: ServerRespons
|
|
|
8006
8207
|
});
|
|
8007
8208
|
}
|
|
8008
8209
|
|
|
8210
|
+
function respondStudioReadOnlyImage(req: IncomingMessage, res: ServerResponse, filePath: string, mimeType: string): void {
|
|
8211
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
8212
|
+
if (method !== "GET" && method !== "HEAD") { res.setHeader("Allow", "GET, HEAD"); respondText(res, 405, "Use GET or HEAD."); return; }
|
|
8213
|
+
const nonce = randomBytes(24).toString("hex");
|
|
8214
|
+
const headers = { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff",
|
|
8215
|
+
"Referrer-Policy": "no-referrer", "Cross-Origin-Resource-Policy": "same-origin",
|
|
8216
|
+
"Content-Security-Policy": `default-src 'none'; img-src data:; style-src 'nonce-${nonce}'; script-src 'nonce-${nonce}'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; sandbox allow-scripts` };
|
|
8217
|
+
if (method === "HEAD") { res.writeHead(200, headers); res.end(); return; }
|
|
8218
|
+
// Same bounded authorized image transport as the in-Studio media resolver;
|
|
8219
|
+
// SVG is only an img source, never markup in this trusted page.
|
|
8220
|
+
const html = buildStudioReadOnlyImagePage({ label: basename(filePath), dataUrl: `data:${mimeType};base64,${readFileSync(filePath).toString("base64")}`, nonce });
|
|
8221
|
+
res.writeHead(200, headers); res.end(html);
|
|
8222
|
+
}
|
|
8223
|
+
|
|
8009
8224
|
function formatStudioMarkdownAngleTarget(pathText: string): string {
|
|
8010
8225
|
return `<${String(pathText || "").replace(/>/g, "%3E")}>`;
|
|
8011
8226
|
}
|
|
@@ -8091,6 +8306,8 @@ async function respondLocalPreviewLinkJson(
|
|
|
8091
8306
|
}
|
|
8092
8307
|
|
|
8093
8308
|
const action = (requestUrl.searchParams.get("action") ?? "resolve").trim().toLowerCase();
|
|
8309
|
+
const hostingError = studioHostingLocalLinkError({ enabled: STUDIO_DOCUMENT_HOSTING_ENABLED, action, kind: resource.kind });
|
|
8310
|
+
if (hostingError) { respondJson(res, 409, { ok: false, error: hostingError }); return; }
|
|
8094
8311
|
const originDocId = (requestUrl.searchParams.get("docId") ?? "").trim();
|
|
8095
8312
|
const originDocument = originDocId ? readTransientStudioDocument(originDocId) : null;
|
|
8096
8313
|
const originatesFromWatchedPreview = requestUrl.searchParams.get("watchedFile") === "1" || originDocument?.watchFile === true;
|
|
@@ -8113,6 +8330,36 @@ async function respondLocalPreviewLinkJson(
|
|
|
8113
8330
|
return;
|
|
8114
8331
|
}
|
|
8115
8332
|
|
|
8333
|
+
if (action === "reader") {
|
|
8334
|
+
if (resource.kind !== "text") {
|
|
8335
|
+
respondJson(res, 400, { ok: false, error: "The linked reader accepts readable text files." });
|
|
8336
|
+
return;
|
|
8337
|
+
}
|
|
8338
|
+
const file = readStudioFile(resource.filePath, dirname(resource.filePath));
|
|
8339
|
+
if (file.ok === false) {
|
|
8340
|
+
respondJson(res, 400, { ok: false, error: file.message });
|
|
8341
|
+
return;
|
|
8342
|
+
}
|
|
8343
|
+
if (file.resolvedPath !== resource.filePath) {
|
|
8344
|
+
respondJson(res, 409, { ok: false, error: "The file identity changed while preparing the reading snapshot. Open its link again." });
|
|
8345
|
+
return;
|
|
8346
|
+
}
|
|
8347
|
+
if (file.text.length > PREVIEW_RENDER_MAX_CHARS) {
|
|
8348
|
+
respondJson(res, 413, { ok: false, error: `Reader source exceeds ${PREVIEW_RENDER_MAX_CHARS.toLocaleString()} characters.` });
|
|
8349
|
+
return;
|
|
8350
|
+
}
|
|
8351
|
+
// Resolution already checked the session's file/folder authority. Reading
|
|
8352
|
+
// this snapshot must not register an editor or grant its parent folder.
|
|
8353
|
+
respondJson(res, 200, { ...basePayload, text: file.text, readOnly: true });
|
|
8354
|
+
return;
|
|
8355
|
+
}
|
|
8356
|
+
|
|
8357
|
+
if (action === "viewer-url") {
|
|
8358
|
+
if (resource.kind !== "pdf" && resource.kind !== "image") { respondJson(res, 400, { ok: false, error: "Only PDF/images have a separate read-only viewer." }); return; }
|
|
8359
|
+
respondJson(res, 200, { ...basePayload, readOnly: true, relativeUrl: buildStudioReadOnlyMediaUrl({ token: serverState.token, kind: resource.kind, path: resource.filePath, resourceDir: resource.resourceDir, page: resource.page }) });
|
|
8360
|
+
return;
|
|
8361
|
+
}
|
|
8362
|
+
|
|
8116
8363
|
if (action === "preview-url") {
|
|
8117
8364
|
if (resource.kind !== "pdf" && resource.kind !== "image") {
|
|
8118
8365
|
respondJson(res, 400, { ok: false, error: "This local resource cannot be opened in a Studio preview tab." });
|
|
@@ -9803,7 +10050,7 @@ function parseStudioReplJournalEntryInput(value: unknown): Partial<StudioReplJou
|
|
|
9803
10050
|
};
|
|
9804
10051
|
}
|
|
9805
10052
|
|
|
9806
|
-
function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
|
|
10053
|
+
function parseIncomingMessage(data: RawData, captureEnvelope?: (message: Record<string, unknown>) => void): IncomingStudioMessage | null {
|
|
9807
10054
|
let parsed: unknown;
|
|
9808
10055
|
try {
|
|
9809
10056
|
parsed = JSON.parse(rawDataToString(data));
|
|
@@ -9813,6 +10060,7 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
|
|
|
9813
10060
|
|
|
9814
10061
|
if (!parsed || typeof parsed !== "object") return null;
|
|
9815
10062
|
const msg = parsed as Record<string, unknown>;
|
|
10063
|
+
captureEnvelope?.(msg);
|
|
9816
10064
|
|
|
9817
10065
|
if (msg.type === "hello") return { type: "hello" };
|
|
9818
10066
|
if (msg.type === "ping") return { type: "ping" };
|
|
@@ -9820,6 +10068,12 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
|
|
|
9820
10068
|
const state = normalizeStudioWorkspaceRecoveryState(msg.state);
|
|
9821
10069
|
if (state) return { type: "workspace_state_update", tabStateId: msg.tabStateId, state };
|
|
9822
10070
|
}
|
|
10071
|
+
if (msg.type === "hosting_clean_leave" && Number.isSafeInteger(msg.generation) && typeof msg.revision === "string" && /^[a-f0-9]{48}$/.test(msg.revision)) {
|
|
10072
|
+
return { type: "hosting_clean_leave", generation: msg.generation as number, revision: msg.revision };
|
|
10073
|
+
}
|
|
10074
|
+
if (msg.type === "editor_mode_switch_request" && typeof msg.requestId === "string" && (msg.mode === "single" || msg.mode === "dual") && typeof msg.text === "string") {
|
|
10075
|
+
return { type: "editor_mode_switch_request", requestId: msg.requestId, mode: msg.mode, text: msg.text, sideQuestions: msg.sideQuestions === true };
|
|
10076
|
+
}
|
|
9823
10077
|
if (msg.type === "get_latest_response") return { type: "get_latest_response" };
|
|
9824
10078
|
if (msg.type === "get_trace_snapshot" && typeof msg.responseHistoryId === "string") {
|
|
9825
10079
|
return {
|
|
@@ -10303,9 +10557,11 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
|
|
|
10303
10557
|
}
|
|
10304
10558
|
|
|
10305
10559
|
if (msg.type === "cancel_request" && typeof msg.requestId === "string") {
|
|
10560
|
+
if (msg.runId !== undefined && (typeof msg.runId !== "string" || !msg.runId)) return null;
|
|
10306
10561
|
return {
|
|
10307
10562
|
type: "cancel_request",
|
|
10308
10563
|
requestId: msg.requestId,
|
|
10564
|
+
...(typeof msg.runId === "string" ? { runId: msg.runId } : {}),
|
|
10309
10565
|
};
|
|
10310
10566
|
}
|
|
10311
10567
|
|
|
@@ -11928,6 +12184,10 @@ function buildStudioRelativeUrl(
|
|
|
11928
12184
|
if (doc?.watchFile) params.set("watchFile", "1");
|
|
11929
12185
|
if (options?.skipWorkspaceRestore) params.set("skipWorkspaceRestore", "1");
|
|
11930
12186
|
if (options?.paneFocus) params.set("paneFocus", options.paneFocus);
|
|
12187
|
+
if (options?.hostedWorkspaceId) {
|
|
12188
|
+
params.set("studioTabState", options.hostedWorkspaceId);
|
|
12189
|
+
params.set("hostedWorkspace", options.hostedWorkspaceId);
|
|
12190
|
+
}
|
|
11931
12191
|
return `/?${params.toString()}`;
|
|
11932
12192
|
}
|
|
11933
12193
|
|
|
@@ -11949,6 +12209,7 @@ interface StudioLaunchFlags {
|
|
|
11949
12209
|
watchPdf: boolean;
|
|
11950
12210
|
listenAll: boolean;
|
|
11951
12211
|
port?: number;
|
|
12212
|
+
editorMode?: "single" | "dual";
|
|
11952
12213
|
error?: string;
|
|
11953
12214
|
}
|
|
11954
12215
|
|
|
@@ -11961,8 +12222,14 @@ function parseStudioLaunchOpenFlags(rawArgs: string): StudioLaunchFlags {
|
|
|
11961
12222
|
let watchPdf = false;
|
|
11962
12223
|
let listenAll = false;
|
|
11963
12224
|
let port: number | undefined;
|
|
12225
|
+
let editorMode: "single" | "dual" | undefined;
|
|
11964
12226
|
for (let i = 0; i < parsed.tokens.length; i += 1) {
|
|
11965
12227
|
const token = parsed.tokens[i]!;
|
|
12228
|
+
if (token === "--single" || token === "--dual") {
|
|
12229
|
+
if (editorMode && editorMode !== token.slice(2)) return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, listenAll, error: "Use either --single or --dual, not both." };
|
|
12230
|
+
editorMode = token === "--single" ? "single" : "dual";
|
|
12231
|
+
continue;
|
|
12232
|
+
}
|
|
11966
12233
|
if (token === "--open-remote" || token === "--open-remote-browser" || token === "--open-browser") {
|
|
11967
12234
|
openRemoteBrowser = true;
|
|
11968
12235
|
continue;
|
|
@@ -11996,7 +12263,7 @@ function parseStudioLaunchOpenFlags(rawArgs: string): StudioLaunchFlags {
|
|
|
11996
12263
|
if (openRemoteBrowser && noBrowser) {
|
|
11997
12264
|
return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, listenAll, port, error: "Use either --no-browser or --open-browser, not both." };
|
|
11998
12265
|
}
|
|
11999
|
-
return { args: remaining.join(" "), openRemoteBrowser, noBrowser, watchPdf, listenAll, port };
|
|
12266
|
+
return { args: remaining.join(" "), openRemoteBrowser, noBrowser, watchPdf, listenAll, port, ...(editorMode ? { editorMode } : {}) };
|
|
12000
12267
|
}
|
|
12001
12268
|
|
|
12002
12269
|
function shouldAutoOpenStudioBrowser(options?: { openRemoteBrowser?: boolean; noBrowser?: boolean }): boolean {
|
|
@@ -12102,10 +12369,8 @@ function formatStudioModelOptionLabel(model: { provider?: string; id?: string; n
|
|
|
12102
12369
|
|
|
12103
12370
|
function buildTerminalSessionLabel(cwd: string, sessionName?: string): string {
|
|
12104
12371
|
const cwdBase = basename(cwd || process.cwd() || "") || cwd || "~";
|
|
12105
|
-
const termProgram = String(process.env.TERM_PROGRAM ?? "").trim();
|
|
12106
12372
|
const name = String(sessionName ?? "").trim();
|
|
12107
12373
|
const parts: string[] = [];
|
|
12108
|
-
if (termProgram) parts.push(termProgram);
|
|
12109
12374
|
if (name) parts.push(name);
|
|
12110
12375
|
parts.push(cwdBase);
|
|
12111
12376
|
return parts.join(" · ");
|
|
@@ -12328,6 +12593,7 @@ function buildStudioHtml(
|
|
|
12328
12593
|
initialTerminalDetail?: string,
|
|
12329
12594
|
initialContextUsage?: StudioContextUsageSnapshot,
|
|
12330
12595
|
studioMode: StudioUiMode = "full",
|
|
12596
|
+
bufferRecovery: { workspaceId: string; capability: string; hosting?: boolean; resuming?: boolean } | null = null,
|
|
12331
12597
|
): string {
|
|
12332
12598
|
const initialText = escapeHtmlForInline(initialDocument?.text ?? "");
|
|
12333
12599
|
const initialSource = initialDocument?.source ?? "blank";
|
|
@@ -12414,7 +12680,7 @@ ${cssVarsBlock}
|
|
|
12414
12680
|
</style>
|
|
12415
12681
|
<link rel="stylesheet" href="${stylesheetHref}" />
|
|
12416
12682
|
</head>
|
|
12417
|
-
<body data-initial-source="${initialSource}" data-initial-label="${initialLabel}" data-initial-path="${initialPath}" data-initial-draft-id="${initialDraftId}" data-initial-resource-dir="${initialResourceDir}" data-initial-disk-revision="${initialDiskRevision}" data-watched-file-preview="${initialWatchFile}" data-model-label="${initialModel}" data-terminal-label="${initialTerminal}" data-terminal-detail="${initialTerminalDetailAttr}" data-theme-name="${initialTheme}" data-context-tokens="${initialContextTokens}" data-context-window="${initialContextWindow}" data-context-percent="${initialContextPercent}" data-studio-mode="${studioMode}" data-ssh-session="${initialSshSession}">
|
|
12683
|
+
<body data-editor-mode="${STUDIO_EDITOR_FLAGS.mode}" data-editor-mode-locked="${STUDIO_EDITOR_FLAGS.forced ? "1" : "0"}" data-hosting-resume="${bufferRecovery?.resuming ? "1" : "0"}" data-document-hosting="${bufferRecovery?.hosting ? "1" : "0"}" data-buffer-switching="${STUDIO_BUFFER_SWITCHING_ENABLED && bufferRecovery && studioMode === "full" && initialWatchFile !== "1" ? "1" : "0"}" data-buffer-workspace-id="${escapeHtmlForInline(bufferRecovery?.workspaceId ?? "")}" data-buffer-capability="${escapeHtmlForInline(bufferRecovery?.capability ?? "")}" data-initial-source="${initialSource}" data-initial-label="${initialLabel}" data-initial-path="${initialPath}" data-initial-draft-id="${initialDraftId}" data-initial-resource-dir="${initialResourceDir}" data-initial-disk-revision="${initialDiskRevision}" data-watched-file-preview="${initialWatchFile}" data-model-label="${initialModel}" data-terminal-label="${initialTerminal}" data-terminal-detail="${initialTerminalDetailAttr}" data-theme-name="${initialTheme}" data-context-tokens="${initialContextTokens}" data-context-window="${initialContextWindow}" data-context-percent="${initialContextPercent}" data-studio-mode="${studioMode}" data-ssh-session="${initialSshSession}">
|
|
12418
12684
|
<header id="studioHeader">
|
|
12419
12685
|
<h1><span class="app-logo" aria-hidden="true">π</span> Studio <span class="app-subtitle">${appSubtitle}</span></h1>
|
|
12420
12686
|
<div class="controls">
|
|
@@ -12481,7 +12747,7 @@ ${cssVarsBlock}
|
|
|
12481
12747
|
</select>
|
|
12482
12748
|
</div>
|
|
12483
12749
|
<div class="source-actions-row">
|
|
12484
|
-
<button id="copyDraftBtn" type="button" title="Copy the
|
|
12750
|
+
<button id="copyDraftBtn" type="button" title="Copy the editor text to the clipboard.">Copy text</button>
|
|
12485
12751
|
<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>
|
|
12486
12752
|
<button id="suggestCompletionOptionsBtn" type="button" hidden title="Suggestion context options">▾</button>
|
|
12487
12753
|
<select id="completionContextSelect" class="studio-flat-select" hidden aria-label="Suggestion context mode" title="Choose how much context Suggest includes.">
|
|
@@ -12554,10 +12820,14 @@ ${cssVarsBlock}
|
|
|
12554
12820
|
<option value="editor-top">Layout: Editor above</option>
|
|
12555
12821
|
<option value="response-top">Layout: Response above</option>
|
|
12556
12822
|
</select>
|
|
12557
|
-
<select id="activityTrackingSelect" class="studio-flat-select" aria-label="Follow main Pi activity" aria-keyshortcuts="Meta+Alt+A Control+Alt+A" title="
|
|
12823
|
+
<select id="activityTrackingSelect" class="studio-flat-select" aria-label="Follow main Pi activity" aria-keyshortcuts="Meta+Alt+A Control+Alt+A" title="Show Working temporarily during main Pi activity, then hand back. Full-workspace fresh default is On; explicit Off is remembered. Shortcut: Cmd/Ctrl+Option/Alt+A.">
|
|
12558
12824
|
<option value="off">Follow activity: Off</option>
|
|
12559
12825
|
<option value="on">Follow activity: On</option>
|
|
12560
12826
|
</select>
|
|
12827
|
+
<select id="documentPreviewFollowSelect" class="studio-flat-select" aria-label="Follow Document preview" title="Show Document preview automatically when selecting Document. Manual view choices take precedence; turning this off restores Document's own view.">
|
|
12828
|
+
<option value="on">Document preview: On</option>
|
|
12829
|
+
<option value="off">Document preview: Off</option>
|
|
12830
|
+
</select>
|
|
12561
12831
|
<select id="editorFontSizeSelect" class="studio-flat-select" aria-label="Editor text size" title="Adjust raw editor text size.">
|
|
12562
12832
|
<option value="10">Editor text: 10px</option>
|
|
12563
12833
|
<option value="11">Editor text: 11px</option>
|
|
@@ -12582,7 +12852,7 @@ ${cssVarsBlock}
|
|
|
12582
12852
|
</div>
|
|
12583
12853
|
<div id="lineNumberMeasure" class="editor-line-number-measure" aria-hidden="true"></div>
|
|
12584
12854
|
<pre id="sourceHighlight" class="editor-highlight" aria-hidden="true"></pre>
|
|
12585
|
-
<textarea id="sourceText" placeholder="Paste or edit text here.">${initialText}</textarea>
|
|
12855
|
+
<textarea id="sourceText" ${bufferRecovery?.hosting ? "readonly" : ""} placeholder="Paste or edit text here.">${initialText}</textarea>
|
|
12586
12856
|
<div id="editorSelectionActions" class="editor-selection-actions" hidden>
|
|
12587
12857
|
<button id="editorSelectionCommentBtn" type="button" class="editor-selection-action-btn" hidden title="Create a new local comment from the current editor selection.">Comment</button>
|
|
12588
12858
|
<button id="editorSelectionJumpBtn" type="button" class="editor-selection-action-btn" hidden title="Jump to the current editor selection in the preview.">Jump</button>
|
|
@@ -12737,6 +13007,7 @@ ${cssVarsBlock}
|
|
|
12737
13007
|
<button id="loadCritiqueNotesBtn" type="button" hidden>Load critique notes into editor</button>
|
|
12738
13008
|
<button id="loadCritiqueFullBtn" type="button" hidden>Load full critique into editor</button>
|
|
12739
13009
|
<button id="loadHistoryPromptBtn" type="button" title="Load the prompt that generated the selected response into the editor.">Load response prompt into editor</button>
|
|
13010
|
+
<button id="copyResponsePromptBtn" type="button" title="Select a response with a recorded prompt." disabled>Copy response prompt (unavailable)</button>
|
|
12740
13011
|
<button id="copyResponseBtn" type="button">Copy response text</button>
|
|
12741
13012
|
</div>
|
|
12742
13013
|
</div>
|
|
@@ -12750,6 +13021,7 @@ ${cssVarsBlock}
|
|
|
12750
13021
|
<div id="footerModelMenu" class="footer-model-menu" hidden></div>
|
|
12751
13022
|
<div id="footerThemeMenu" class="footer-theme-menu" hidden></div>
|
|
12752
13023
|
<span class="footer-actions">
|
|
13024
|
+
<button id="editorModeBtn" class="shortcut-hint" type="button" hidden></button>
|
|
12753
13025
|
<button id="footerThemeBtn" class="shortcut-hint footer-theme-btn" type="button" aria-haspopup="menu" aria-expanded="false" title="Switch the active Pi theme. This affects terminal Pi and Studio.">Theme: ${initialTheme}</button>
|
|
12754
13026
|
<button id="shortcutsBtn" class="shortcut-hint" type="button" title="Show Studio keyboard shortcuts. Press ? when not editing text.">Shortcuts (?)</button>
|
|
12755
13027
|
</span>
|
|
@@ -12769,11 +13041,13 @@ ${cssVarsBlock}
|
|
|
12769
13041
|
<h3>Navigation</h3>
|
|
12770
13042
|
<dl>
|
|
12771
13043
|
<div><dt>F6</dt><dd>Switch between editor and right pane</dd></div>
|
|
13044
|
+
<div class="shortcuts-full-only shortcuts-buffer-switching-only"><dt>Cmd/Ctrl+Shift+1 / 2</dt><dd>Select Prompt / Document in the left pane</dd></div>
|
|
12772
13045
|
<div><dt>F7 / Shift+F7</dt><dd>Cycle the active pane's view</dd></div>
|
|
12773
13046
|
<div><dt>Cmd/Ctrl+Option/Alt+1–8</dt><dd>Switch the right pane directly: Response Raw, Response Preview, Editor Preview, Working, Changes, Files, REPL, Side questions</dd></div>
|
|
12774
13047
|
<div><dt>Cmd/Ctrl+Option/Alt+P</dt><dd>Switch the right pane directly to Response Preview; in editor-only views, Editor Preview</dd></div>
|
|
12775
13048
|
<div><dt>Cmd/Ctrl+Option/Alt+E</dt><dd>Switch the right pane directly to Editor Preview</dd></div>
|
|
12776
13049
|
<div><dt>Cmd/Ctrl+Option/Alt+W</dt><dd>Switch the right pane directly to Working</dd></div>
|
|
13050
|
+
<div><dt>Control+Shift+[ / ]</dt><dd>Collapse / expand displayed Working sections, while focused in Working (not editable controls)</dd></div>
|
|
12777
13051
|
<div><dt>Cmd/Ctrl+Option/Alt+F</dt><dd>Switch the right pane directly to Files</dd></div>
|
|
12778
13052
|
<div><dt>Cmd/Ctrl+Option/Alt+R</dt><dd>Switch the right pane directly to REPL and focus Quick send when a session is selected</dd></div>
|
|
12779
13053
|
<div><dt>Cmd/Ctrl+Option/Alt+Q</dt><dd>Switch the right pane directly to Side questions</dd></div>
|
|
@@ -12806,7 +13080,7 @@ ${cssVarsBlock}
|
|
|
12806
13080
|
<div><dt>Tab</dt><dd>Insert a visible completion suggestion; otherwise indent selected editor text</dd></div>
|
|
12807
13081
|
<div><dt>Esc</dt><dd>Dismiss a visible completion suggestion, close overlays, exit pane focus, or stop an active request</dd></div>
|
|
12808
13082
|
<div><dt>Shift+Tab</dt><dd>Unindent selected editor text</dd></div>
|
|
12809
|
-
<div><dt>Ctrl+Shift+H</dt><dd>Toggle editor syntax highlighting</dd></div>
|
|
13083
|
+
<div><dt>Ctrl+Shift+H</dt><dd>Toggle editor syntax highlighting while editing</dd></div>
|
|
12810
13084
|
</dl>
|
|
12811
13085
|
</section>
|
|
12812
13086
|
<section class="shortcuts-group shortcuts-full-only">
|
|
@@ -12880,11 +13154,37 @@ ${cssVarsBlock}
|
|
|
12880
13154
|
export default function (pi: ExtensionAPI) {
|
|
12881
13155
|
let serverState: StudioServerState | null = null;
|
|
12882
13156
|
const studioWorkspaceStateStore = createStudioWorkspaceStateStore();
|
|
13157
|
+
// Hosting-capable unless the env flags turned hosting off; single editor mode doesn't use it.
|
|
13158
|
+
const studioBufferStateStore = createStudioBufferServerStore({ legacyState: (id: string) => studioWorkspaceStateStore.get(id),
|
|
13159
|
+
...(!STUDIO_EDITOR_FLAGS.forced || STUDIO_DOCUMENT_HOSTING_ENABLED ? { hosting: { requireView: true,
|
|
13160
|
+
canonicalPath: (path: string) => canonicalStudioDocumentPath(path, studioCwd), sameFile: studioDocumentPathsReferToSameFile } } : {}) });
|
|
13161
|
+
const writeClassicStudioHtml = async (outputPath: string, html: Buffer, cwd: string): Promise<void> => {
|
|
13162
|
+
if (!STUDIO_DOCUMENT_HOSTING_ENABLED) {
|
|
13163
|
+
await writeFile(outputPath, html);
|
|
13164
|
+
return;
|
|
13165
|
+
}
|
|
13166
|
+
await studioPersistentStateQueue.catch(() => undefined);
|
|
13167
|
+
const result = writeStudioClassicHtmlExportFile(outputPath, html, cwd, canonical => studioBufferStateStore.checkUnownedHostedFile(canonical));
|
|
13168
|
+
if (!result.filePath) throw new Error(result.error || "HTML project writing was refused; existing work was kept.");
|
|
13169
|
+
};
|
|
13170
|
+
const hostedConnections = new Map<WebSocket, { capability: string; generation: number; workspaceId: string; continuation?: { revision: string } }>();
|
|
12883
13171
|
const studioResourceGrantRegistry = createStudioResourceGrantRegistry();
|
|
12884
13172
|
const studioFileWatchers = new Map<WebSocket, ReturnType<typeof createStudioFileWatcher>>();
|
|
12885
13173
|
const studioWatchedClientPaths = new Map<WebSocket, string>();
|
|
12886
13174
|
const studioFileWatchSubscriptionGenerations = new Map<WebSocket, number>();
|
|
12887
13175
|
let activeRequest: ActiveStudioRequest | null = null;
|
|
13176
|
+
const studioRunLifecycle = createStudioRunLifecycle({ makeId: randomUUID });
|
|
13177
|
+
// Evidence for how the current run ends: the last assistant stop reason, and
|
|
13178
|
+
// every input receipt accepted during the run (steering included).
|
|
13179
|
+
let studioRunLastStopReason: string | null = null;
|
|
13180
|
+
const studioRunInputRequestIds = new Set<string>();
|
|
13181
|
+
// Informational correlation only; never used for SDK/run authority. Keep
|
|
13182
|
+
// per-input provenance so a later queued Studio input cannot credit an
|
|
13183
|
+
// earlier outside input with the same text.
|
|
13184
|
+
let studioPromptInputs: Array<{ text: string; requestId: string | null }> = [];
|
|
13185
|
+
// Once observation loses an input, same-text matching cannot prove origin
|
|
13186
|
+
// again in this server lifetime. Prefer unknown over borrowing later proof.
|
|
13187
|
+
let studioPromptInputProvenanceLost = false;
|
|
12888
13188
|
let studioDirectRunChain: StudioDirectRunChain | null = null;
|
|
12889
13189
|
let queuedStudioDirectRequests: QueuedStudioDirectRequest[] = [];
|
|
12890
13190
|
let pendingStudioPromptMetadata: StudioPromptDescriptor | null = null;
|
|
@@ -12892,6 +13192,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
12892
13192
|
let preparedPdfExports = new Map<string, PreparedStudioPdfExport>();
|
|
12893
13193
|
let preparedHtmlExports = new Map<string, PreparedStudioHtmlExport>();
|
|
12894
13194
|
let initialStudioDocument: InitialStudioDocument | null = null;
|
|
13195
|
+
let lastHostedFullWorkspaceId: string | null = null;
|
|
13196
|
+
const currentFullStudioUrl = (state: StudioServerState) => buildStudioUrl(state.port, state.token, "full", undefined, undefined,
|
|
13197
|
+
STUDIO_DOCUMENT_HOSTING_ENABLED ? { hostedWorkspaceId: studioBufferStateStore.activeHostingWorkspaces("full")[0] || lastHostedFullWorkspaceId || undefined } : undefined);
|
|
12895
13198
|
let studioCwd = process.cwd();
|
|
12896
13199
|
let lastCommandCtx: ExtensionCommandContext | null = null;
|
|
12897
13200
|
let latestModelRequestCtx: StudioModelRequestContext | null = null;
|
|
@@ -13299,10 +13602,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
13299
13602
|
const clearStudioDirectRunState = () => {
|
|
13300
13603
|
studioDirectRunChain = null;
|
|
13301
13604
|
queuedStudioDirectRequests = [];
|
|
13605
|
+
studioPromptInputs = [];
|
|
13302
13606
|
pendingStudioPromptMetadata = null;
|
|
13303
13607
|
};
|
|
13304
13608
|
|
|
13305
|
-
const isStudioBusy = () => agentBusy || activeRequest !== null || compactInProgress;
|
|
13609
|
+
const isStudioBusy = () => agentBusy || activeRequest !== null || compactInProgress || studioRunLifecycle.snapshot().compaction !== null;
|
|
13306
13610
|
|
|
13307
13611
|
const getSessionNameSafe = (): string | undefined => {
|
|
13308
13612
|
try {
|
|
@@ -13618,7 +13922,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
13618
13922
|
|
|
13619
13923
|
const attachStudioTraceSummariesToHistory = (items: StudioResponseHistoryItem[]): StudioResponseHistoryItem[] => items.map((item) => {
|
|
13620
13924
|
const stored = studioTraceHistory.get(item.id);
|
|
13621
|
-
return stored ? { ...item, traceSummary: stored.summary } : item;
|
|
13925
|
+
return stored ? { ...item, traceSummary: stored.summary, studioRunId: stored.traceState.runId } : item;
|
|
13622
13926
|
});
|
|
13623
13927
|
|
|
13624
13928
|
const pruneStudioTraceHistory = () => {
|
|
@@ -13641,7 +13945,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
13641
13945
|
const summary = summarizeStudioTraceSnapshot(snapshot.traceState, snapshot.truncated);
|
|
13642
13946
|
if (!summary.hasTrace) return null;
|
|
13643
13947
|
studioTraceHistory.set(id, { traceState: snapshot.traceState, summary });
|
|
13644
|
-
studioResponseHistory = studioResponseHistory.map((item) => item.id === id ? { ...item, traceSummary: summary } : item);
|
|
13948
|
+
studioResponseHistory = studioResponseHistory.map((item) => item.id === id ? { ...item, traceSummary: summary, studioRunId: snapshot.traceState.runId } : item);
|
|
13645
13949
|
pruneStudioTraceHistory();
|
|
13646
13950
|
return summary;
|
|
13647
13951
|
};
|
|
@@ -14125,7 +14429,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
14125
14429
|
const resetStudioTraceForRun = () => {
|
|
14126
14430
|
const now = Date.now();
|
|
14127
14431
|
studioTraceState = {
|
|
14128
|
-
runId: randomUUID(),
|
|
14432
|
+
runId: studioRunLifecycle.snapshot().run?.id ?? randomUUID(),
|
|
14129
14433
|
requestId: activeRequest?.id ?? null,
|
|
14130
14434
|
requestKind: activeRequest?.kind ?? null,
|
|
14131
14435
|
status: "running",
|
|
@@ -14408,9 +14712,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
14408
14712
|
contextTokens: contextUsageSnapshot.tokens,
|
|
14409
14713
|
contextWindow: contextUsageSnapshot.contextWindow,
|
|
14410
14714
|
contextPercent: contextUsageSnapshot.percent,
|
|
14411
|
-
compactInProgress,
|
|
14412
|
-
|
|
14413
|
-
|
|
14715
|
+
compactInProgress: compactInProgress || studioRunLifecycle.snapshot().compaction !== null,
|
|
14716
|
+
runtimeActivity: studioRunLifecycle.snapshot(),
|
|
14717
|
+
activeRequestId: activeRequest?.id ?? studioRunLifecycle.snapshot().run?.requestId ?? compactRequestId ?? null,
|
|
14718
|
+
activeRequestKind: activeRequest?.kind ?? studioRunLifecycle.snapshot().run?.kind ?? (compactInProgress ? "compact" : null),
|
|
14414
14719
|
studioRunChainActive: isStudioDirectRunChainActive(),
|
|
14415
14720
|
queuedSteeringCount: getQueuedStudioSteeringCount(),
|
|
14416
14721
|
});
|
|
@@ -14425,6 +14730,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
14425
14730
|
if (!activeRequest) return;
|
|
14426
14731
|
const completedRequestId = activeRequest.id;
|
|
14427
14732
|
const completedKind = activeRequest.kind;
|
|
14733
|
+
studioPromptInputs = studioPromptInputs.filter(input => input.requestId !== completedRequestId);
|
|
14428
14734
|
clearTimeout(activeRequest.timer);
|
|
14429
14735
|
activeRequest = null;
|
|
14430
14736
|
syncCmuxStudioStatus();
|
|
@@ -14446,7 +14752,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
14446
14752
|
}
|
|
14447
14753
|
};
|
|
14448
14754
|
|
|
14449
|
-
const cancelActiveRequest = (requestId: string): { ok: true; kind: StudioRequestKind } | { ok: false; message: string } => {
|
|
14755
|
+
const cancelActiveRequest = (requestId: string, runId?: string): { ok: true; kind: StudioRequestKind } | { ok: false; message: string } => {
|
|
14756
|
+
const run = studioRunLifecycle.snapshot().run;
|
|
14757
|
+
if (run) {
|
|
14758
|
+
if (!lastCommandCtx) return { ok: false, message: "No interactive pi context is available to stop the request." };
|
|
14759
|
+
const result = studioRunLifecycle.stop(requestId, runId, () => lastCommandCtx!.abort());
|
|
14760
|
+
if (!result.ok) return { ok: false, message: result.message || "Could not request cancellation." };
|
|
14761
|
+
const kind = result.kind as StudioRequestKind;
|
|
14762
|
+
const stillTarget = studioRunLifecycle.snapshot().run?.id === run.id;
|
|
14763
|
+
if (stillTarget) {
|
|
14764
|
+
if (kind === "direct") clearStudioDirectRunState();
|
|
14765
|
+
clearPendingStudioCompletion();
|
|
14766
|
+
suppressedStudioResponse = { requestId, kind };
|
|
14767
|
+
}
|
|
14768
|
+
broadcastState(); // delivery does not clear an unsettled target
|
|
14769
|
+
broadcast({ type: "info", message: stillTarget ? "Stopping request…" : "Cancellation requested.", level: "warning" });
|
|
14770
|
+
return { ok: true, kind };
|
|
14771
|
+
}
|
|
14772
|
+
if (runId) return { ok: false, message: "That Studio run has already settled." };
|
|
14450
14773
|
if (!activeRequest) {
|
|
14451
14774
|
return { ok: false, message: "No studio request is currently running." };
|
|
14452
14775
|
}
|
|
@@ -15272,12 +15595,40 @@ export default function (pi: ExtensionAPI) {
|
|
|
15272
15595
|
}
|
|
15273
15596
|
};
|
|
15274
15597
|
|
|
15275
|
-
const handleStudioMessage = (client: WebSocket, msg: IncomingStudioMessage) => {
|
|
15598
|
+
const handleStudioMessage = (client: WebSocket, msg: IncomingStudioMessage, hostingProof?: Record<string, unknown>) => {
|
|
15599
|
+
const hosted = hostedConnections.get(client);
|
|
15600
|
+
if (STUDIO_DOCUMENT_HOSTING_ENABLED && msg.type === "open_editor_only_request") {
|
|
15601
|
+
sendToClient(client, { type: "error", requestId: msg.requestId, message: "Use Move Document or explicitly Copy Document in the hosting controls." }); return;
|
|
15602
|
+
}
|
|
15603
|
+
if (hosted && ["save_as_request", "save_over_request", "refresh_from_disk_request", "open_editor_only_request"].includes(msg.type)) {
|
|
15604
|
+
const authority = hostingProof?.generation === hosted.generation
|
|
15605
|
+
? studioBufferStateStore.hostingAuthority(hosted.capability, hosted.generation, hostingProof.bufferId, hostingProof.documentEpoch)
|
|
15606
|
+
: { ok: false, message: "The Document's owning connection is not ready." };
|
|
15607
|
+
if (!authority.ok) { sendToClient(client, { type: "error", requestId: "requestId" in msg ? msg.requestId : undefined, message: "message" in authority ? authority.message : "Document ownership could not be verified." }); return; }
|
|
15608
|
+
}
|
|
15276
15609
|
if (msg.type === "ping") {
|
|
15277
15610
|
sendToClient(client, { type: "pong", timestamp: Date.now() });
|
|
15278
15611
|
return;
|
|
15279
15612
|
}
|
|
15613
|
+
if (msg.type === "hosting_clean_leave") {
|
|
15614
|
+
// A tab forced off by a handover doesn't get to skip the resume question (Sol, candidate70).
|
|
15615
|
+
if (hosted && !studioMovedAwayCapabilities.has(hosted.capability)) studioBufferStateStore.hostingCleanLeave(hosted.capability, client, { generation: msg.generation, revision: msg.revision });
|
|
15616
|
+
return;
|
|
15617
|
+
}
|
|
15618
|
+
if (msg.type === "editor_mode_switch_request") {
|
|
15619
|
+
const refusal = studioEditorModeSwitchRefusal(client, msg);
|
|
15620
|
+
if (refusal) { sendToClient(client, { type: "editor_mode_switch_result", requestId: msg.requestId, ok: false, message: refusal }); return; }
|
|
15621
|
+
const state = serverState!, token = createSessionToken(), nonce = randomUUID();
|
|
15622
|
+
try { writeStudioEditorMode(STUDIO_EDITOR_MODE_PATH, msg.mode); }
|
|
15623
|
+
catch { sendToClient(client, { type: "editor_mode_switch_result", requestId: msg.requestId, ok: false, message: "Couldn't save the editor mode setting. Nothing changed." }); return; }
|
|
15624
|
+
studioRestarting = true;
|
|
15625
|
+
const switching = withStudioLifecycle(() => restartStudioInEditorModeNow(msg.mode, state.port, state.listenAll, token, { nonce, text: msg.text }));
|
|
15626
|
+
sendToClient(client, { type: "editor_mode_switch_result", requestId: msg.requestId, ok: true, landing: `/?token=${token}&modeSwitch=${nonce}` });
|
|
15627
|
+
void switching;
|
|
15628
|
+
return;
|
|
15629
|
+
}
|
|
15280
15630
|
if (msg.type === "workspace_state_update") {
|
|
15631
|
+
if (STUDIO_DOCUMENT_HOSTING_ENABLED && (hosted || studioBufferStateStore.hostedWorkspace(msg.tabStateId))) return;
|
|
15281
15632
|
studioWorkspaceStateStore.set(msg.tabStateId, msg.state);
|
|
15282
15633
|
return;
|
|
15283
15634
|
}
|
|
@@ -15322,15 +15673,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
15322
15673
|
contextTokens: contextUsageSnapshot.tokens,
|
|
15323
15674
|
contextWindow: contextUsageSnapshot.contextWindow,
|
|
15324
15675
|
contextPercent: contextUsageSnapshot.percent,
|
|
15325
|
-
compactInProgress,
|
|
15326
|
-
|
|
15327
|
-
|
|
15676
|
+
compactInProgress: compactInProgress || studioRunLifecycle.snapshot().compaction !== null,
|
|
15677
|
+
runtimeActivity: studioRunLifecycle.snapshot(),
|
|
15678
|
+
activeRequestId: activeRequest?.id ?? studioRunLifecycle.snapshot().run?.requestId ?? compactRequestId ?? null,
|
|
15679
|
+
activeRequestKind: activeRequest?.kind ?? studioRunLifecycle.snapshot().run?.kind ?? (compactInProgress ? "compact" : null),
|
|
15328
15680
|
studioRunChainActive: isStudioDirectRunChainActive(),
|
|
15329
15681
|
queuedSteeringCount: getQueuedStudioSteeringCount(),
|
|
15330
15682
|
lastResponse: lastStudioResponse,
|
|
15331
15683
|
responseHistory: studioResponseHistory,
|
|
15332
15684
|
traceState: studioTraceState,
|
|
15333
|
-
initialDocument: initialStudioDocument,
|
|
15685
|
+
initialDocument: hosted ? null : initialStudioDocument,
|
|
15686
|
+
...(hosted ? { documentHosting: { workspaceId: hosted.workspaceId, generation: hosted.generation, ...(hosted.continuation ? { continuation: hosted.continuation } : {}) } } : {}),
|
|
15334
15687
|
resourceGrants: studioResourceGrantRegistry.snapshot(),
|
|
15335
15688
|
quartoPreview: getStudioQuartoPreviewSnapshot(),
|
|
15336
15689
|
sideQuestion: getStudioSideQuestionPublicState(),
|
|
@@ -15669,9 +16022,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
15669
16022
|
return;
|
|
15670
16023
|
}
|
|
15671
16024
|
|
|
15672
|
-
const result = cancelActiveRequest(msg.requestId);
|
|
16025
|
+
const result = cancelActiveRequest(msg.requestId, msg.runId);
|
|
15673
16026
|
if (result.ok === false) {
|
|
15674
|
-
sendToClient(client, { type: "error", requestId: msg.requestId, message: result.message });
|
|
16027
|
+
sendToClient(client, { type: msg.runId ? "cancel_rejected" : "error", requestId: msg.requestId, runId: msg.runId, message: result.message });
|
|
15675
16028
|
}
|
|
15676
16029
|
return;
|
|
15677
16030
|
}
|
|
@@ -16395,7 +16748,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
16395
16748
|
return;
|
|
16396
16749
|
}
|
|
16397
16750
|
|
|
16398
|
-
const
|
|
16751
|
+
const writer = (authorizeCommit?: (path: string) => { ok: boolean; message?: string }) => writeStudioFile(msg.path, studioCwd, msg.content, msg.overwrite === true, msg.expectedRevision, authorizeCommit);
|
|
16752
|
+
const result = (hosted ? studioBufferStateStore.hostingSave(hosted.capability, { ...hostingProof,
|
|
16753
|
+
kind: "save-as", path: msg.path, content: msg.content, overwrite: msg.overwrite === true, expectedDiskRevision: msg.expectedRevision }, writer)
|
|
16754
|
+
: writer()) as ReturnType<typeof writeStudioFile> & { documents?: Record<string, number>; operationId?: string; claimStatus?: string };
|
|
16399
16755
|
if (result.ok === false) {
|
|
16400
16756
|
if (result.conflict) {
|
|
16401
16757
|
sendToClient(client, {
|
|
@@ -16430,6 +16786,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
16430
16786
|
resourceDir: dirname(result.resolvedPath),
|
|
16431
16787
|
diskRevision: result.diskRevision,
|
|
16432
16788
|
message: `Saved editor text to ${result.label}`,
|
|
16789
|
+
...(hosted ? { hosting: { documents: result.documents, operationId: result.operationId, claimStatus: result.claimStatus } } : {}),
|
|
16433
16790
|
});
|
|
16434
16791
|
return;
|
|
16435
16792
|
}
|
|
@@ -16448,12 +16805,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
16448
16805
|
return;
|
|
16449
16806
|
}
|
|
16450
16807
|
|
|
16451
|
-
const
|
|
16452
|
-
path: msg.path,
|
|
16453
|
-
|
|
16454
|
-
|
|
16455
|
-
force: msg.force === true,
|
|
16456
|
-
|
|
16808
|
+
const writer = (authorizeCommit?: (path: string) => { ok: boolean; message?: string }) => saveStudioDiskFileIfRevision({
|
|
16809
|
+
path: msg.path, content: msg.content, expectedRevision: msg.expectedRevision, force: msg.force === true, authorizeCommit,
|
|
16810
|
+
});
|
|
16811
|
+
const result = (hosted ? studioBufferStateStore.hostingSave(hosted.capability, { ...hostingProof,
|
|
16812
|
+
kind: "save-over", path: msg.path, content: msg.content, force: msg.force === true, expectedDiskRevision: msg.expectedRevision }, writer)
|
|
16813
|
+
: writer()) as StudioDiskWriteResult & { documents?: Record<string, number>; operationId?: string; claimStatus?: string };
|
|
16457
16814
|
if (result.ok === false) {
|
|
16458
16815
|
if (result.conflict) {
|
|
16459
16816
|
sendToClient(client, {
|
|
@@ -16492,6 +16849,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
16492
16849
|
resourceDir: dirname(result.path),
|
|
16493
16850
|
diskRevision: result.revision,
|
|
16494
16851
|
message: `Saved over ${existingLabel}`,
|
|
16852
|
+
...(hosted ? { hosting: { documents: result.documents, operationId: result.operationId, claimStatus: result.claimStatus } } : {}),
|
|
16495
16853
|
});
|
|
16496
16854
|
return;
|
|
16497
16855
|
}
|
|
@@ -16516,7 +16874,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
16516
16874
|
return;
|
|
16517
16875
|
}
|
|
16518
16876
|
|
|
16519
|
-
const refreshed =
|
|
16877
|
+
const refreshed = hosted
|
|
16878
|
+
? studioBufferStateStore.hostingRefresh(hosted.capability, { ...hostingProof, path: requestedPath },
|
|
16879
|
+
(canonical: string, buffer: { sourceState: { path: string | null }; resourceDir: string }) => {
|
|
16880
|
+
// Use the verified checkpoint's context, never request-supplied hints;
|
|
16881
|
+
// ownership does not bypass the existing resource permission check.
|
|
16882
|
+
const resource = resolveStudioLocalPreviewResourcePath(canonical, buffer.sourceState.path ?? undefined, buffer.resourceDir, studioCwd, studioResourceGrantRegistry);
|
|
16883
|
+
if (resource.kind !== "text" || resource.filePath !== canonical) return { ok: false, message: "Refresh requires a permitted text file at the same canonical location." };
|
|
16884
|
+
return readStudioFile(canonical, studioCwd, { requireCanonicalPath: true });
|
|
16885
|
+
})
|
|
16886
|
+
: readStudioFile(refreshPath, studioCwd, { requireCanonicalPath: true });
|
|
16520
16887
|
if (refreshed.ok === false) {
|
|
16521
16888
|
sendToClient(client, {
|
|
16522
16889
|
type: "error",
|
|
@@ -16534,7 +16901,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
16534
16901
|
resourceDir: dirname(refreshed.resolvedPath),
|
|
16535
16902
|
diskRevision: refreshed.diskRevision,
|
|
16536
16903
|
};
|
|
16537
|
-
if (!requestedPath || initialStudioDocument?.path === refreshed.resolvedPath) {
|
|
16904
|
+
if (!hosted && (!requestedPath || initialStudioDocument?.path === refreshed.resolvedPath)) {
|
|
16538
16905
|
initialStudioDocument = refreshedDocument;
|
|
16539
16906
|
recordStudioDocumentResourceGrants(refreshedDocument);
|
|
16540
16907
|
}
|
|
@@ -16754,6 +17121,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
16754
17121
|
"X-Content-Type-Options": "nosniff",
|
|
16755
17122
|
"Content-Disposition": `inline; filename="${safeAsciiName}"; filename*=UTF-8''${encodeURIComponent(prepared.filename)}`,
|
|
16756
17123
|
"Content-Length": String(prepared.pdf.length),
|
|
17124
|
+
"Referrer-Policy": "no-referrer",
|
|
16757
17125
|
};
|
|
16758
17126
|
if (prepared.warning) headers["X-Pi-Studio-Export-Warning"] = prepared.warning;
|
|
16759
17127
|
|
|
@@ -16853,12 +17221,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
16853
17221
|
"Content-Length": String(prepared.html.length),
|
|
16854
17222
|
};
|
|
16855
17223
|
if (prepared.warning) headers["X-Pi-Studio-Export-Warning"] = prepared.warning;
|
|
17224
|
+
if (prepared.documentExport) {
|
|
17225
|
+
// Generated HTML is editable only inside a registered/sandboxed Document.
|
|
17226
|
+
// Its retained download must never execute at the privileged Studio origin.
|
|
17227
|
+
headers["Content-Disposition"] = headers["Content-Disposition"].replace(/^inline;/, "attachment;");
|
|
17228
|
+
headers["Content-Security-Policy"] = "sandbox; default-src 'none'";
|
|
17229
|
+
headers["Referrer-Policy"] = "no-referrer";
|
|
17230
|
+
}
|
|
16856
17231
|
|
|
16857
17232
|
res.writeHead(200, headers);
|
|
16858
17233
|
res.end(prepared.html);
|
|
16859
17234
|
};
|
|
16860
17235
|
|
|
16861
17236
|
const handleScratchpadStateRequest = async (req: IncomingMessage, res: ServerResponse, requestUrl: URL) => {
|
|
17237
|
+
if (STUDIO_DOCUMENT_HOSTING_ENABLED) await studioPersistentStateQueue.catch(() => undefined);
|
|
16862
17238
|
const method = (req.method ?? "GET").toUpperCase();
|
|
16863
17239
|
if (method === "GET") {
|
|
16864
17240
|
const action = (requestUrl.searchParams.get("action") ?? "").trim().toLowerCase();
|
|
@@ -16921,7 +17297,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
16921
17297
|
? (parsedBody as { label: string }).label
|
|
16922
17298
|
: undefined;
|
|
16923
17299
|
|
|
16924
|
-
|
|
17300
|
+
const writeOwnership = parseStudioMetadataWriteOwnership(parsedBody);
|
|
17301
|
+
if (!writeOwnership.ok) {
|
|
17302
|
+
respondJson(res, 400, { ok: false, error: "Invalid metadata write ownership." });
|
|
17303
|
+
return;
|
|
17304
|
+
}
|
|
17305
|
+
const proof = (parsedBody as { hosting?: Record<string, unknown> }).hosting;
|
|
17306
|
+
if (STUDIO_DOCUMENT_HOSTING_ENABLED && proof) {
|
|
17307
|
+
const result = await studioBufferStateStore.hostingMetadata(proof.capability, proof, "scratchpad", documentKey,
|
|
17308
|
+
(authorize: () => void) => writePersistedStudioScratchpadText(documentKey, text, label, writeOwnership.ownership, authorize));
|
|
17309
|
+
respondJson(res, result.ok ? 200 : 409, { ...result, ...(!result.ok ? { error: result.message } : {}) }); return;
|
|
17310
|
+
}
|
|
17311
|
+
await writePersistedStudioScratchpadText(documentKey, text, label, writeOwnership.ownership, STUDIO_DOCUMENT_HOSTING_ENABLED ? () => {
|
|
17312
|
+
if (studioBufferStateStore.ownsMetadataKey("scratchpad", documentKey)) throw new Error("Edit this scratchpad in its owning Document.");
|
|
17313
|
+
} : undefined);
|
|
16925
17314
|
respondJson(res, 200, { ok: true });
|
|
16926
17315
|
};
|
|
16927
17316
|
|
|
@@ -16974,6 +17363,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
16974
17363
|
};
|
|
16975
17364
|
|
|
16976
17365
|
const handleReviewNotesRequest = async (req: IncomingMessage, res: ServerResponse, requestUrl: URL) => {
|
|
17366
|
+
if (STUDIO_DOCUMENT_HOSTING_ENABLED) await studioPersistentStateQueue.catch(() => undefined);
|
|
16977
17367
|
const method = (req.method ?? "GET").toUpperCase();
|
|
16978
17368
|
if (method === "GET") {
|
|
16979
17369
|
const documentKey = (requestUrl.searchParams.get("documentKey") ?? "").trim();
|
|
@@ -17026,7 +17416,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
17026
17416
|
return;
|
|
17027
17417
|
}
|
|
17028
17418
|
|
|
17029
|
-
|
|
17419
|
+
const writeOwnership = parseStudioMetadataWriteOwnership(parsedBody);
|
|
17420
|
+
if (!writeOwnership.ok) {
|
|
17421
|
+
respondJson(res, 400, { ok: false, error: "Invalid metadata write ownership." });
|
|
17422
|
+
return;
|
|
17423
|
+
}
|
|
17424
|
+
const proof = (parsedBody as { hosting?: Record<string, unknown> }).hosting;
|
|
17425
|
+
if (STUDIO_DOCUMENT_HOSTING_ENABLED && proof) {
|
|
17426
|
+
const result = await studioBufferStateStore.hostingMetadata(proof.capability, proof, "review-notes", documentKey,
|
|
17427
|
+
(authorize: () => void) => writePersistedStudioReviewNotes(documentKey, notes, writeOwnership.ownership, authorize));
|
|
17428
|
+
respondJson(res, result.ok ? 200 : 409, { ...result, ...(!result.ok ? { error: result.message } : {}) }); return;
|
|
17429
|
+
}
|
|
17430
|
+
await writePersistedStudioReviewNotes(documentKey, notes, writeOwnership.ownership, STUDIO_DOCUMENT_HOSTING_ENABLED ? () => {
|
|
17431
|
+
if (studioBufferStateStore.ownsMetadataKey("review-notes", documentKey)) throw new Error("Edit these comments in their owning Document.");
|
|
17432
|
+
} : undefined);
|
|
17030
17433
|
respondJson(res, 200, { ok: true });
|
|
17031
17434
|
};
|
|
17032
17435
|
|
|
@@ -17259,6 +17662,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
17259
17662
|
const writeResult = writeStudioPreviewExportFile(buildStudioPreviewExportPath(sourcePath || undefined, userResourceDir || undefined, studioCwd, filename), pdf);
|
|
17260
17663
|
const exportId = storePreparedPdfExport(pdf, filename, warning, writeResult.filePath ?? undefined);
|
|
17261
17664
|
const token = serverState?.token ?? "";
|
|
17665
|
+
if (openTarget === "studio" && STUDIO_DOCUMENT_HOSTING_ENABLED) {
|
|
17666
|
+
// The resident export bytes remain viewable even when project writing
|
|
17667
|
+
// fails. No editable wrapper, file grant or Document is created.
|
|
17668
|
+
respondJson(res, 200, { ok: true, filename, path: writeResult.filePath, writeError: writeResult.error, warning: warning ?? null,
|
|
17669
|
+
openedStudio: false, readOnly: true, relativeUrl: `/export-pdf?token=${encodeURIComponent(token)}&id=${encodeURIComponent(exportId)}`,
|
|
17670
|
+
downloadUrl: `/export-pdf?token=${encodeURIComponent(token)}&id=${encodeURIComponent(exportId)}` });
|
|
17671
|
+
return;
|
|
17672
|
+
}
|
|
17262
17673
|
if (openTarget === "studio" && serverState && writeResult.filePath) {
|
|
17263
17674
|
const exportedPath = writeResult.filePath;
|
|
17264
17675
|
const title = sanitizeStudioPreviewBlockLine(filename || basename(exportedPath) || "PDF preview");
|
|
@@ -17381,6 +17792,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
17381
17792
|
? (parsedBody as { openTarget: string }).openTarget.trim().toLowerCase()
|
|
17382
17793
|
: "browser";
|
|
17383
17794
|
const openTarget = requestedOpenTarget === "studio" ? "studio" : "browser";
|
|
17795
|
+
const hostedDocumentExport = openTarget === "studio" && STUDIO_DOCUMENT_HOSTING_ENABLED;
|
|
17796
|
+
const exportCapability = req.headers["x-studio-buffer-capability"];
|
|
17797
|
+
const exportProof = parsedBody && typeof parsedBody === "object" ? (parsedBody as { hosting?: Record<string, unknown> }).hosting : undefined;
|
|
17798
|
+
let exportAuthority: ReturnType<typeof studioBufferStateStore.hostingExportAuthority> | null = null;
|
|
17799
|
+
if (hostedDocumentExport) {
|
|
17800
|
+
await studioPersistentStateQueue.catch(() => undefined);
|
|
17801
|
+
exportAuthority = typeof exportCapability === "string" ? studioBufferStateStore.hostingExportAuthority(exportCapability, exportProof) : null;
|
|
17802
|
+
if (!exportAuthority?.ok) { respondJson(res, 409, { ok: false, error: exportAuthority?.message || "A current owning export checkpoint is required; current work was kept." }); return; }
|
|
17803
|
+
}
|
|
17384
17804
|
const editorHtmlLanguage = inferStudioPdfLanguage(markdown, requestedEditorHtmlLanguage);
|
|
17385
17805
|
const isLatex = editorHtmlLanguage === "latex"
|
|
17386
17806
|
|| (
|
|
@@ -17403,9 +17823,33 @@ export default function (pi: ExtensionAPI) {
|
|
|
17403
17823
|
themeVars,
|
|
17404
17824
|
},
|
|
17405
17825
|
);
|
|
17406
|
-
const
|
|
17407
|
-
const exportId = storePreparedHtmlExport(html, filename, warning, writeResult.filePath ?? undefined);
|
|
17826
|
+
const targetPath = buildStudioPreviewExportPath(sourcePath || undefined, userResourceDir || undefined, studioCwd, filename);
|
|
17408
17827
|
const token = serverState?.token ?? "";
|
|
17828
|
+
if (hostedDocumentExport && exportAuthority?.ok && typeof exportCapability === "string") {
|
|
17829
|
+
// Retain generated bytes BEFORE any filesystem attempt; no Document is
|
|
17830
|
+
// registered until a separate original-identity launch transaction.
|
|
17831
|
+
const exportId = storePreparedHtmlExport(html, filename, warning);
|
|
17832
|
+
const prepared = preparedHtmlExports.get(exportId)!;
|
|
17833
|
+
prepared.documentExport = { workspaceId: exportAuthority.workspaceId, bufferId: exportAuthority.bufferId,
|
|
17834
|
+
documentEpoch: exportProof!.documentEpoch as number, expectedRevision: exportProof!.expectedRevision as string,
|
|
17835
|
+
resourceDir: resourcePath || studioCwd, diskRevision: null };
|
|
17836
|
+
const writeResult = writeStudioHostedHtmlDocumentExport(targetPath, html, studioCwd, (canonical) => {
|
|
17837
|
+
const current = studioBufferStateStore.hostingExportAuthority(exportCapability, exportProof);
|
|
17838
|
+
if (!current.ok) return { ok: false, message: current.message };
|
|
17839
|
+
const found = studioBufferStateStore.hostingOwner(exportCapability, canonical);
|
|
17840
|
+
return found.ok && "owner" in found && !found.owner ? { ok: true } : { ok: false, message: "The export target is owned or claimed by a Document; its file and work were kept." };
|
|
17841
|
+
});
|
|
17842
|
+
if (writeResult.filePath) { prepared.filePath = writeResult.filePath; prepared.persistent = true; prepared.documentExport.diskRevision = writeResult.diskRevision; }
|
|
17843
|
+
respondJson(res, 200, { ok: true, filename, path: writeResult.filePath, writeError: writeResult.error, warning: warning ?? null,
|
|
17844
|
+
openedStudio: false, preparedDocument: { exportId, kind: writeResult.filePath ? "file" : "blank", path: writeResult.filePath || "" },
|
|
17845
|
+
downloadUrl: `/export-html?token=${encodeURIComponent(token)}&id=${encodeURIComponent(exportId)}` });
|
|
17846
|
+
return;
|
|
17847
|
+
}
|
|
17848
|
+
if (STUDIO_DOCUMENT_HOSTING_ENABLED) await studioPersistentStateQueue.catch(() => undefined);
|
|
17849
|
+
const writeResult = STUDIO_DOCUMENT_HOSTING_ENABLED
|
|
17850
|
+
? writeStudioClassicHtmlExportFile(targetPath, html, studioCwd, canonical => studioBufferStateStore.checkUnownedHostedFile(canonical), true)
|
|
17851
|
+
: writeStudioPreviewExportFile(targetPath, html);
|
|
17852
|
+
const exportId = storePreparedHtmlExport(html, filename, warning, writeResult.filePath ?? undefined);
|
|
17409
17853
|
if (openTarget === "studio" && serverState) {
|
|
17410
17854
|
const exportedPath = writeResult.filePath ?? "";
|
|
17411
17855
|
const document: InitialStudioDocument = {
|
|
@@ -17458,7 +17902,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
17458
17902
|
};
|
|
17459
17903
|
|
|
17460
17904
|
const handleHttpRequest = (req: IncomingMessage, res: ServerResponse) => {
|
|
17461
|
-
if (!serverState) {
|
|
17905
|
+
if (!serverState || studioRestarting) {
|
|
17462
17906
|
respondText(res, 503, "Studio server not ready");
|
|
17463
17907
|
return;
|
|
17464
17908
|
}
|
|
@@ -17611,6 +18055,174 @@ export default function (pi: ExtensionAPI) {
|
|
|
17611
18055
|
return;
|
|
17612
18056
|
}
|
|
17613
18057
|
|
|
18058
|
+
if (requestUrl.pathname.startsWith("/buffer-modules/")) {
|
|
18059
|
+
if (!STUDIO_BUFFER_RECOVERY_ENABLED || requestUrl.searchParams.get("token") !== serverState.token) {
|
|
18060
|
+
respondText(res, 403, "Buffer module authorization unavailable.");
|
|
18061
|
+
return;
|
|
18062
|
+
}
|
|
18063
|
+
const name = requestUrl.pathname.slice("/buffer-modules/".length);
|
|
18064
|
+
const sourceUrl = STUDIO_BUFFER_MODULE_URLS.get(name);
|
|
18065
|
+
if (!sourceUrl) { respondText(res, 404, "Not found"); return; }
|
|
18066
|
+
try {
|
|
18067
|
+
let source = readFileSync(sourceUrl, "utf8");
|
|
18068
|
+
// Module imports do not inherit query parameters: authorize each fixed graph edge.
|
|
18069
|
+
for (const moduleName of STUDIO_BUFFER_MODULE_URLS.keys()) {
|
|
18070
|
+
source = source.replaceAll(JSON.stringify("./" + moduleName), JSON.stringify("/buffer-modules/" + moduleName + "?token=" + encodeURIComponent(serverState.token)));
|
|
18071
|
+
}
|
|
18072
|
+
res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", "Cross-Origin-Resource-Policy": "same-origin" });
|
|
18073
|
+
res.end(source);
|
|
18074
|
+
} catch { respondText(res, 500, "Could not load buffer module."); }
|
|
18075
|
+
return;
|
|
18076
|
+
}
|
|
18077
|
+
|
|
18078
|
+
if (requestUrl.pathname === "/document-hosting") {
|
|
18079
|
+
const capability = req.headers["x-studio-buffer-capability"];
|
|
18080
|
+
if (!STUDIO_DOCUMENT_HOSTING_ENABLED || requestUrl.searchParams.get("token") !== serverState.token || typeof capability !== "string") {
|
|
18081
|
+
respondJson(res, 403, { ok: false, message: "Document hosting authorization unavailable." }); return;
|
|
18082
|
+
}
|
|
18083
|
+
if (req.method !== "POST") { respondJson(res, 405, { ok: false, message: "Use POST." }); return; }
|
|
18084
|
+
void (async () => {
|
|
18085
|
+
const body = JSON.parse(await readRequestBody(req, 64_000)) as Record<string, unknown>;
|
|
18086
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid request");
|
|
18087
|
+
const current = studioBufferStateStore.read(capability);
|
|
18088
|
+
if (!current.ok) { respondJson(res, 403, current); return; }
|
|
18089
|
+
const url = (id: string, mode: StudioUiMode) => buildStudioRelativeUrl(serverState!.token, mode) + "&studioTabState=" + encodeURIComponent(id) + "&hostedWorkspace=" + encodeURIComponent(id);
|
|
18090
|
+
if (body.operation === "hosts") {
|
|
18091
|
+
respondJson(res, 200, { ok: true, workspaces: studioBufferStateStore.activeHostingWorkspaces("full") }); return;
|
|
18092
|
+
}
|
|
18093
|
+
if (body.operation === "owner") {
|
|
18094
|
+
// Retained buffer identity covers draft/moved Documents without a path.
|
|
18095
|
+
// Neither lookup reads file contents, creates an owner or grants access.
|
|
18096
|
+
let found: ReturnType<typeof studioBufferStateStore.hostingOwner>;
|
|
18097
|
+
if (body.bufferId !== undefined) {
|
|
18098
|
+
if (typeof body.bufferId !== "string" || !/^[a-zA-Z0-9_-]{1,128}$/.test(body.bufferId) || body.path) {
|
|
18099
|
+
respondJson(res, 400, { ok: false, reason: "invalid-request", message: "A bounded retained Document identity is required." }); return;
|
|
18100
|
+
}
|
|
18101
|
+
found = { ok: true, owner: studioBufferStateStore.hostedDocumentOwner(body.bufferId) };
|
|
18102
|
+
} else {
|
|
18103
|
+
const rawPath = typeof body.path === "string" ? body.path.trim() : "";
|
|
18104
|
+
if (!rawPath || rawPath.includes("\0") || rawPath.startsWith("//") || (/^[a-z][a-z0-9+.-]*:/i.test(rawPath) && !/^file:/i.test(rawPath) && !/^[a-z]:[\\/]/i.test(rawPath))) {
|
|
18105
|
+
respondJson(res, 400, { ok: false, reason: "invalid-request", message: "A local Document path is required." }); return;
|
|
18106
|
+
}
|
|
18107
|
+
const context = resolveStudioPreviewResourceContext(typeof body.sourcePath === "string" ? body.sourcePath : undefined,
|
|
18108
|
+
typeof body.resourceDir === "string" ? body.resourceDir : undefined, studioCwd);
|
|
18109
|
+
const reference = recoverLikelyDroppedLeadingSlashPath(expandHome(decodeStudioLocalPreviewResourceReference(rawPath)));
|
|
18110
|
+
found = studioBufferStateStore.hostingOwner(capability, isAbsolute(reference) ? reference : resolve(context.baseDir, reference));
|
|
18111
|
+
}
|
|
18112
|
+
if (!("owner" in found) || !found.owner) { respondJson(res, 200, found); return; }
|
|
18113
|
+
const owner = found.owner;
|
|
18114
|
+
const expectedOwner = body.expectedOwner as Record<string, unknown> | undefined;
|
|
18115
|
+
if (expectedOwner && (expectedOwner.workspaceId !== owner.workspaceId || expectedOwner.bufferId !== owner.bufferId)) {
|
|
18116
|
+
respondJson(res, 409, { ok: false, reason: "owner-changed", message: "The Document owner changed. Recheck it without opening an independent copy." }); return;
|
|
18117
|
+
}
|
|
18118
|
+
if (body.focus === true) {
|
|
18119
|
+
const authority = studioBufferStateStore.hostingViewAuthority(capability, body.generation);
|
|
18120
|
+
if (!authority.ok) { respondJson(res, 409, authority); return; }
|
|
18121
|
+
}
|
|
18122
|
+
const live = [...hostedConnections].filter(([socket, value]) => value.workspaceId === owner.workspaceId && socket.readyState === WebSocket.OPEN);
|
|
18123
|
+
if (body.focus === true) for (const [socket] of live) sendToClient(socket, { type: "document_hosting_focus", bufferId: owner.bufferId });
|
|
18124
|
+
respondJson(res, 200, { ...found, seen: studioBufferStateStore.seenHostingView(owner.workspaceId), live: live.length > 0, url: url(owner.workspaceId, owner.mode as StudioUiMode) }); return;
|
|
18125
|
+
}
|
|
18126
|
+
await studioPersistentStateQueue.catch(() => undefined);
|
|
18127
|
+
let launchRequest = body;
|
|
18128
|
+
const opening = ["open", "open-cancel", "open-ack"].includes(String(body.operation));
|
|
18129
|
+
if (opening || (body.operation === "launch" && body.kind === "file")) {
|
|
18130
|
+
const raw = typeof body.path === "string" ? body.path.trim() : "";
|
|
18131
|
+
if (!raw || raw.includes("\0") || raw.startsWith("//") || (/^[a-z][a-z0-9+.-]*:/i.test(raw) && !/^file:/i.test(raw) && !/^[a-z]:[\\/]/i.test(raw))) {
|
|
18132
|
+
respondJson(res, 400, { ok: false, reason: "invalid-request", message: "A local file path is required." }); return;
|
|
18133
|
+
}
|
|
18134
|
+
const context = resolveStudioPreviewResourceContext(typeof body.sourcePath === "string" ? body.sourcePath : undefined, typeof body.resourceDir === "string" ? body.resourceDir : undefined, studioCwd);
|
|
18135
|
+
const reference = recoverLikelyDroppedLeadingSlashPath(expandHome(decodeStudioLocalPreviewResourceReference(raw)));
|
|
18136
|
+
launchRequest = { ...body, path: isAbsolute(reference) ? reference : resolve(context.baseDir, reference) };
|
|
18137
|
+
}
|
|
18138
|
+
const loadHostedFile = (canonical: string, prepared?: { path: string | null }) => {
|
|
18139
|
+
if (!prepared) {
|
|
18140
|
+
const resource = resolveStudioLocalPreviewResourcePath(String(body.path), typeof body.sourcePath === "string" ? body.sourcePath : undefined, typeof body.resourceDir === "string" ? body.resourceDir : undefined, studioCwd, studioResourceGrantRegistry);
|
|
18141
|
+
if (resource.kind !== "text" || resource.filePath !== canonical) return { ok: false, reason: "source-changed", message: "Choose a readable text file whose canonical location has not changed." };
|
|
18142
|
+
} else if (prepared.path !== canonical) return { ok: false, reason: "source-changed", message: "The generated HTML file identity changed." };
|
|
18143
|
+
const file = readStudioFile(canonical, studioCwd, { requireCanonicalPath: true });
|
|
18144
|
+
return file.ok === false ? file : { ...file, path: file.resolvedPath, resourceDir: dirname(file.resolvedPath), editorLanguage: inferStudioPdfLanguageFromPath(file.resolvedPath) || "markdown" };
|
|
18145
|
+
};
|
|
18146
|
+
const loadHostedExport = (request: Record<string, unknown>, scope: { workspaceId: string }) => {
|
|
18147
|
+
const prepared = typeof request.exportId === "string" ? getPreparedHtmlExport(request.exportId) : null;
|
|
18148
|
+
const origin = prepared?.documentExport;
|
|
18149
|
+
if (!prepared || !origin || origin.workspaceId !== scope.workspaceId || origin.bufferId !== request.bufferId || origin.documentEpoch !== request.documentEpoch || origin.expectedRevision !== request.expectedRevision) return { ok: false, reason: "export-unavailable", message: "The prepared HTML expired or belongs to another source. Keep existing Documents and re-export explicitly." };
|
|
18150
|
+
const path = prepared.persistent ? prepared.filePath || null : null;
|
|
18151
|
+
if ((path ? request.kind !== "file" || request.path !== path : request.kind !== "blank")) return { ok: false, reason: "source-changed", message: "The prepared HTML backing does not match its original request." };
|
|
18152
|
+
return { ok: true, kind: path ? "file" : "blank", path, text: prepared.html.toString("utf-8"), label: prepared.filename,
|
|
18153
|
+
resourceDir: path ? dirname(path) : origin.resourceDir, diskRevision: path ? origin.diskRevision : null, editorLanguage: "html" };
|
|
18154
|
+
};
|
|
18155
|
+
const result = (opening ? studioBufferStateStore.hostingOpen(capability, launchRequest, loadHostedFile)
|
|
18156
|
+
: body.operation === "launch" ? studioBufferStateStore.hostingLaunch(capability, launchRequest, loadHostedFile, loadHostedExport)
|
|
18157
|
+
: body.operation === "copy" ? studioBufferStateStore.hostingCopy(capability, body)
|
|
18158
|
+
: body.operation === "save-metadata" ? await studioBufferStateStore.hostingSaveMetadata(capability, body, runStudioSavedMetadataTransaction)
|
|
18159
|
+
: body.operation === "discard-save" ? studioBufferStateStore.discardHostingSave(capability, body)
|
|
18160
|
+
: studioBufferStateStore.hostingMove(capability, body)) as Record<string, unknown>;
|
|
18161
|
+
if (opening) { respondJson(res, result.ok ? 200 : 409, result); return; }
|
|
18162
|
+
if (result.ok) {
|
|
18163
|
+
if (body.operation === "launch" && typeof body.exportId === "string" && body.kind === "file") {
|
|
18164
|
+
const prepared = getPreparedHtmlExport(body.exportId);
|
|
18165
|
+
if (prepared?.documentExport && prepared.persistent && prepared.filePath) {
|
|
18166
|
+
try { studioResourceGrantRegistry.grantFile(prepared.filePath, { cwd: studioCwd, source: "generated-html", expectedCanonicalPath: prepared.filePath }); } catch { /* Never broaden a failed exact-file grant. */ }
|
|
18167
|
+
}
|
|
18168
|
+
}
|
|
18169
|
+
const owner = typeof result.bufferId === "string" ? studioBufferStateStore.hostedDocumentOwner(result.bufferId) : null;
|
|
18170
|
+
if (owner && (result.status === "committed" || body.operation === "copy" || body.operation === "launch")) {
|
|
18171
|
+
result.seen = studioBufferStateStore.seenHostingView(owner.workspaceId);
|
|
18172
|
+
result.url = url(owner.workspaceId, owner.mode as StudioUiMode);
|
|
18173
|
+
const live = [...hostedConnections].filter(([socket, value]) => value.workspaceId === owner.workspaceId && socket.readyState === WebSocket.OPEN);
|
|
18174
|
+
result.live = live.length > 0;
|
|
18175
|
+
if (body.focus === true) for (const [socket] of live) sendToClient(socket, { type: "document_hosting_focus", bufferId: result.bufferId });
|
|
18176
|
+
}
|
|
18177
|
+
if (body.operation !== "status" && typeof result.moveId === "string") for (const [socket, value] of hostedConnections) {
|
|
18178
|
+
if ([result.sourceWorkspaceId, result.targetWorkspaceId].includes(value.workspaceId)) sendToClient(socket, { type: "document_hosting_changed", moveId: result.moveId });
|
|
18179
|
+
}
|
|
18180
|
+
}
|
|
18181
|
+
respondJson(res, result.ok ? 200 : 409, result);
|
|
18182
|
+
})().catch(error => {
|
|
18183
|
+
if (error instanceof StudioResourceGrantRequiredError) { respondStudioResourceGrantRequiredJson(res, error); return; }
|
|
18184
|
+
respondJson(res, 500, { ok: false, reason: "unavailable", message: "The outcome could not be checked. Keep all copies and recheck; do not resume a frozen Document." });
|
|
18185
|
+
});
|
|
18186
|
+
return;
|
|
18187
|
+
}
|
|
18188
|
+
|
|
18189
|
+
if (requestUrl.pathname === "/tab-buffer-state") {
|
|
18190
|
+
if (!STUDIO_BUFFER_RECOVERY_ENABLED || requestUrl.searchParams.get("token") !== serverState.token) {
|
|
18191
|
+
respondJson(res, 403, { ok: false, reason: "forbidden", message: "Buffer recovery authorization unavailable." });
|
|
18192
|
+
return;
|
|
18193
|
+
}
|
|
18194
|
+
const capability = req.headers["x-studio-buffer-capability"];
|
|
18195
|
+
if (typeof capability !== "string" || capability.length !== 48) {
|
|
18196
|
+
respondJson(res, 403, { ok: false, reason: "forbidden", message: "An editable page recovery capability is required." });
|
|
18197
|
+
return;
|
|
18198
|
+
}
|
|
18199
|
+
void (async () => {
|
|
18200
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
18201
|
+
// Releasing a page capability never deletes its workspace recovery.
|
|
18202
|
+
if (method === "DELETE") { respondJson(res, 200, studioBufferStateStore.release(capability)); return; }
|
|
18203
|
+
if (method === "GET" && requestUrl.searchParams.get("inspect") === "1") {
|
|
18204
|
+
const result = studioBufferStateStore.inspect(capability);
|
|
18205
|
+
respondJson(res, result.ok ? 200 : ("reason" in result && result.reason === "forbidden" ? 403 : 409), result); return;
|
|
18206
|
+
}
|
|
18207
|
+
const current = studioBufferStateStore.read(capability);
|
|
18208
|
+
if (!current.ok) { respondJson(res, "reason" in current && current.reason === "forbidden" ? 403 : 409, current); return; }
|
|
18209
|
+
if (method === "GET") { respondJson(res, 200, current); return; }
|
|
18210
|
+
if (method !== "POST") { res.setHeader("Allow", "GET, POST, DELETE"); respondJson(res, 405, { ok: false, message: "Use GET, POST, or DELETE (release page authorization only)." }); return; }
|
|
18211
|
+
let raw: string;
|
|
18212
|
+
try { raw = await readRequestBody(req, STUDIO_BUFFER_REQUEST_MAX_BYTES); }
|
|
18213
|
+
catch { respondJson(res, 413, { ok: false, reason: "limit-exceeded", message: "Buffer request exceeds its limit; recovery was retained." }); return; }
|
|
18214
|
+
let body: Record<string, unknown>;
|
|
18215
|
+
try {
|
|
18216
|
+
body = JSON.parse(raw);
|
|
18217
|
+
if (!body || typeof body !== "object" || Array.isArray(body) || Object.keys(body).some((key) => !["state", "expectedRevision", "generation", "documents"].includes(key))) throw new Error();
|
|
18218
|
+
} catch { respondJson(res, 400, { ok: false, reason: "invalid-request", message: "Invalid buffer request." }); return; }
|
|
18219
|
+
if (STUDIO_DOCUMENT_HOSTING_ENABLED) await studioPersistentStateQueue.catch(() => undefined);
|
|
18220
|
+
const result = studioBufferStateStore.write(capability, body.expectedRevision, body.state, body.generation, body.documents);
|
|
18221
|
+
respondJson(res, result.ok ? 200 : 409, result);
|
|
18222
|
+
})().catch(() => respondJson(res, 500, { ok: false, reason: "unavailable", message: "Buffer recovery failed; keep the current editor text." }));
|
|
18223
|
+
return;
|
|
18224
|
+
}
|
|
18225
|
+
|
|
17614
18226
|
if (requestUrl.pathname === "/tab-workspace-state") {
|
|
17615
18227
|
const token = requestUrl.searchParams.get("token") ?? "";
|
|
17616
18228
|
if (token !== serverState.token) {
|
|
@@ -17660,6 +18272,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
17660
18272
|
respondJson(res, 400, { ok: false, error: "Invalid or oversized Studio workspace state." });
|
|
17661
18273
|
return;
|
|
17662
18274
|
}
|
|
18275
|
+
if (STUDIO_DOCUMENT_HOSTING_ENABLED && studioBufferStateStore.hostedWorkspace(tabStateId)) {
|
|
18276
|
+
respondJson(res, 409, { ok: false, error: "Use the owning Document's buffer recovery; legacy publication is disabled for this workspace." }); return;
|
|
18277
|
+
}
|
|
17663
18278
|
const stored = studioWorkspaceStateStore.set(tabStateId, state);
|
|
17664
18279
|
respondJson(res, 200, { ok: true, stored });
|
|
17665
18280
|
})().catch((error) => {
|
|
@@ -17912,6 +18527,29 @@ export default function (pi: ExtensionAPI) {
|
|
|
17912
18527
|
return;
|
|
17913
18528
|
}
|
|
17914
18529
|
|
|
18530
|
+
if (requestUrl.pathname === "/studio-handover") {
|
|
18531
|
+
const token = requestUrl.searchParams.get("token") ?? "";
|
|
18532
|
+
if (token !== serverState.token) { respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." }); return; }
|
|
18533
|
+
if ((req.method ?? "GET").toUpperCase() !== "POST") { res.setHeader("Allow", "POST"); respondJson(res, 405, { ok: false, error: "Method not allowed. Use POST." }); return; }
|
|
18534
|
+
if (!STUDIO_DOCUMENT_HOSTING_ENABLED) { respondJson(res, 409, { ok: false, error: "Use /studio-replace in Pi." }); return; }
|
|
18535
|
+
const admitted = serverState;
|
|
18536
|
+
void (async () => {
|
|
18537
|
+
let body: Record<string, unknown>;
|
|
18538
|
+
try { const raw = await readRequestBody(req, 4096); body = raw ? JSON.parse(raw) as Record<string, unknown> : {}; }
|
|
18539
|
+
catch { respondJson(res, 400, { ok: false, error: "Invalid request." }); return; }
|
|
18540
|
+
// Never wait behind a restart: it waits for this response to finish (Sol, candidate70).
|
|
18541
|
+
if (studioRestarting || serverState !== admitted || studioLifecycle.pending() > 0 || !STUDIO_DOCUMENT_HOSTING_ENABLED) {
|
|
18542
|
+
respondJson(res, 503, { ok: false, error: "Studio is busy. Try again in a moment." }); return;
|
|
18543
|
+
}
|
|
18544
|
+
const capability = typeof body.capability === "string" ? body.capability : "";
|
|
18545
|
+
const issued = studioBufferStateStore.capabilityWorkspace(capability);
|
|
18546
|
+
if (!issued || issued.mode !== "full" || studioMovedAwayCapabilities.has(capability)) { respondJson(res, 409, { ok: false, error: "This tab can't take over Studio. Run /studio-replace in Pi instead." }); return; }
|
|
18547
|
+
const moved = await handOverFullStudioView({ state: admitted, capability, workspaceId: issued.workspaceId });
|
|
18548
|
+
respondJson(res, moved.ok ? 200 : 409, moved.ok ? { ok: true, clean: moved.clean } : { ok: false, error: moved.message });
|
|
18549
|
+
})().catch(error => respondJson(res, 500, { ok: false, error: `Studio couldn't move: ${error instanceof Error ? error.message : String(error)}` }));
|
|
18550
|
+
return;
|
|
18551
|
+
}
|
|
18552
|
+
|
|
17915
18553
|
if (requestUrl.pathname === "/side-question-context-root") {
|
|
17916
18554
|
const token = requestUrl.searchParams.get("token") ?? "";
|
|
17917
18555
|
if (token !== serverState.token) {
|
|
@@ -18038,6 +18676,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
18038
18676
|
return;
|
|
18039
18677
|
}
|
|
18040
18678
|
|
|
18679
|
+
if (requestUrl.pathname === "/image-viewer") {
|
|
18680
|
+
const token = requestUrl.searchParams.get("token") ?? "";
|
|
18681
|
+
if (token !== serverState.token) { respondText(res, 403, "Invalid or expired studio token. Re-run /studio."); return; }
|
|
18682
|
+
try {
|
|
18683
|
+
const resource = resolveStudioHtmlPreviewResourcePath(requestUrl.searchParams.get("path") ?? "", requestUrl.searchParams.get("sourcePath") ?? undefined,
|
|
18684
|
+
requestUrl.searchParams.get("resourceDir") ?? undefined, studioCwd, studioResourceGrantRegistry);
|
|
18685
|
+
if (!resource.mimeType.startsWith("image/")) { respondText(res, 400, "Only images can open in this read-only viewer."); return; }
|
|
18686
|
+
respondStudioReadOnlyImage(req, res, resource.filePath, resource.mimeType);
|
|
18687
|
+
} catch (error) {
|
|
18688
|
+
if (error instanceof StudioResourceGrantRequiredError) { respondStudioResourceGrantRequiredJson(res, error); return; }
|
|
18689
|
+
respondText(res, 404, "Image unavailable; its identity or permission changed.");
|
|
18690
|
+
}
|
|
18691
|
+
return;
|
|
18692
|
+
}
|
|
18693
|
+
|
|
18041
18694
|
if (requestUrl.pathname !== "/") {
|
|
18042
18695
|
respondText(res, 404, "Not found");
|
|
18043
18696
|
return;
|
|
@@ -18048,7 +18701,37 @@ export default function (pi: ExtensionAPI) {
|
|
|
18048
18701
|
respondText(res, 403, "Invalid or expired studio token. Re-run /studio.");
|
|
18049
18702
|
return;
|
|
18050
18703
|
}
|
|
18704
|
+
if (requestUrl.searchParams.has("modeSwitch")) {
|
|
18705
|
+
const landing = pendingEditorModeLanding;
|
|
18706
|
+
if (!landing || landing.nonce !== requestUrl.searchParams.get("modeSwitch") || landing.expiresAt < Date.now()) {
|
|
18707
|
+
respondText(res, 410, "This link has expired. Run /studio in Pi to open Studio.");
|
|
18708
|
+
return;
|
|
18709
|
+
}
|
|
18710
|
+
res.writeHead(302, { Location: landing.path, "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" });
|
|
18711
|
+
res.end();
|
|
18712
|
+
return;
|
|
18713
|
+
}
|
|
18051
18714
|
|
|
18715
|
+
refreshContextUsage();
|
|
18716
|
+
const studioMode = normalizeStudioUiMode(requestUrl.searchParams.get("mode"));
|
|
18717
|
+
const hostingPage = resolveStudioHostingPage({ enabled: STUDIO_DOCUMENT_HOSTING_ENABLED, requestUrl, mode: studioMode,
|
|
18718
|
+
workspace: (id: string) => studioBufferStateStore.hostedWorkspace(id), transient: readTransientStudioDocument });
|
|
18719
|
+
if (!hostingPage.ok) { respondText(res, 409, "message" in hostingPage ? hostingPage.message : "This editing page is unavailable."); return; }
|
|
18720
|
+
const hostedId = "workspaceId" in hostingPage ? hostingPage.workspaceId : undefined;
|
|
18721
|
+
// Neither path hints nor watch flags authorize reads. Owned recovery uses
|
|
18722
|
+
// a neutral surface; watched pages require a retained read-only capability.
|
|
18723
|
+
const requestInitialDocument: InitialStudioDocument | null = "legacy" in hostingPage && hostingPage.legacy
|
|
18724
|
+
? resolveRequestedStudioDocumentFromUrl(requestUrl, initialStudioDocument, studioCwd, lastStudioResponse)
|
|
18725
|
+
: "document" in hostingPage ? hostingPage.document as InitialStudioDocument : null;
|
|
18726
|
+
let bufferRecovery: { workspaceId: string; capability: string; hosting?: boolean; resuming?: boolean } | null = null;
|
|
18727
|
+
if (STUDIO_BUFFER_RECOVERY_ENABLED && !requestInitialDocument?.watchFile && requestUrl.searchParams.get("watchedFile") !== "1") {
|
|
18728
|
+
const requestedId = hostedId || requestUrl.searchParams.get("studioTabState") || "";
|
|
18729
|
+
const workspaceId = hostedId || (requestUrl.searchParams.get("skipWorkspaceRestore") !== "1" && isValidStudioTabStateId(requestedId) ? requestedId : "tab_" + createSessionToken().slice(0, 40));
|
|
18730
|
+
const issued = studioBufferStateStore.issue({ workspaceId, mode: studioMode });
|
|
18731
|
+
if (!("capability" in issued)) { respondText(res, 503, "Buffer recovery page capacity reached. Existing recovery was retained; close unused test pages before retrying."); return; }
|
|
18732
|
+
bufferRecovery = { workspaceId, capability: issued.capability, hosting: STUDIO_DOCUMENT_HOSTING_ENABLED,
|
|
18733
|
+
resuming: STUDIO_DOCUMENT_HOSTING_ENABLED && studioBufferStateStore.seenHostingView(workspaceId) && requestUrl.searchParams.get("resumeAcknowledged") !== "1" };
|
|
18734
|
+
}
|
|
18052
18735
|
res.writeHead(200, {
|
|
18053
18736
|
"Content-Type": "text/html; charset=utf-8",
|
|
18054
18737
|
"Cache-Control": "no-store",
|
|
@@ -18057,13 +18740,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
18057
18740
|
"Cross-Origin-Opener-Policy": "same-origin",
|
|
18058
18741
|
"Cross-Origin-Resource-Policy": "same-origin",
|
|
18059
18742
|
});
|
|
18060
|
-
|
|
18061
|
-
const studioMode = normalizeStudioUiMode(requestUrl.searchParams.get("mode"));
|
|
18062
|
-
const requestInitialDocument = resolveRequestedStudioDocumentFromUrl(requestUrl, initialStudioDocument, studioCwd, lastStudioResponse);
|
|
18063
|
-
res.end(buildStudioHtml(requestInitialDocument, serverState.token, lastCommandCtx?.ui.theme, currentModelLabel, terminalSessionLabel, terminalSessionDetail, contextUsageSnapshot, studioMode));
|
|
18743
|
+
res.end(buildStudioHtml(requestInitialDocument, serverState.token, lastCommandCtx?.ui.theme, currentModelLabel, terminalSessionLabel, terminalSessionDetail, contextUsageSnapshot, studioMode, bufferRecovery));
|
|
18064
18744
|
};
|
|
18065
18745
|
|
|
18066
|
-
|
|
18746
|
+
// Starting, stopping and switching editor mode take turns (Sol, candidate69). The *Now
|
|
18747
|
+
// forms run inside a turn; ensureServer and stopServer queue for one.
|
|
18748
|
+
const ensureServer = (requestedPort?: number, listenAll = false): Promise<StudioServerState> =>
|
|
18749
|
+
serverState && studioLifecycle.pending() === 0 ? Promise.resolve(serverState) : withStudioLifecycle(() => startServerNow(requestedPort, listenAll));
|
|
18750
|
+
const startServerNow = async (requestedPort?: number, listenAll = false, token = createSessionToken()): Promise<StudioServerState> => {
|
|
18067
18751
|
if (serverState) return serverState;
|
|
18068
18752
|
|
|
18069
18753
|
const binding = resolveStudioNetworkBinding(listenAll);
|
|
@@ -18080,10 +18764,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
18080
18764
|
port: 0,
|
|
18081
18765
|
bindHost: binding.bindHost,
|
|
18082
18766
|
listenAll: binding.listenAll,
|
|
18083
|
-
token
|
|
18767
|
+
token,
|
|
18084
18768
|
};
|
|
18085
18769
|
|
|
18086
18770
|
server.on("upgrade", (req, socket, head) => {
|
|
18771
|
+
if (studioRestarting) {
|
|
18772
|
+
socket.write("HTTP/1.1 503 Service Unavailable\r\n\r\n");
|
|
18773
|
+
socket.destroy();
|
|
18774
|
+
return;
|
|
18775
|
+
}
|
|
18087
18776
|
const requestUrl = parseStudioRequestTarget(req.url);
|
|
18088
18777
|
if (!requestUrl) {
|
|
18089
18778
|
socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
@@ -18154,6 +18843,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
18154
18843
|
}
|
|
18155
18844
|
return;
|
|
18156
18845
|
}
|
|
18846
|
+
if (studioMovedAwayCapabilities.has(requestUrl.searchParams.get("bufferCapability") || "")) {
|
|
18847
|
+
try { ws.close(4006, "Studio moved to another tab"); } catch {}
|
|
18848
|
+
return;
|
|
18849
|
+
}
|
|
18157
18850
|
if (clientMode === "full") {
|
|
18158
18851
|
for (const client of clients) {
|
|
18159
18852
|
if (client.readyState !== WebSocket.OPEN) continue;
|
|
@@ -18167,6 +18860,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
18167
18860
|
return;
|
|
18168
18861
|
}
|
|
18169
18862
|
}
|
|
18863
|
+
if (STUDIO_DOCUMENT_HOSTING_ENABLED && !canonicalWatchedPath) {
|
|
18864
|
+
const capability = requestUrl.searchParams.get("bufferCapability") || "";
|
|
18865
|
+
const binding = studioBufferStateStore.bindHostingView(capability, clientMode, ws);
|
|
18866
|
+
if (!("generation" in binding)) { ws.close(4005, "Document editing view unavailable or already open"); return; }
|
|
18867
|
+
hostedConnections.set(ws, { capability, workspaceId: binding.workspaceId, generation: binding.generation, ...("continuation" in binding && binding.continuation ? { continuation: binding.continuation } : {}) });
|
|
18868
|
+
}
|
|
18170
18869
|
clients.add(ws);
|
|
18171
18870
|
clientModes.set(ws, clientMode);
|
|
18172
18871
|
if (canonicalWatchedPath) studioWatchedClientPaths.set(ws, canonicalWatchedPath);
|
|
@@ -18174,15 +18873,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
18174
18873
|
broadcastState();
|
|
18175
18874
|
|
|
18176
18875
|
ws.on("message", (data) => {
|
|
18177
|
-
|
|
18876
|
+
if (studioRestarting) return;
|
|
18877
|
+
let proof: Record<string, unknown> | undefined;
|
|
18878
|
+
const parsed = parseIncomingMessage(data, envelope => {
|
|
18879
|
+
if (envelope.hosting && typeof envelope.hosting === "object" && !Array.isArray(envelope.hosting)) proof = envelope.hosting as Record<string, unknown>;
|
|
18880
|
+
});
|
|
18178
18881
|
if (!parsed) {
|
|
18179
18882
|
sendToClient(ws, { type: "error", message: "Invalid message payload." });
|
|
18180
18883
|
return;
|
|
18181
18884
|
}
|
|
18182
|
-
handleStudioMessage(ws, parsed);
|
|
18885
|
+
handleStudioMessage(ws, parsed, proof);
|
|
18183
18886
|
});
|
|
18184
18887
|
|
|
18185
18888
|
const cleanupClient = () => {
|
|
18889
|
+
const hosted = hostedConnections.get(ws);
|
|
18890
|
+
if (hosted) { studioBufferStateStore.releaseHostingView(hosted.capability, ws); hostedConnections.delete(ws); }
|
|
18186
18891
|
clients.delete(ws);
|
|
18187
18892
|
clientModes.delete(ws);
|
|
18188
18893
|
studioWatchedClientPaths.delete(ws);
|
|
@@ -18273,7 +18978,145 @@ export default function (pi: ExtensionAPI) {
|
|
|
18273
18978
|
return state;
|
|
18274
18979
|
};
|
|
18275
18980
|
|
|
18276
|
-
|
|
18981
|
+
// Oliver, 13 Oct: switching editor mode restarts Studio's server (Pi keeps running).
|
|
18982
|
+
// The page that asked lands on a fresh workspace holding the Prompt it carried.
|
|
18983
|
+
// The old server takes no new connections, requests or messages while it stops (Sol, candidate69).
|
|
18984
|
+
const studioEditorModeLabel = (mode: "single" | "dual") => mode === "single" ? "single editor mode" : "dual editor mode";
|
|
18985
|
+
let pendingEditorModeLanding: { nonce: string; path: string; expiresAt: number } | null = null;
|
|
18986
|
+
let studioRestarting = false;
|
|
18987
|
+
const studioLifecycle = createStudioTurns();
|
|
18988
|
+
const withStudioLifecycle = <T>(operation: () => Promise<T>): Promise<T> => studioLifecycle.run(operation);
|
|
18989
|
+
const isStudioSideQuestionBusy = () => Boolean(studioSideQuestionStartRequestId || studioSideQuestionRuntime?.publicState.status === "running");
|
|
18990
|
+
const studioEditorModeCarryFits = (mode: "single" | "dual", text: string) => mode === "dual"
|
|
18991
|
+
? buildStudioHostingBootstrapState({ text, label: "blank", source: "blank" }).ok : text.length <= STUDIO_EDITOR_MODE_CARRY_MAX_CHARS;
|
|
18992
|
+
const studioEditorModeSwitchRefusal = (client: WebSocket, msg: EditorModeSwitchRequestMessage): string | null => {
|
|
18993
|
+
const state = serverState;
|
|
18994
|
+
if (!state || !state.clients.has(client) || client.readyState !== WebSocket.OPEN) return "Studio isn't running.";
|
|
18995
|
+
if (studioLifecycle.pending() > 0 || studioRestarting) return "Studio is restarting.";
|
|
18996
|
+
if (STUDIO_EDITOR_FLAGS.forced) return "Studio's editor mode is set by environment variables here.";
|
|
18997
|
+
const hosted = hostedConnections.get(client);
|
|
18998
|
+
if (state.clientModes.get(client) !== "full" || studioWatchedClientPaths.has(client) || (STUDIO_DOCUMENT_HOSTING_ENABLED && !hosted)) return "Switch from Studio's main tab.";
|
|
18999
|
+
if (msg.mode === STUDIO_EDITOR_FLAGS.mode) return `Studio is already in ${studioEditorModeLabel(msg.mode)}.`;
|
|
19000
|
+
if (isStudioBusy()) return "Wait for Pi to finish, then switch.";
|
|
19001
|
+
if (isStudioSideQuestionBusy()) return "Wait for the side question to finish, then switch.";
|
|
19002
|
+
// The page named side questions in its question only if it knew of them.
|
|
19003
|
+
if (studioSideQuestionRuntime?.publicState.threadId && !msg.sideQuestions) return "Side questions changed while you were asked. Try again.";
|
|
19004
|
+
if ([...state.clients].some(other => other !== client && other.readyState === WebSocket.OPEN)) return "Close Studio's other tabs first.";
|
|
19005
|
+
if (!studioEditorModeCarryFits(msg.mode, msg.text)) return "The text is too long to carry over. Save it to a file first.";
|
|
19006
|
+
if (STUDIO_DOCUMENT_HOSTING_ENABLED && hosted) {
|
|
19007
|
+
const work = studioBufferStateStore.retainedWork(hosted.workspaceId, msg.text);
|
|
19008
|
+
if (work.unsaved.includes(hosted.workspaceId)) return "The Document has unsaved text. Save it, or add it to the Prompt, before switching.";
|
|
19009
|
+
if (work.pending || work.unsaved.length) return "Another Studio window still has unsaved text, or a move hasn't finished. Save it first.";
|
|
19010
|
+
}
|
|
19011
|
+
return null;
|
|
19012
|
+
};
|
|
19013
|
+
// Runs inside a lifecycle turn. The footer sets studioRestarting when it accepts.
|
|
19014
|
+
const restartStudioInEditorModeNow = async (mode: "single" | "dual", port: number | undefined, listenAll: boolean, token: string, landing: { nonce: string; text: string } | null): Promise<StudioServerState | null> => {
|
|
19015
|
+
const label = studioEditorModeLabel(mode);
|
|
19016
|
+
studioRestarting = true;
|
|
19017
|
+
try {
|
|
19018
|
+
await studioPersistentStateQueue.catch(() => undefined);
|
|
19019
|
+
await stopServerNow();
|
|
19020
|
+
applyStudioEditorMode(mode);
|
|
19021
|
+
pendingEditorModeLanding = null;
|
|
19022
|
+
if (landing) {
|
|
19023
|
+
try {
|
|
19024
|
+
const document: InitialStudioDocument = { text: landing.text, label: "blank", source: "blank", draftId: createStudioDraftId(), resourceDir: studioCwd };
|
|
19025
|
+
let path: string;
|
|
19026
|
+
if (STUDIO_DOCUMENT_HOSTING_ENABLED) {
|
|
19027
|
+
const result = studioBufferStateStore.hostingBootstrap({}, () => ({ ok: true, document, editorLanguage: "markdown" }));
|
|
19028
|
+
if (!result.ok || !("owner" in result) || !result.owner) throw new Error("message" in result ? String(result.message) : "The new workspace could not be registered.");
|
|
19029
|
+
lastHostedFullWorkspaceId = result.owner.workspaceId;
|
|
19030
|
+
path = buildStudioRelativeUrl(token, "full", undefined, undefined, { hostedWorkspaceId: result.owner.workspaceId });
|
|
19031
|
+
} else {
|
|
19032
|
+
path = buildStudioRelativeUrl(token, "full", document, storeTransientStudioDocument(document));
|
|
19033
|
+
}
|
|
19034
|
+
pendingEditorModeLanding = { nonce: landing.nonce, path, expiresAt: Date.now() + 10 * 60 * 1000 };
|
|
19035
|
+
} catch (error) {
|
|
19036
|
+
lastCommandCtx?.ui.notify(`Studio couldn't reopen the tab: ${error instanceof Error ? error.message : String(error)}. The tab keeps its text.`, "warning");
|
|
19037
|
+
}
|
|
19038
|
+
}
|
|
19039
|
+
} finally {
|
|
19040
|
+
studioRestarting = false;
|
|
19041
|
+
}
|
|
19042
|
+
try {
|
|
19043
|
+
const state = await startServerNow(port, listenAll, token);
|
|
19044
|
+
if (state.token !== token) throw new Error("another Studio server started first");
|
|
19045
|
+
lastCommandCtx?.ui.notify(`Studio switched to ${label}.`, "info");
|
|
19046
|
+
if (pendingEditorModeLanding) lastCommandCtx?.ui.notify(`Studio URL: http://${STUDIO_ADVERTISED_HOST}:${state.port}${pendingEditorModeLanding.path}`, "info");
|
|
19047
|
+
return state;
|
|
19048
|
+
} catch (error) {
|
|
19049
|
+
lastCommandCtx?.ui.notify(`Studio is now in ${label}, but it couldn't restart${port ? ` on port ${port}` : ""}: ${error instanceof Error ? error.message : String(error)}. Run /studio to open it.`, "error");
|
|
19050
|
+
return null;
|
|
19051
|
+
}
|
|
19052
|
+
};
|
|
19053
|
+
|
|
19054
|
+
// Oliver, 14 Oct: Studio opened in another browser, or in a tab you can't find, holds the
|
|
19055
|
+
// workspace. Ask that tab to hand over: it sends its text and leaves cleanly, so the new tab
|
|
19056
|
+
// opens without asking. A tab that doesn't answer is disconnected, and the new tab asks the
|
|
19057
|
+
// usual resume question. Single editor mode replaces the tab, as /studio-replace always did.
|
|
19058
|
+
const STUDIO_HANDOVER_WAIT_MS = 5000;
|
|
19059
|
+
// Tabs disconnected without answering: if one wakes and reconnects, it's told it moved,
|
|
19060
|
+
// rather than being offered to move Studio back.
|
|
19061
|
+
const studioMovedAwayCapabilities = new Set<string>();
|
|
19062
|
+
const openFullStudioClients = () => {
|
|
19063
|
+
const state = serverState;
|
|
19064
|
+
return state ? [...state.clients].filter(client => client.readyState === WebSocket.OPEN && (state.clientModes.get(client) ?? "full") === "full" && !studioWatchedClientPaths.has(client)) : [];
|
|
19065
|
+
};
|
|
19066
|
+
// asking: the Move it here page, revalidated here, inside the turn (Sol, candidate70).
|
|
19067
|
+
const handOverFullStudioView = (asking: { state: StudioServerState; capability: string; workspaceId: string } | null = null): Promise<{ ok: true; workspaceId: string | null; clean: boolean } | { ok: false; message: string }> => withStudioLifecycle(async () => {
|
|
19068
|
+
if (asking && (serverState !== asking.state || studioMovedAwayCapabilities.has(asking.capability)
|
|
19069
|
+
|| studioBufferStateStore.capabilityWorkspace(asking.capability)?.workspaceId !== asking.workspaceId)) return { ok: false, message: "This tab can't take over Studio. Run /studio-replace in Pi instead." };
|
|
19070
|
+
if (!serverState) return { ok: true, workspaceId: null, clean: false };
|
|
19071
|
+
const owners = openFullStudioClients();
|
|
19072
|
+
const workspaceId = owners.map(client => hostedConnections.get(client)?.workspaceId).find(Boolean) ?? lastHostedFullWorkspaceId;
|
|
19073
|
+
if (asking && owners.length && workspaceId !== asking.workspaceId) return { ok: false, message: "This tab is for a different workspace. Run /studio-replace in Pi instead." };
|
|
19074
|
+
if (owners.length && (isStudioBusy() || isStudioSideQuestionBusy())) return { ok: false, message: "Wait for Pi to finish, then try again." };
|
|
19075
|
+
await Promise.all(owners.map(client => new Promise<void>(resolve => {
|
|
19076
|
+
let forced: NodeJS.Timeout | null = null;
|
|
19077
|
+
const timer = setTimeout(() => {
|
|
19078
|
+
const hosted = hostedConnections.get(client);
|
|
19079
|
+
if (hosted) { studioMovedAwayCapabilities.add(hosted.capability); studioBufferStateStore.forgetCleanLeave(hosted.workspaceId); }
|
|
19080
|
+
try { client.close(4006, "Studio moved to another tab"); } catch {}
|
|
19081
|
+
forced = setTimeout(() => { try { client.terminate(); } catch {} }, 1500);
|
|
19082
|
+
}, STUDIO_HANDOVER_WAIT_MS);
|
|
19083
|
+
client.once("close", () => { clearTimeout(timer); if (forced) clearTimeout(forced); resolve(); });
|
|
19084
|
+
if (STUDIO_DOCUMENT_HOSTING_ENABLED && hostedConnections.has(client)) sendToClient(client, { type: "workspace_handover_request" });
|
|
19085
|
+
else { clearTimeout(timer); try { client.close(4001, "Full Studio replaced"); } catch { resolve(); } }
|
|
19086
|
+
})));
|
|
19087
|
+
return { ok: true, workspaceId: workspaceId ?? null, clean: Boolean(workspaceId && studioBufferStateStore.hasCleanLeave(workspaceId)) };
|
|
19088
|
+
});
|
|
19089
|
+
// /studio-replace in dual editor mode: move the open Studio (or the last one) here.
|
|
19090
|
+
const moveHostedStudioHere = async (trimmed: string, ctx: ExtensionCommandContext) => {
|
|
19091
|
+
const flags = parseStudioLaunchOpenFlags(trimmed);
|
|
19092
|
+
if (flags.error) { ctx.ui.notify(`${flags.error} Use /studio-replace --help`, "error"); return; }
|
|
19093
|
+
if (flags.args || flags.editorMode) { ctx.ui.notify("In dual editor mode, /studio-replace moves the open Studio here. Open files from Studio's Document controls.", "warning"); return; }
|
|
19094
|
+
lastCommandCtx = ctx;
|
|
19095
|
+
const moved = await handOverFullStudioView();
|
|
19096
|
+
if (!moved.ok) { ctx.ui.notify(moved.message, "warning"); return; }
|
|
19097
|
+
const state = serverState;
|
|
19098
|
+
if (!state || !moved.workspaceId || !studioBufferStateStore.hostedWorkspace(moved.workspaceId)) {
|
|
19099
|
+
await openStudioView(trimmed, ctx, "full", { defaultSource: "last-response", commandLabel: "/studio-replace" });
|
|
19100
|
+
return;
|
|
19101
|
+
}
|
|
19102
|
+
lastHostedFullWorkspaceId = moved.workspaceId;
|
|
19103
|
+
const url = buildStudioUrl(state.port, state.token, "full", undefined, undefined, { hostedWorkspaceId: moved.workspaceId });
|
|
19104
|
+
try {
|
|
19105
|
+
if (shouldAutoOpenStudioBrowser(flags)) await openStudioUrlInBrowser(url);
|
|
19106
|
+
ctx.ui.notify(moved.clean ? "Studio moved here." : "Opening Studio here. The other tab didn't hand over cleanly, so Studio asks before resuming.", "info");
|
|
19107
|
+
} catch (error) {
|
|
19108
|
+
ctx.ui.notify(`Browser opening failed; the workspace is kept: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
19109
|
+
} finally {
|
|
19110
|
+
ctx.ui.notify(`Studio URL: ${url}`, "info");
|
|
19111
|
+
const hint = state.listenAll ? null : buildStudioSshTunnelHint(state.port, url)
|
|
19112
|
+
?? (flags.noBrowser ? buildStudioForwardingHint(state.port, url, { prefix: "Browser auto-open was skipped because --no-browser was used." }) : null);
|
|
19113
|
+
if (hint) ctx.ui.notify(hint, "info");
|
|
19114
|
+
if (state.listenAll) ctx.ui.notify(buildStudioListenAllWarning(state.port, url), "warning");
|
|
19115
|
+
}
|
|
19116
|
+
};
|
|
19117
|
+
|
|
19118
|
+
const stopServer = () => withStudioLifecycle(stopServerNow);
|
|
19119
|
+
const stopServerNow = async () => {
|
|
18277
19120
|
studioSideQuestionStartRequestId = null;
|
|
18278
19121
|
await disposeStudioSideQuestionRuntime();
|
|
18279
19122
|
await closeAllStudioFileWatchers();
|
|
@@ -18310,7 +19153,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
18310
19153
|
studioWatchedClientPaths.clear();
|
|
18311
19154
|
studioFileWatchSubscriptionGenerations.clear();
|
|
18312
19155
|
studioWorkspaceStateStore.clear();
|
|
19156
|
+
studioBufferStateStore.clear();
|
|
19157
|
+
lastHostedFullWorkspaceId = null;
|
|
18313
19158
|
studioResourceGrantRegistry.clear();
|
|
19159
|
+
studioMovedAwayCapabilities.clear();
|
|
18314
19160
|
};
|
|
18315
19161
|
|
|
18316
19162
|
const hydrateLatestAssistant = (entries: SessionEntry[]) => {
|
|
@@ -18401,9 +19247,49 @@ export default function (pi: ExtensionAPI) {
|
|
|
18401
19247
|
broadcastState();
|
|
18402
19248
|
});
|
|
18403
19249
|
|
|
18404
|
-
pi.on("
|
|
19250
|
+
pi.on("input", async (event) => {
|
|
19251
|
+
studioRunLifecycle.input(event.source);
|
|
19252
|
+
const text = normalizePromptText(event.text);
|
|
19253
|
+
if (!text) return;
|
|
19254
|
+
if (text.length > 900_000) { studioPromptInputProvenanceLost = true; return; }
|
|
19255
|
+
while (studioPromptInputs.length && (studioPromptInputs.length >= 16 || studioPromptInputs.reduce((sum, input) => sum + input.text.length, 0) + text.length > 2_700_000)) {
|
|
19256
|
+
studioPromptInputProvenanceLost = true;
|
|
19257
|
+
studioPromptInputs.shift();
|
|
19258
|
+
}
|
|
19259
|
+
const candidates = [...queuedStudioDirectRequests.map(request => ({ ...request, id: request.requestId })),
|
|
19260
|
+
...(activeRequest?.kind === "direct" ? [activeRequest] : [])];
|
|
19261
|
+
const candidate = event.source === "extension" ? candidates.find(request =>
|
|
19262
|
+
normalizePromptText(request.promptTriggerText ?? request.prompt) === text && !studioPromptInputs.some(input => input.requestId === request.id)) : null;
|
|
19263
|
+
studioPromptInputs.push({ text, requestId: candidate?.id ?? null });
|
|
19264
|
+
});
|
|
19265
|
+
pi.on("session_before_compact", async (event) => {
|
|
19266
|
+
// Automatic pre-prompt compaction is already inside the SDK run, even
|
|
19267
|
+
// before agent_start. Manual compaction alone grants no main-run target.
|
|
19268
|
+
if (event.reason !== "manual" && !studioRunLifecycle.snapshot().run) {
|
|
19269
|
+
studioRunLifecycle.start(activeRequest);
|
|
19270
|
+
agentBusy = true;
|
|
19271
|
+
resetStudioTraceForRun();
|
|
19272
|
+
}
|
|
19273
|
+
if (studioRunLifecycle.snapshot().run?.stopping) return { cancel: true };
|
|
19274
|
+
studioRunLifecycle.compactStart(event);
|
|
19275
|
+
broadcastState();
|
|
19276
|
+
});
|
|
19277
|
+
pi.on("session_compact", async () => { studioRunLifecycle.compactEnd(); refreshContextUsage(); broadcastState(); });
|
|
19278
|
+
pi.on("session_compact_failed", async () => { studioRunLifecycle.compactEnd(); broadcastState(); });
|
|
19279
|
+
pi.on("before_provider_request", async (_event, ctx) => {
|
|
19280
|
+
// Also fence delayed observer dispatch before a continued agent's
|
|
19281
|
+
// provider request. This does not pretend to abort the SDK compactor.
|
|
19282
|
+
if (studioRunLifecycle.snapshot().run?.stopping) ctx.abort();
|
|
19283
|
+
});
|
|
19284
|
+
|
|
19285
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
19286
|
+
// ctx.abort does not cancel an already-running SDK compactor. Fence an
|
|
19287
|
+
// automatic continuation of a stopped run at its fresh agent signal.
|
|
19288
|
+
if (studioRunLifecycle.snapshot().run?.stopping) ctx.abort();
|
|
19289
|
+
const continuingRun = studioRunLifecycle.snapshot().run !== null;
|
|
19290
|
+
studioRunLifecycle.start(activeRequest);
|
|
18405
19291
|
agentBusy = true;
|
|
18406
|
-
resetStudioTraceForRun();
|
|
19292
|
+
if (!continuingRun) resetStudioTraceForRun();
|
|
18407
19293
|
emitDebugEvent("agent_start", { activeRequestId: activeRequest?.id ?? null, activeRequestKind: activeRequest?.kind ?? null });
|
|
18408
19294
|
setTerminalActivity("running");
|
|
18409
19295
|
});
|
|
@@ -18487,6 +19373,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
18487
19373
|
const message = event.message as { stopReason?: string; role?: string };
|
|
18488
19374
|
const stopReason = typeof message.stopReason === "string" ? message.stopReason : "";
|
|
18489
19375
|
const role = typeof message.role === "string" ? message.role : "";
|
|
19376
|
+
if (role === "assistant" && studioRunLifecycle.snapshot().run) studioRunLastStopReason = stopReason || null;
|
|
18490
19377
|
const markdown = extractAssistantText(event.message);
|
|
18491
19378
|
const thinking = extractAssistantThinking(event.message);
|
|
18492
19379
|
emitDebugEvent("message_end", {
|
|
@@ -18503,6 +19390,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
18503
19390
|
if (role === "user") {
|
|
18504
19391
|
const userPrompt = extractUserText(event.message);
|
|
18505
19392
|
pendingTurnPrompt = userPrompt;
|
|
19393
|
+
const inputIndex = studioPromptInputs.findIndex(input => input.text === normalizePromptText(userPrompt));
|
|
19394
|
+
const registeredInput = inputIndex >= 0 ? studioPromptInputs.splice(inputIndex, 1)[0] : null;
|
|
18506
19395
|
const activatedQueuedRequest = activateQueuedStudioDirectRequestForPrompt(userPrompt);
|
|
18507
19396
|
if (activatedQueuedRequest) {
|
|
18508
19397
|
emitDebugEvent("activate_queued_request", {
|
|
@@ -18516,6 +19405,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
18516
19405
|
} else {
|
|
18517
19406
|
pendingStudioPromptMetadata = null;
|
|
18518
19407
|
}
|
|
19408
|
+
// Informational input receipt is independent of response/consent ownership:
|
|
19409
|
+
// steering can register while the original response request stays active.
|
|
19410
|
+
const receiptRequestId = registeredInput?.requestId ?? (activeRequest?.kind === "direct" ? activeRequest.id : null);
|
|
19411
|
+
if (receiptRequestId) {
|
|
19412
|
+
const timestamp = (event.message as { timestamp?: unknown }).timestamp;
|
|
19413
|
+
const studioOwned = !studioPromptInputProvenanceLost && registeredInput?.requestId === receiptRequestId;
|
|
19414
|
+
if (studioRunLifecycle.snapshot().run) studioRunInputRequestIds.add(receiptRequestId);
|
|
19415
|
+
broadcast({ type: "prompt_submitted", requestId: receiptRequestId, studioOwned,
|
|
19416
|
+
sentAt: typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0 ? timestamp : Date.now() });
|
|
19417
|
+
}
|
|
18519
19418
|
return;
|
|
18520
19419
|
}
|
|
18521
19420
|
|
|
@@ -18571,7 +19470,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
18571
19470
|
const responseTimestamp = latestItem?.timestamp ?? Date.now();
|
|
18572
19471
|
const responseThinking = latestItem?.thinking ?? thinking ?? null;
|
|
18573
19472
|
pendingTurnPrompt = null;
|
|
18574
|
-
|
|
19473
|
+
// Only settlement ends a tracked run; a message end may precede retry or compaction.
|
|
19474
|
+
if (!studioRunLifecycle.snapshot().run) setStudioTraceRunStatus("complete");
|
|
18575
19475
|
if (latestItem) {
|
|
18576
19476
|
storeStudioTraceSnapshotForResponse(latestItem.id);
|
|
18577
19477
|
}
|
|
@@ -18600,6 +19500,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
18600
19500
|
thinking: lastStudioResponse.thinking,
|
|
18601
19501
|
timestamp: lastStudioResponse.timestamp,
|
|
18602
19502
|
responseHistory: studioResponseHistory,
|
|
19503
|
+
runtimeActivity: studioRunLifecycle.snapshot(),
|
|
19504
|
+
busy: isStudioBusy(),
|
|
18603
19505
|
});
|
|
18604
19506
|
broadcastResponseHistory();
|
|
18605
19507
|
pendingStudioCompletionKind = kind;
|
|
@@ -18627,11 +19529,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
18627
19529
|
thinking: lastStudioResponse.thinking,
|
|
18628
19530
|
timestamp: lastStudioResponse.timestamp,
|
|
18629
19531
|
responseHistory: studioResponseHistory,
|
|
19532
|
+
runtimeActivity: studioRunLifecycle.snapshot(),
|
|
19533
|
+
busy: isStudioBusy(),
|
|
18630
19534
|
});
|
|
18631
19535
|
broadcastResponseHistory();
|
|
18632
19536
|
});
|
|
18633
19537
|
|
|
18634
|
-
|
|
19538
|
+
// agent_end precedes SDK retry/compaction/queued continuation. It is not
|
|
19539
|
+
// final settlement and must not retire a cancellable Studio run.
|
|
19540
|
+
pi.on("agent_settled", async () => {
|
|
19541
|
+
const settlingRun = studioRunLifecycle.snapshot().run;
|
|
19542
|
+
const stopping = settlingRun?.stopping === true;
|
|
19543
|
+
// Settlement, not Stop delivery or a model toolUse pause, decides the outcome.
|
|
19544
|
+
const lastStop = studioRunLastStopReason, owned = settlingRun?.origin === "studio";
|
|
19545
|
+
const ended = lastStop === "stop" || lastStop === "length";
|
|
19546
|
+
const outcome: StudioRunOutcome = stopping || lastStop === "aborted" ? "stopped"
|
|
19547
|
+
: activeRequest || lastStop === "error" || (owned && !ended) ? "failed" : "completed";
|
|
19548
|
+
const outcomeRequestId = settlingRun?.requestId ?? activeRequest?.id ?? null;
|
|
19549
|
+
const outcomeRequestIds = [...new Set([outcomeRequestId, ...studioRunInputRequestIds].filter((id): id is string => Boolean(id)))];
|
|
19550
|
+
studioRunLastStopReason = null; studioRunInputRequestIds.clear();
|
|
19551
|
+
studioRunLifecycle.settled();
|
|
18635
19552
|
agentBusy = false;
|
|
18636
19553
|
pendingTurnPrompt = null;
|
|
18637
19554
|
pendingStudioPromptMetadata = null;
|
|
@@ -18649,10 +19566,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
18649
19566
|
});
|
|
18650
19567
|
clearStudioDirectRunState();
|
|
18651
19568
|
setTerminalActivity("idle");
|
|
18652
|
-
setStudioTraceRunStatus("complete");
|
|
19569
|
+
setStudioTraceRunStatus(outcome === "stopped" ? "stopped" : outcome === "failed" ? "error" : "complete");
|
|
19570
|
+
if (settlingRun && outcomeRequestId) {
|
|
19571
|
+
broadcast({ type: "run_settled", runId: settlingRun.id, requestId: outcomeRequestId, requestIds: outcomeRequestIds, outcome, settledAt: Date.now() });
|
|
19572
|
+
}
|
|
18653
19573
|
if (activeRequest) {
|
|
18654
19574
|
const requestId = activeRequest.id;
|
|
18655
|
-
broadcast({
|
|
19575
|
+
if (!stopping) broadcast({
|
|
18656
19576
|
type: "error",
|
|
18657
19577
|
requestId,
|
|
18658
19578
|
message: "Request ended without a complete assistant response.",
|
|
@@ -18667,8 +19587,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
18667
19587
|
});
|
|
18668
19588
|
|
|
18669
19589
|
pi.on("session_shutdown", async () => {
|
|
19590
|
+
studioRunLastStopReason = null; studioRunInputRequestIds.clear();
|
|
18670
19591
|
lastCommandCtx = null;
|
|
18671
19592
|
latestModelRequestCtx = null;
|
|
19593
|
+
studioRunLifecycle.settled();
|
|
18672
19594
|
agentBusy = false;
|
|
18673
19595
|
clearStudioDirectRunState();
|
|
18674
19596
|
clearPendingStudioCompletion();
|
|
@@ -19075,7 +19997,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
19075
19997
|
themeVars,
|
|
19076
19998
|
},
|
|
19077
19999
|
);
|
|
19078
|
-
await
|
|
20000
|
+
await writeClassicStudioHtml(source.outputPath, html, ctx.cwd);
|
|
19079
20001
|
const openError = await maybeOpenStudioExportPath(source.outputPath, params.open);
|
|
19080
20002
|
return { ok: true, format: "html", path: source.outputPath, source: source.sourceLabel, bytes: html.length, warning, openError: openError ?? undefined };
|
|
19081
20003
|
} catch (error) {
|
|
@@ -19083,6 +20005,147 @@ export default function (pi: ExtensionAPI) {
|
|
|
19083
20005
|
}
|
|
19084
20006
|
};
|
|
19085
20007
|
|
|
20008
|
+
const openHostedStudioView = async (
|
|
20009
|
+
launchArgs: string, ctx: ExtensionCommandContext, flags: StudioLaunchFlags,
|
|
20010
|
+
options?: { defaultSource?: "blank" | "last-response"; commandLabel?: string },
|
|
20011
|
+
) => {
|
|
20012
|
+
if (hasConnectedFullStudioView() && serverState) {
|
|
20013
|
+
ctx.ui.notify("The existing full Studio was kept. Use its opening controls, or /studio-replace to move it here if you can't find it.", "warning");
|
|
20014
|
+
ctx.ui.notify(`Studio URL: ${currentFullStudioUrl(serverState)}`, "info");
|
|
20015
|
+
return;
|
|
20016
|
+
}
|
|
20017
|
+
// Oliver, 14 Oct: plain /studio reopens the last workspace when its tab is gone, asking to
|
|
20018
|
+
// resume only if that tab didn't leave cleanly. --blank and --last start afresh; a file finds its owner.
|
|
20019
|
+
const lastWorkspace = lastHostedFullWorkspaceId;
|
|
20020
|
+
if (!launchArgs && serverState && lastWorkspace && studioBufferStateStore.hostedWorkspace(lastWorkspace)) {
|
|
20021
|
+
const state = serverState;
|
|
20022
|
+
const url = buildStudioUrl(state.port, state.token, "full", undefined, undefined, { hostedWorkspaceId: lastWorkspace });
|
|
20023
|
+
const clean = studioBufferStateStore.hasCleanLeave(lastWorkspace);
|
|
20024
|
+
try {
|
|
20025
|
+
if (shouldAutoOpenStudioBrowser(flags)) await openStudioUrlInBrowser(url);
|
|
20026
|
+
ctx.ui.notify(clean ? "Opening your last Studio workspace." : "Opening your last Studio workspace. Studio asks before resuming, in case its tab had newer edits.", "info");
|
|
20027
|
+
} catch (error) {
|
|
20028
|
+
ctx.ui.notify(`Browser opening failed; the workspace is kept: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
20029
|
+
} finally {
|
|
20030
|
+
ctx.ui.notify(`Studio URL: ${url}`, "info");
|
|
20031
|
+
const hint = state.listenAll ? null : buildStudioSshTunnelHint(state.port, url)
|
|
20032
|
+
?? (flags.noBrowser ? buildStudioForwardingHint(state.port, url, { prefix: "Browser auto-open was skipped because --no-browser was used." }) : null);
|
|
20033
|
+
if (hint) ctx.ui.notify(hint, "info");
|
|
20034
|
+
if (state.listenAll) ctx.ui.notify(buildStudioListenAllWarning(state.port, url), "warning");
|
|
20035
|
+
}
|
|
20036
|
+
return;
|
|
20037
|
+
}
|
|
20038
|
+
let path: string | undefined;
|
|
20039
|
+
if (launchArgs && !["--blank", "blank", "--last", "last"].includes(launchArgs) && !launchArgs.startsWith("-")) {
|
|
20040
|
+
const argument = parsePathArgument(launchArgs);
|
|
20041
|
+
if (!argument) { ctx.ui.notify("Invalid file path argument.", "error"); return; }
|
|
20042
|
+
const resolved = resolveStudioPath(argument, ctx.cwd);
|
|
20043
|
+
if (!resolved.ok) { ctx.ui.notify(resolved.message, "error"); return; }
|
|
20044
|
+
path = resolved.resolved;
|
|
20045
|
+
}
|
|
20046
|
+
try {
|
|
20047
|
+
const state = await ensureServer(flags.port, flags.listenAll);
|
|
20048
|
+
const result = studioBufferStateStore.hostingBootstrap({ path }, () => {
|
|
20049
|
+
const selection = resolveStudioLaunchDocument(launchArgs, ctx, options);
|
|
20050
|
+
if (!selection) return { ok: false, message: "The initial document was not loaded." };
|
|
20051
|
+
return { ok: true, document: selection.document,
|
|
20052
|
+
editorLanguage: selection.document.path ? inferStudioPdfLanguageFromPath(selection.document.path) || "markdown" : "markdown" };
|
|
20053
|
+
});
|
|
20054
|
+
if (!result.ok || !("owner" in result) || !result.owner) {
|
|
20055
|
+
ctx.ui.notify("message" in result ? result.message : "The workspace could not be registered. Existing work was kept.", "warning"); return;
|
|
20056
|
+
}
|
|
20057
|
+
const owner = result.owner;
|
|
20058
|
+
if (result.status === "created" && "document" in result) {
|
|
20059
|
+
initialStudioDocument = result.document;
|
|
20060
|
+
recordStudioDocumentResourceGrants(result.document, ctx.cwd);
|
|
20061
|
+
}
|
|
20062
|
+
if (owner.mode === "full") lastHostedFullWorkspaceId = owner.workspaceId;
|
|
20063
|
+
const url = buildStudioUrl(state.port, state.token, owner.mode as StudioUiMode, undefined, undefined, { hostedWorkspaceId: owner.workspaceId });
|
|
20064
|
+
const live = [...hostedConnections].filter(([socket, value]) => value.workspaceId === owner.workspaceId && socket.readyState === WebSocket.OPEN);
|
|
20065
|
+
try {
|
|
20066
|
+
if (live.length) {
|
|
20067
|
+
for (const [socket] of live) sendToClient(socket, { type: "document_hosting_focus", bufferId: owner.bufferId });
|
|
20068
|
+
ctx.ui.notify("Requested focus in the existing editor. Its unsaved work was kept; no independent copy was opened.", "info");
|
|
20069
|
+
} else if (shouldAutoOpenStudioBrowser(flags)) {
|
|
20070
|
+
await openStudioUrlInBrowser(url);
|
|
20071
|
+
ctx.ui.notify(result.status !== "reused" ? "Opened a registered Studio workspace." : studioBufferStateStore.hasCleanLeave(owner.workspaceId) ? "Opening the retained workspace."
|
|
20072
|
+
: "Opening the retained owner. Confirm resuming if its previous browser may still contain newer work.", "info");
|
|
20073
|
+
} else ctx.ui.notify("Studio workspace is registered. Browser auto-open was skipped; use the same URL to open or resume it.", "info");
|
|
20074
|
+
} catch (error) {
|
|
20075
|
+
ctx.ui.notify(`Browser opening failed; the same workspace is retained: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
20076
|
+
} finally {
|
|
20077
|
+
ctx.ui.notify(`Studio URL: ${url}`, "info");
|
|
20078
|
+
const hint = state.listenAll ? null : buildStudioSshTunnelHint(state.port, url)
|
|
20079
|
+
?? (flags.noBrowser ? buildStudioForwardingHint(state.port, url, { prefix: "Browser auto-open was skipped because --no-browser was used." }) : null);
|
|
20080
|
+
if (hint) ctx.ui.notify(hint, "info");
|
|
20081
|
+
if (state.listenAll) ctx.ui.notify(buildStudioListenAllWarning(state.port, url), "warning");
|
|
20082
|
+
}
|
|
20083
|
+
} catch (error) { ctx.ui.notify(`Studio launch failed; existing work was kept: ${error instanceof Error ? error.message : String(error)}`, "error"); }
|
|
20084
|
+
};
|
|
20085
|
+
|
|
20086
|
+
// /studio --single and --dual: remember the choice, restarting Studio if it's running in
|
|
20087
|
+
// the other mode. Works when the page won't load; open tabs are disconnected, as by --stop.
|
|
20088
|
+
// What a restart would drop that no open tab shows needs a yes first, and the yes covers
|
|
20089
|
+
// only what was named: the final checks rerun inside the lifecycle turn (Sol, candidate69).
|
|
20090
|
+
const studioEditorModeCommandStake = (mode: "single" | "dual"): { key: string; message: string } | null => {
|
|
20091
|
+
if (!serverState || mode === STUDIO_EDITOR_FLAGS.mode) return null;
|
|
20092
|
+
const parts: string[] = [], key: Record<string, unknown> = {};
|
|
20093
|
+
if (STUDIO_DOCUMENT_HOSTING_ENABLED) {
|
|
20094
|
+
const work = studioBufferStateStore.retainedWork();
|
|
20095
|
+
const live = new Set([...hostedConnections].filter(([socket]) => socket.readyState === WebSocket.OPEN).map(([, value]) => value.workspaceId));
|
|
20096
|
+
const offline = work.unsaved.filter(id => !live.has(id)).sort();
|
|
20097
|
+
if (work.pending || offline.length) {
|
|
20098
|
+
parts.push("Studio holds unsaved text that isn't open in a tab, or a save or move hasn't finished. Restarting Studio loses it.");
|
|
20099
|
+
key.work = { pending: work.pendingKeys, offline: offline.map(id => [id, studioBufferStateStore.hostedWorkspace(id)?.revision ?? null]) };
|
|
20100
|
+
}
|
|
20101
|
+
}
|
|
20102
|
+
const side = studioSideQuestionRuntime?.publicState;
|
|
20103
|
+
if (side?.threadId) {
|
|
20104
|
+
parts.push("Side questions are cleared.");
|
|
20105
|
+
key.side = [side.threadId, side.messages.length, side.status];
|
|
20106
|
+
}
|
|
20107
|
+
return parts.length ? { key: JSON.stringify(key), message: parts.join(" ") + " Switch anyway?" } : null;
|
|
20108
|
+
};
|
|
20109
|
+
const chooseStudioEditorMode = async (mode: "single" | "dual", flags: StudioLaunchFlags, ctx: ExtensionCommandContext): Promise<boolean> => {
|
|
20110
|
+
const label = studioEditorModeLabel(mode);
|
|
20111
|
+
const save = () => {
|
|
20112
|
+
try { writeStudioEditorMode(STUDIO_EDITOR_MODE_PATH, mode); return true; }
|
|
20113
|
+
catch (error) { ctx.ui.notify(`Couldn't save the editor mode setting: ${error instanceof Error ? error.message : String(error)}`, "error"); return false; }
|
|
20114
|
+
};
|
|
20115
|
+
let consented: string | null = null;
|
|
20116
|
+
for (;;) {
|
|
20117
|
+
if (STUDIO_EDITOR_FLAGS.forced) { ctx.ui.notify("Studio's editor mode is set by PI_STUDIO_* environment variables here. Unset them to choose a mode.", "warning"); return false; }
|
|
20118
|
+
const stake = studioEditorModeCommandStake(mode);
|
|
20119
|
+
if (stake && stake.key !== consented) {
|
|
20120
|
+
if (!await ctx.ui.confirm(`Switch to ${label}?`, stake.message)) { ctx.ui.notify("Nothing changed.", "info"); return false; }
|
|
20121
|
+
consented = stake.key;
|
|
20122
|
+
continue; // Anything may have changed while asking.
|
|
20123
|
+
}
|
|
20124
|
+
const outcome = await withStudioLifecycle(async (): Promise<"done" | "failed" | "changed"> => {
|
|
20125
|
+
if (STUDIO_EDITOR_FLAGS.forced) return "changed";
|
|
20126
|
+
const now = studioEditorModeCommandStake(mode);
|
|
20127
|
+
if (now && now.key !== consented) return "changed";
|
|
20128
|
+
if (mode === STUDIO_EDITOR_FLAGS.mode) return save() ? "done" : "failed";
|
|
20129
|
+
if (!serverState) {
|
|
20130
|
+
if (!save()) return "failed";
|
|
20131
|
+
applyStudioEditorMode(mode);
|
|
20132
|
+
ctx.ui.notify(`Studio is now in ${label}.`, "info");
|
|
20133
|
+
return "done";
|
|
20134
|
+
}
|
|
20135
|
+
if (isStudioBusy() || isStudioSideQuestionBusy()) { ctx.ui.notify("Wait for Pi to finish, then switch.", "warning"); return "failed"; }
|
|
20136
|
+
if (!save()) return "failed";
|
|
20137
|
+
const previous = serverState, open = getStudioClientCounts();
|
|
20138
|
+
flags.port ??= previous.port;
|
|
20139
|
+
flags.listenAll ||= previous.listenAll;
|
|
20140
|
+
lastCommandCtx = ctx;
|
|
20141
|
+
if (!await restartStudioInEditorModeNow(mode, flags.port, flags.listenAll, createSessionToken(), null)) return "failed";
|
|
20142
|
+
if (open.full + open.editorOnly) ctx.ui.notify("Studio's open tabs were disconnected. Their text is still on screen.", "warning");
|
|
20143
|
+
return "done";
|
|
20144
|
+
});
|
|
20145
|
+
if (outcome !== "changed") return outcome === "done";
|
|
20146
|
+
}
|
|
20147
|
+
};
|
|
20148
|
+
|
|
19086
20149
|
const openStudioView = async (
|
|
19087
20150
|
trimmed: string,
|
|
19088
20151
|
ctx: ExtensionCommandContext,
|
|
@@ -19095,6 +20158,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
19095
20158
|
return;
|
|
19096
20159
|
}
|
|
19097
20160
|
const launchArgs = launchOpenFlags.args;
|
|
20161
|
+
if (launchOpenFlags.editorMode && !await chooseStudioEditorMode(launchOpenFlags.editorMode, launchOpenFlags, ctx)) return;
|
|
19098
20162
|
if (serverState && launchOpenFlags.port && serverState.port !== launchOpenFlags.port) {
|
|
19099
20163
|
ctx.ui.notify(`Studio server is already running on port ${serverState.port}; requested port ${launchOpenFlags.port}. Use /studio --stop, then restart Studio with --port ${launchOpenFlags.port} to change it.`, "warning");
|
|
19100
20164
|
}
|
|
@@ -19113,13 +20177,38 @@ export default function (pi: ExtensionAPI) {
|
|
|
19113
20177
|
}
|
|
19114
20178
|
const launchesWatchedTextPreview = Boolean(launchOpenFlags.watchPdf && parsedLaunchPath && !launchesPdfPreview);
|
|
19115
20179
|
const requestedLaunchMode: StudioUiMode = launchesPdfPreview || launchesWatchedTextPreview ? "editor-only" : mode;
|
|
20180
|
+
if (STUDIO_DOCUMENT_HOSTING_ENABLED && launchesPdfPreview && !launchOpenFlags.watchPdf && !options?.replaceExistingFull) {
|
|
20181
|
+
await ctx.waitForIdle();
|
|
20182
|
+
try {
|
|
20183
|
+
const target = parseStudioPdfLaunchTarget(normalizePathInput(parsedLaunchPath!));
|
|
20184
|
+
if (!target) throw new Error("A local PDF is required.");
|
|
20185
|
+
// The explicit terminal argument approves exactly this file, never its
|
|
20186
|
+
// folder or an editable owner. Existing full-workspace buffers stay put.
|
|
20187
|
+
const grant = studioResourceGrantRegistry.grantFile(decodeStudioLocalPreviewResourceReference(target.path), { cwd: ctx.cwd, source: "command-media" });
|
|
20188
|
+
// The grant is canonical; this resolver accepts a URI reference.
|
|
20189
|
+
const pdf = resolveStudioPdfResourcePath(encodeURIComponent(grant.path), undefined, undefined, ctx.cwd, studioResourceGrantRegistry);
|
|
20190
|
+
const state = await ensureServer(launchOpenFlags.port, launchOpenFlags.listenAll);
|
|
20191
|
+
const url = `http://127.0.0.1:${state.port}` + buildStudioReadOnlyMediaUrl({ token: state.token, kind: "pdf", path: pdf, resourceDir: ctx.cwd, page: target.page });
|
|
20192
|
+
const automatic = shouldAutoOpenStudioBrowser({ openRemoteBrowser: launchOpenFlags.openRemoteBrowser, noBrowser: launchOpenFlags.noBrowser });
|
|
20193
|
+
if (automatic) await openStudioUrlInBrowser(url);
|
|
20194
|
+
ctx.ui.notify(`Read-only PDF preview${automatic ? " requested" : " ready; browser auto-open skipped"}: ${url}`, "info");
|
|
20195
|
+
const hint = state.listenAll ? null : buildStudioSshTunnelHint(state.port, url) ?? (launchOpenFlags.noBrowser ? buildStudioForwardingHint(state.port, url) : null);
|
|
20196
|
+
if (hint) ctx.ui.notify(hint, "info");
|
|
20197
|
+
} catch (error) { ctx.ui.notify(`PDF preview failed: ${error instanceof Error ? error.message : String(error)}`, "error"); }
|
|
20198
|
+
return;
|
|
20199
|
+
}
|
|
20200
|
+
const hostingError = studioHostingCommandError({ enabled: STUDIO_DOCUMENT_HOSTING_ENABLED, mode: requestedLaunchMode,
|
|
20201
|
+
replace: options?.replaceExistingFull, watchedText: launchesWatchedTextPreview });
|
|
20202
|
+
if (hostingError) { ctx.ui.notify(hostingError, "warning"); return; }
|
|
19116
20203
|
if (requestedLaunchMode === "full" && hasConnectedFullStudioView()) {
|
|
19117
20204
|
if (options?.replaceExistingFull) {
|
|
19118
20205
|
closeStudioClientsByMode("full", 4001, "Full Studio replaced");
|
|
19119
20206
|
} else {
|
|
19120
|
-
ctx.ui.notify(
|
|
20207
|
+
ctx.ui.notify(STUDIO_DOCUMENT_HOSTING_ENABLED
|
|
20208
|
+
? "A full Studio view is already open. Use its Open Document or New editor tab controls; existing work was kept."
|
|
20209
|
+
: "A full pi Studio view is already open for this session. Close it first, use /studio-replace for a fresh full Studio view, or use /studio-editor-only for a concurrent editor-only Studio view.", "warning");
|
|
19121
20210
|
if (serverState) {
|
|
19122
|
-
const url =
|
|
20211
|
+
const url = currentFullStudioUrl(serverState);
|
|
19123
20212
|
ctx.ui.notify(`Studio URL: ${url}`, "info");
|
|
19124
20213
|
const tunnelHint = serverState.listenAll
|
|
19125
20214
|
? null
|
|
@@ -19147,6 +20236,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
19147
20236
|
// ignore theme read errors
|
|
19148
20237
|
}
|
|
19149
20238
|
|
|
20239
|
+
if (STUDIO_DOCUMENT_HOSTING_ENABLED && requestedLaunchMode === "full") {
|
|
20240
|
+
await openHostedStudioView(launchArgs, ctx, launchOpenFlags, options);
|
|
20241
|
+
return;
|
|
20242
|
+
}
|
|
19150
20243
|
const selection = resolveStudioLaunchDocument(launchArgs, ctx, {
|
|
19151
20244
|
...options,
|
|
19152
20245
|
watchPdf: launchOpenFlags.watchPdf && launchesPdfPreview,
|
|
@@ -19220,7 +20313,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
19220
20313
|
};
|
|
19221
20314
|
|
|
19222
20315
|
pi.registerCommand("studio", {
|
|
19223
|
-
description: "Open pi Studio browser UI or a read-only watched preview (/studio, /studio <file>, /studio --watch <file>, /studio --blank, /studio --last, /studio --no-browser, /studio --listen-all)",
|
|
20316
|
+
description: "Open pi Studio browser UI or a read-only watched preview (/studio, /studio <file>, /studio --watch <file>, /studio --blank, /studio --last, /studio --no-browser, /studio --listen-all, /studio --single, /studio --dual)",
|
|
19224
20317
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
19225
20318
|
const trimmed = args.trim();
|
|
19226
20319
|
|
|
@@ -19236,7 +20329,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
19236
20329
|
return;
|
|
19237
20330
|
}
|
|
19238
20331
|
const counts = getStudioClientCounts();
|
|
19239
|
-
const url =
|
|
20332
|
+
const url = currentFullStudioUrl(serverState);
|
|
19240
20333
|
ctx.ui.notify(
|
|
19241
20334
|
`Studio running at ${url} (listening on ${serverState.bindHost}:${serverState.port}; busy: ${isStudioBusy() ? "yes" : "no"}; full views: ${counts.full}; editor-only views: ${counts.editorOnly})`,
|
|
19242
20335
|
"info",
|
|
@@ -19250,7 +20343,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
19250
20343
|
if (trimmed === "help" || trimmed === "--help" || trimmed === "-h") {
|
|
19251
20344
|
ctx.ui.notify(
|
|
19252
20345
|
"Usage: /studio [path|--blank|--last]\n"
|
|
19253
|
-
+ " /studio Open studio with last model response (fallback: blank)\n"
|
|
20346
|
+
+ " /studio Open studio with last model response (fallback: blank); in dual editor mode, reopen your last workspace if its tab is gone\n"
|
|
19254
20347
|
+ " /studio <path> Open a text file in Studio, or a PDF in a read-only companion preview\n"
|
|
19255
20348
|
+ " /studio --watch <file> Open a read-only text preview that follows disk changes, or a PDF with auto-refresh\n"
|
|
19256
20349
|
+ " /studio --blank Open with blank editor\n"
|
|
@@ -19261,6 +20354,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
19261
20354
|
+ " /studio --open-remote Over SSH, open the remote browser anyway\n"
|
|
19262
20355
|
+ " /studio --status Show studio status\n"
|
|
19263
20356
|
+ " /studio --stop Stop studio server\n"
|
|
20357
|
+
+ " /studio --single Use single editor mode (one editor), and remember it\n"
|
|
20358
|
+
+ " /studio --dual Use dual editor mode (Prompt and Document), the default\n"
|
|
19264
20359
|
+ " Note: only one full /studio view is allowed per Pi session; PDF previews open as companions.\n"
|
|
19265
20360
|
+ " /studio-replace [path] Replace the current full Studio view with a new one\n"
|
|
19266
20361
|
+ " /studio-editor-only [path] Open another Studio tab in editor-only mode\n"
|
|
@@ -19290,12 +20385,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
19290
20385
|
+ " /studio-replace --no-browser Print URL without opening a browser\n"
|
|
19291
20386
|
+ " /studio-replace --port <port> Bind Studio to a fixed port when starting\n"
|
|
19292
20387
|
+ " /studio-replace --listen-all Explicitly bind to 0.0.0.0 instead of localhost\n"
|
|
19293
|
-
+ "Editor-only Studio views stay open
|
|
20388
|
+
+ "Editor-only Studio views stay open.\n"
|
|
20389
|
+
+ "In dual editor mode, /studio-replace moves the open Studio (or the last one) to a new tab here: the old tab hands over its text.",
|
|
19294
20390
|
"info",
|
|
19295
20391
|
);
|
|
19296
20392
|
return;
|
|
19297
20393
|
}
|
|
19298
20394
|
|
|
20395
|
+
if (STUDIO_DOCUMENT_HOSTING_ENABLED) { await moveHostedStudioHere(trimmed, ctx); return; }
|
|
19299
20396
|
await openStudioView(trimmed, ctx, "full", {
|
|
19300
20397
|
defaultSource: "last-response",
|
|
19301
20398
|
commandLabel: "/studio-replace",
|
|
@@ -19535,7 +20632,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
19535
20632
|
themeVars,
|
|
19536
20633
|
},
|
|
19537
20634
|
);
|
|
19538
|
-
await
|
|
20635
|
+
await writeClassicStudioHtml(outputPath, html, ctx.cwd);
|
|
19539
20636
|
|
|
19540
20637
|
let openError: string | null = null;
|
|
19541
20638
|
try {
|
|
@@ -19603,7 +20700,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
19603
20700
|
themeVars,
|
|
19604
20701
|
},
|
|
19605
20702
|
);
|
|
19606
|
-
await
|
|
20703
|
+
await writeClassicStudioHtml(outputPath, html, ctx.cwd);
|
|
19607
20704
|
|
|
19608
20705
|
let openError: string | null = null;
|
|
19609
20706
|
try {
|
|
@@ -19647,6 +20744,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
19647
20744
|
return;
|
|
19648
20745
|
}
|
|
19649
20746
|
|
|
20747
|
+
const hostingError = studioHostingCommandError({ enabled: STUDIO_DOCUMENT_HOSTING_ENABLED, mode: "full", current: true });
|
|
20748
|
+
if (hostingError) { ctx.ui.notify(hostingError, "warning"); return; }
|
|
19650
20749
|
const file = readStudioFile(pathArg, ctx.cwd);
|
|
19651
20750
|
if (file.ok === false) {
|
|
19652
20751
|
ctx.ui.notify(file.message, "error");
|