pi-studio 0.9.58 → 0.9.60-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,23 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.60-rc.0] — 2026-09-03
8
+
9
+ ### Added
10
+ - Add explicit `/studio --listen-all` support for container and trusted private-network workflows. Studio binds to `0.0.0.0` only when requested, retains token authentication, keeps generated browser URLs on `127.0.0.1` for same-port host publishing, reports the active listening address in `/studio --status`, and requires a stop/restart before changing a running localhost server to wildcard binding.
11
+
12
+ ### Security
13
+ - Wildcard network binding remains opt-in and emits a prominent warning that the tokenized URL grants control of Studio for the Pi process, including prompt submission and broad file access through Studio workflows. HTTP routing no longer derives its base authority from the inbound `Host` header. In wildcard mode, a browser-supplied HTTP(S) `Origin` must match that request's `Host`; originless non-browser or embedded clients continue to rely on the URL token. Documentation recommends loopback-only host port publishing, forbids direct public-internet exposure, and retains localhost plus SSH forwarding as the safe default.
14
+
15
+ ## [0.9.59] — 2026-09-03
16
+
17
+ ### Added
18
+ - Add `Cmd/Ctrl+Shift+L` for loading the current Pi terminal input draft into Studio without clearing it.
19
+ - Add a confirmed **Clear Pi editor text…** action for removing text waiting in Pi's terminal input editor without changing Studio text or conversation history.
20
+
21
+ ### Changed
22
+ - Treat Pi terminal drafts loaded into Studio or staged from Studio as transient linked handoffs. An accepted **Run editor text** now clears the terminal draft only when its SHA-256 fingerprint and byte length still match; later terminal typing, added `pi-nvim-context` context, another client change, and failed submissions all preserve it.
23
+
7
24
  ## [0.9.58] — 2026-09-03
8
25
 
9
26
  ### Added
package/README.md CHANGED
@@ -35,6 +35,7 @@ _The video shows an earlier version of the Studio interface. The basic workflow
35
35
  - Supports one canonical full Studio view per Pi session, plus additional editor-only companion views when you want extra editing/preview surfaces; editor-only views can also browse files and use the Studio REPL send controls without taking over the full Studio session view
36
36
  - Includes a global **Zen** mode that hides the Studio header and secondary chrome without changing the current pane layout; the header can also be hidden independently and restored from the top-right edge
37
37
  - Runs editor text directly, asks for structured critique (auto/writing/code focus), offers explicit **Show me** actions for a one-turn compact visual or structural explanation of the editor selection/document, displayed response, or current conversation topic, provides a manual **Suggest completion** action for short cursor-aware continuations (`Option/Alt+Tab` where available or `Cmd/Ctrl+Shift+Space` from the editor, `Tab` to insert a visible suggestion) with an optional editor-plus-latest-response context mode, or opens **Quiz me** for a Studio-native active-recall loop over the current editor text, selection, current file, folder, or repo, with optional focus guidance for shaping question selection
38
+ - Treats text copied from or staged in Pi's terminal input editor as a linked draft: ordinary Studio edits retain the link, and an accepted **Run editor text** clears the terminal draft only when it has not changed in the meantime; a confirmed **Clear Pi editor text…** action remains available explicitly
38
39
  - Adds a right-pane **Side questions** thread for contextual questions that stay outside the main Pi conversation unless explicitly promoted. It can start with the editor selection, the Markdown/LaTeX heading block at the cursor, nearby unstructured text, the whole editor, the displayed response, or no starting text; independently, it can use read-only tools to map, search, and read the document folder, repository, or another chosen folder—including extracted PDF, DOCX, ODT, and EPUB text—and can optionally search the web through Brave Search.
39
40
  - Includes a live **Working** view for following current model/tool activity, with `All` / `Thinking` / `Tools` filters, image previews for image-producing tool outputs, plus **Load visible into editor** and **Copy visible** actions; optional browser-stored **Follow activity** (off by default under **View → Workspace**) switches to Working when the main Pi agent starts generating and to Response Preview when it completes, unless you manually choose another view during that request. When cycling response history, Working follows saved working details for the selected response when available, and `Cmd/Ctrl+Alt+1–8` switches directly between right-pane views while `Cmd/Ctrl+Alt+P` / `Cmd/Ctrl+Alt+E` / `Cmd/Ctrl+Alt+W` / `Cmd/Ctrl+Alt+F` / `Cmd/Ctrl+Alt+R` / `Cmd/Ctrl+Alt+Q` keep quick mnemonic shortcuts for Response Preview, Editor Preview, Working, Files, REPL, and Side questions
40
41
  - Includes a right-pane **Changes** view for browsing the current git diff by file, previewing per-file diffs, opening changed files, loading the full diff into the editor, and copying the diff
@@ -66,6 +67,12 @@ _The video shows an earlier version of the Studio interface. The basic workflow
66
67
  - Exports local files headlessly via `/studio-pdf <path>` to `<name>.studio.pdf` or `/studio-html <path>` to `<name>.studio.html`; without a path, those commands export the last model response to a timestamped file. Agent tools `studio_export_pdf` and `studio_export_html` expose the same export pipeline for remote/Telegram-style sessions.
67
68
  - Shows model/session/context usage in the footer, plus compact-context and active-theme controls
68
69
 
70
+ ## Pi input-draft handoff
71
+
72
+ Tools such as [`pi-nvim-context`](https://github.com/omaclaren/pi-nvim-context) deliberately accumulate visible context in Pi's terminal input draft without submitting it. **Load from pi editor** (`Cmd/Ctrl+Shift+L`) copies that draft into Studio without clearing it and records only a transient SHA-256 fingerprint and byte length for the handoff—no additional snapshot copy beyond the text already in Studio. You can then edit the text freely in Studio.
73
+
74
+ When Pi accepts **Run editor text**, Studio clears the linked terminal draft only if its current fingerprint and byte length still match those captured at load time. Terminal typing, additional Neovim context, or another client changing the draft causes Studio to preserve it silently. A failed or rejected run also leaves it intact. **Send current text to Pi editor** establishes the same safe link, while **Clear Pi editor text…** provides a separate confirmed, unconditional cleanup action. Studio never clears terminal history or its own editor through this handoff.
75
+
69
76
  ## Commands
70
77
 
71
78
  | Command | Description |
@@ -76,8 +83,9 @@ _The video shows an earlier version of the Studio interface. The basic workflow
76
83
  | `/studio --last` | Force last response |
77
84
  | `/studio --blank` | Force blank editor |
78
85
  | `/studio --no-browser` | Start/print the Studio URL without opening a browser, useful for forwarded or phone/browser sessions |
79
- | `/studio --port <port>` | Bind Studio to a fixed localhost port instead of a random free port |
80
- | `/studio --status` | Show studio server status |
86
+ | `/studio --port <port>` | Bind Studio to a fixed port instead of a random free port; the default host remains localhost |
87
+ | `/studio --listen-all` | Explicitly bind Studio to all IPv4 interfaces (`0.0.0.0`) for a container or trusted private-network workflow |
88
+ | `/studio --status` | Show studio server status, including its listening address |
81
89
  | `/studio --stop` | Stop studio server |
82
90
  | `/studio --help` | Show help |
83
91
  | `/studio-replace [path\|--blank\|--last]` | Replace the current full Studio view with a new full Studio view |
@@ -111,6 +119,18 @@ Run once without installing:
111
119
  pi -e https://github.com/omaclaren/pi-studio
112
120
  ```
113
121
 
122
+ ## Container and network access
123
+
124
+ Studio binds to `127.0.0.1` by default. For a container or sandbox whose port must be published to the host, opt in explicitly and choose a stable port:
125
+
126
+ ```text
127
+ /studio --no-browser --listen-all --port 4321
128
+ ```
129
+
130
+ This listens on `0.0.0.0:4321` while still printing a tokenized `http://127.0.0.1:4321/...` browser URL. With a same-port mapping such as `sbx run --publish 4321:4321`, open that URL unchanged on the host. `--listen-all` is a server-lifetime setting: if Studio is already running on localhost, use `/studio --stop` before restarting it with the flag.
131
+
132
+ Treat wildcard binding as a security-sensitive mode. The URL token remains required, but anyone who obtains the full URL can control Studio for that Pi process: they can submit prompts, read or write files through Studio, and expand local-resource access. Studio does not add TLS. Treat the URL like a password, never expose the port directly to the public internet, and bind the host side of a container mapping to loopback when possible—for example, `-p 127.0.0.1:4321:4321`. For access across an untrusted network, keep Studio on its localhost default and use SSH local port forwarding instead.
133
+
114
134
  ## Shared REPL record
115
135
 
116
136
  Studio remains a standalone REPL client: it can create and use its own tmux sessions without `pi-repl`. When a compatible `pi-repl` installation uses the same tmux session, both clients automatically discover one session-owned clean record and see each other's code and captured output. No package imports or depends on the other.
@@ -213,7 +233,7 @@ Thanks to [Hal Gumbert / CampSoftware](https://github.com/campsoftware) for publ
213
233
 
214
234
  ## Notes
215
235
 
216
- - Local-only server (`127.0.0.1`) with tokenized Studio URLs.
236
+ - The Studio server is localhost-only (`127.0.0.1`) by default and every Studio URL carries a server-lifetime token. The explicit `--listen-all` option changes the listening address to `0.0.0.0` without changing the advertised loopback URL or removing token authentication; `/studio --status` reports the active binding.
217
237
  - When Pi runs inside Muxy or cmux, Studio opens in that terminal app’s built-in browser. Muxy is detected from its pane/socket environment without installing global hooks; cmux targets and focuses the caller’s workspace. If the detected terminal browser is unavailable, disabled, or declines the request, Studio falls back once to the system browser.
218
238
  - For remote SSH sessions, keep Studio bound to localhost and use SSH local port forwarding; `/studio` and `/studio --status` print the full tokenized localhost URL. The SSH hint repeats the full URL so it is visible even if your terminal only shows the latest notification. Open that URL through the tunnel, preserving the `?token=...` parameter. If SSH is not auto-detected, use `/studio --no-browser`; for stable forwarding, use `/studio --port <port>` or combine them, e.g. `/studio --no-browser --port 3417`.
219
239
  - Full Studio is a singleton per Pi session: use `/studio` to open it, `/studio-replace` to explicitly replace it, and `/studio-editor-only` for extra editing/preview tabs that do not take over the full Studio session view.
package/ROADMAP.md CHANGED
@@ -106,6 +106,27 @@ A small interaction increment before the buffer-first architecture:
106
106
  - add opt-in activity following from Working during main-agent generation to Response Preview on completion, disabled by default and cancelled when the user manually changes views;
107
107
  - keep Quick send separate from the durable editor, the raw tmux mirror read-only, and protocol-v1 authority unchanged.
108
108
 
109
+ ## 0.9.59 — Safe Pi input-draft handoff (shipped 2026-09-03)
110
+
111
+ A small workflow refinement for moving Neovim-provided context through Pi and Studio:
112
+
113
+ - load or stage Pi's terminal input draft without clearing it, retaining only a transient SHA-256 fingerprint and byte length for provenance;
114
+ - keep that link through unrestricted Studio edits, then clear the original Pi draft only after Pi accepts the Studio run and the current terminal text still matches exactly;
115
+ - silently preserve later terminal typing, added `pi-nvim-context` context, or another client's changes, and never clear after a rejected submission;
116
+ - provide `Cmd/Ctrl+Shift+L` for loading and a confirmed **Clear Pi editor text…** fallback without changing Studio text or conversation history;
117
+ - keep `pi-nvim-context`, protocol v1, networking, and the buffer-first architecture unchanged.
118
+
119
+ ## 0.9.60 — Opt-in container/network binding (`0.9.60-rc.0` prerelease, 2026-09-03)
120
+
121
+ A bounded implementation of GitHub issue #4, separate from REPL transcript and buffer work:
122
+
123
+ - retain `127.0.0.1` as the default and require explicit `--listen-all` intent before binding to `0.0.0.0`;
124
+ - keep the server-lifetime bearer token mandatory and the generated URL on loopback for same-port container publishing;
125
+ - keep inbound `Host` values out of route parsing and require wildcard-mode browser origins to match the request authority;
126
+ - report the actual listening address, emit a prominent exposure warning, and require `/studio --stop` before changing a running server's binding;
127
+ - document loopback-only host publishing, private-network limits, and SSH forwarding as the safer option on untrusted networks;
128
+ - validate both default loopback and opt-in wildcard sockets without changing the buffer model, protocol v1, or `pi-repl`.
129
+
109
130
  ## 0.10.0 — Buffer-first editing
110
131
 
111
132
  The first architectural `0.10` release should add:
@@ -121,6 +121,7 @@
121
121
  const refreshFromDiskBtn = document.getElementById("refreshFromDiskBtn");
122
122
  const clearWorkspaceBtn = document.getElementById("clearWorkspaceBtn");
123
123
  const sendEditorBtn = document.getElementById("sendEditorBtn");
124
+ const clearPiEditorBtn = document.getElementById("clearPiEditorBtn");
124
125
  const openCompanionBtn = document.getElementById("openCompanionBtn");
125
126
  const getEditorBtn = document.getElementById("getEditorBtn");
126
127
  const studioHeaderEl = document.getElementById("studioHeader");
@@ -361,6 +362,9 @@
361
362
  const pendingSaveOperations = new Map();
362
363
  const pendingCompanionLaunches = new Map();
363
364
  const activeStudioTabLaunches = new Set();
365
+ // Deliberately page-memory-only: never persist Pi input-draft handoff fingerprints.
366
+ const pendingPiEditorDraftSnapshots = new Map();
367
+ let linkedPiEditorDraftSnapshot = null;
364
368
  let sourceOriginSummaryEl = null;
365
369
  let sourceResetOriginBtn = null;
366
370
  let sourceOpenCurrentFileTabBtn = null;
@@ -3186,7 +3190,9 @@
3186
3190
  if (sendEditorBtn) sendEditorBtn.textContent = "Send current text to Pi editor";
3187
3191
  appendStudioUiRefreshMenuSection(contextMenu.menu, "Document", [sourceOriginSummaryEl, sourceResetOriginBtn, sourceOpenCurrentFileTabBtn, sourceOpenCurrentTextCopyTabBtn]);
3188
3192
  appendStudioUiRefreshMenuSection(contextMenu.menu, "Working directory", [resourceDirBtn, resourceDirLabel, resourceDirInputWrap]);
3189
- if (!isEditorOnlyMode && sendEditorBtn) appendStudioUiRefreshMenuSection(contextMenu.menu, "Pi editor", [sendEditorBtn]);
3193
+ if (!isEditorOnlyMode) {
3194
+ appendStudioUiRefreshMenuSection(contextMenu.menu, "Pi editor", [sendEditorBtn, clearPiEditorBtn]);
3195
+ }
3190
3196
  const cursorContextBtn = makeStudioUiRefreshElement("button", "completion-context-option", "Editor only");
3191
3197
  cursorContextBtn.type = "button";
3192
3198
  cursorContextBtn.setAttribute("data-completion-context-mode", "cursor");
@@ -3545,6 +3551,7 @@
3545
3551
  if (kind === "compact") return "compacting context";
3546
3552
  if (kind === "send_to_editor") return "sending to pi editor";
3547
3553
  if (kind === "get_from_editor") return "loading from pi editor";
3554
+ if (kind === "clear_pi_editor") return "clearing Pi editor text";
3548
3555
  if (kind === "load_git_diff") return "loading git diff";
3549
3556
  if (kind === "open_editor_only") return "opening companion editor";
3550
3557
  if (kind === "refresh_from_disk") return "refreshing from disk";
@@ -3803,6 +3810,7 @@
3803
3810
  if (kind === "compact") return "Compacting…";
3804
3811
  if (kind === "send_to_editor") return "Sending to editor…";
3805
3812
  if (kind === "get_from_editor") return "Loading from editor…";
3813
+ if (kind === "clear_pi_editor") return "Clearing Pi editor text…";
3806
3814
  if (kind === "load_git_diff") return "Loading git diff…";
3807
3815
  if (kind === "refresh_from_disk") return "Refreshing from disk…";
3808
3816
  if (kind === "save_as" || kind === "save_over") return "Saving…";
@@ -4464,6 +4472,58 @@
4464
4472
  return /^sha256:[a-f0-9]{64}$/.test(revision) ? revision : null;
4465
4473
  }
4466
4474
 
4475
+ function normalizePiEditorDraftSnapshot(value) {
4476
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
4477
+ const fingerprint = normalizeStudioDiskRevision(value.fingerprint);
4478
+ const byteLength = value.byteLength;
4479
+ if (!fingerprint || typeof byteLength !== "number" || !Number.isSafeInteger(byteLength) || byteLength < 0) return null;
4480
+ return { fingerprint, byteLength };
4481
+ }
4482
+
4483
+ function piEditorDraftSnapshotsMatch(left, right) {
4484
+ const normalizedLeft = normalizePiEditorDraftSnapshot(left);
4485
+ const normalizedRight = normalizePiEditorDraftSnapshot(right);
4486
+ return Boolean(
4487
+ normalizedLeft
4488
+ && normalizedRight
4489
+ && normalizedLeft.fingerprint === normalizedRight.fingerprint
4490
+ && normalizedLeft.byteLength === normalizedRight.byteLength
4491
+ );
4492
+ }
4493
+
4494
+ function setLinkedPiEditorDraftSnapshot(value, hasContent) {
4495
+ linkedPiEditorDraftSnapshot = hasContent ? normalizePiEditorDraftSnapshot(value) : null;
4496
+ }
4497
+
4498
+ function clearLinkedPiEditorDraftSnapshot() {
4499
+ linkedPiEditorDraftSnapshot = null;
4500
+ }
4501
+
4502
+ function reserveLinkedPiEditorDraftSnapshot(requestId) {
4503
+ const snapshot = normalizePiEditorDraftSnapshot(linkedPiEditorDraftSnapshot);
4504
+ if (!snapshot || typeof requestId !== "string" || !requestId) return null;
4505
+ linkedPiEditorDraftSnapshot = null;
4506
+ pendingPiEditorDraftSnapshots.set(requestId, snapshot);
4507
+ return snapshot;
4508
+ }
4509
+
4510
+ function restoreReservedPiEditorDraftSnapshot(requestId) {
4511
+ if (typeof requestId !== "string" || !requestId) return;
4512
+ const snapshot = pendingPiEditorDraftSnapshots.get(requestId);
4513
+ if (!snapshot) return;
4514
+ pendingPiEditorDraftSnapshots.delete(requestId);
4515
+ if (!linkedPiEditorDraftSnapshot) linkedPiEditorDraftSnapshot = snapshot;
4516
+ }
4517
+
4518
+ function settleReservedPiEditorDraftSnapshot(requestId, reportedSnapshot) {
4519
+ if (typeof requestId !== "string" || !requestId) return false;
4520
+ const snapshot = pendingPiEditorDraftSnapshots.get(requestId);
4521
+ if (!snapshot) return false;
4522
+ if (reportedSnapshot && !piEditorDraftSnapshotsMatch(snapshot, reportedSnapshot)) return false;
4523
+ pendingPiEditorDraftSnapshots.delete(requestId);
4524
+ return true;
4525
+ }
4526
+
4467
4527
  function markFileBackedBaseline(text, diskRevision) {
4468
4528
  fileBackedBaselineText = String(text || "");
4469
4529
  fileBackedDiskRevision = normalizeStudioDiskRevision(diskRevision);
@@ -5098,6 +5158,20 @@
5098
5158
  return true;
5099
5159
  }
5100
5160
 
5161
+ function triggerLoadFromPiEditorShortcut() {
5162
+ if (isWatchedFilePreview) {
5163
+ setStatus("This preview is read-only. Open full Studio to load the Pi draft.", "warning");
5164
+ return false;
5165
+ }
5166
+ if (!getEditorBtn || getEditorBtn.hidden) return false;
5167
+ if (getEditorBtn.disabled) {
5168
+ setStatus("Load from Pi editor is unavailable while Studio is busy.", "warning");
5169
+ return false;
5170
+ }
5171
+ getEditorBtn.click();
5172
+ return true;
5173
+ }
5174
+
5101
5175
  function triggerEditorSaveShortcut() {
5102
5176
  if (hasRefreshableFilePath() && saveOverBtn && !saveOverBtn.disabled && !saveOverBtn.hidden) {
5103
5177
  saveOverBtn.click();
@@ -5459,6 +5533,18 @@
5459
5533
  return;
5460
5534
  }
5461
5535
 
5536
+ const isLoadFromPiEditorShortcut =
5537
+ (key.toLowerCase() === "l" || code === "KeyL")
5538
+ && (event.metaKey || event.ctrlKey)
5539
+ && !event.altKey
5540
+ && event.shiftKey;
5541
+
5542
+ if (isLoadFromPiEditorShortcut) {
5543
+ event.preventDefault();
5544
+ triggerLoadFromPiEditorShortcut();
5545
+ return;
5546
+ }
5547
+
5462
5548
  const isSaveAsShortcut =
5463
5549
  key.toLowerCase() === "s"
5464
5550
  && (event.metaKey || event.ctrlKey)
@@ -13888,7 +13974,7 @@
13888
13974
  const safeStart = Math.max(0, Math.min(start, current.length));
13889
13975
  const safeEnd = Math.max(safeStart, Math.min(end, current.length));
13890
13976
  const next = current.slice(0, safeStart) + answer.text + current.slice(safeEnd);
13891
- setEditorText(next, { preserveScroll: false, preserveSelection: false });
13977
+ setEditorText(next, { preserveScroll: false, preserveSelection: false, preservePiEditorDraftLink: true });
13892
13978
  const caret = safeStart + answer.text.length;
13893
13979
  sourceTextEl.setSelectionRange(caret, caret);
13894
13980
  setActivePane("left");
@@ -14504,6 +14590,7 @@
14504
14590
  if (refreshFromDiskBtn) refreshFromDiskBtn.disabled = uiBusy || isWatchedFilePreview || !canRefreshFromDisk;
14505
14591
  if (clearWorkspaceBtn) clearWorkspaceBtn.disabled = uiBusy || isWatchedFilePreview;
14506
14592
  sendEditorBtn.disabled = uiBusy || isEditorOnlyMode;
14593
+ if (clearPiEditorBtn) clearPiEditorBtn.disabled = uiBusy || isEditorOnlyMode;
14507
14594
  if (getEditorBtn) getEditorBtn.disabled = uiBusy || isWatchedFilePreview;
14508
14595
  if (watchedOpenEditableBtn) {
14509
14596
  watchedOpenEditableBtn.hidden = !isWatchedFilePreview;
@@ -14885,6 +14972,9 @@
14885
14972
  const value = String(nextText || "");
14886
14973
  const preserveScroll = Boolean(options && options.preserveScroll);
14887
14974
  const preserveSelection = Boolean(options && options.preserveSelection);
14975
+ if (!(options && options.preservePiEditorDraftLink === true)) {
14976
+ clearLinkedPiEditorDraftSnapshot();
14977
+ }
14888
14978
  if (activePreviewCommentSelection) {
14889
14979
  clearPreviewCommentSelection();
14890
14980
  }
@@ -22387,7 +22477,7 @@
22387
22477
  ? current.slice(0, inlineState.range.end) + current.slice(inlineState.range.end + inlineState.markerText.length)
22388
22478
  : current.slice(0, inlineState.range.end) + inlineState.markerText + current.slice(inlineState.range.end);
22389
22479
  setEditorView("markdown");
22390
- setEditorText(next, { preserveScroll: true, preserveSelection: true });
22480
+ setEditorText(next, { preserveScroll: true, preserveSelection: true, preservePiEditorDraftLink: true });
22391
22481
  pendingReviewNoteInlineFocusId = note.id;
22392
22482
  renderReviewNotesList();
22393
22483
  updateReviewNotesUi();
@@ -22432,7 +22522,7 @@
22432
22522
  }
22433
22523
 
22434
22524
  setEditorView("markdown");
22435
- setEditorText(currentText, { preserveScroll: true, preserveSelection: true });
22525
+ setEditorText(currentText, { preserveScroll: true, preserveSelection: true, preservePiEditorDraftLink: true });
22436
22526
  renderReviewNotesList();
22437
22527
  updateReviewNotesUi();
22438
22528
  if (reviewNotesInlineAllBtn && typeof reviewNotesInlineAllBtn.focus === "function") {
@@ -22618,7 +22708,7 @@
22618
22708
  const safeStart = Math.max(0, Math.min(start, current.length));
22619
22709
  const safeEnd = Math.max(safeStart, Math.min(end, current.length));
22620
22710
  const next = current.slice(0, safeStart) + content + current.slice(safeEnd);
22621
- setEditorText(next, { preserveScroll: false, preserveSelection: false });
22711
+ setEditorText(next, { preserveScroll: false, preserveSelection: false, preservePiEditorDraftLink: true });
22622
22712
  const caret = safeStart + content.length;
22623
22713
  sourceTextEl.setSelectionRange(caret, caret);
22624
22714
  setActivePane("left");
@@ -22821,11 +22911,14 @@
22821
22911
  const replHint = rightView === "repl" && getActiveReplSessionForCurrentRuntime()
22822
22912
  ? " Sends text to Pi, not the REPL; use Send chunk/selection or Send to REPL to execute code in the active REPL."
22823
22913
  : "";
22914
+ const piEditorDraftHint = linkedPiEditorDraftSnapshot
22915
+ ? " The linked Pi terminal draft will be cleared after Pi accepts this run only if that draft remains unchanged."
22916
+ : "";
22824
22917
  sendRunBtn.title = directIsStop
22825
22918
  ? "Stop the active run. Shortcut: Esc."
22826
22919
  : (annotationsEnabled
22827
- ? "Run editor text as-is (includes [an: ...] markers). Shortcut: Cmd/Ctrl+Enter. Stop the active request with Esc." + replHint
22828
- : "Run editor text with [an: ...] markers stripped. Shortcut: Cmd/Ctrl+Enter. Stop the active request with Esc." + replHint);
22920
+ ? "Run editor text as-is (includes [an: ...] markers). Shortcut: Cmd/Ctrl+Enter. Stop the active request with Esc." + piEditorDraftHint + replHint
22921
+ : "Run editor text with [an: ...] markers stripped. Shortcut: Cmd/Ctrl+Enter. Stop the active request with Esc." + piEditorDraftHint + replHint);
22829
22922
  }
22830
22923
 
22831
22924
  if (queueSteerBtn) {
@@ -23670,6 +23763,7 @@
23670
23763
  }
23671
23764
 
23672
23765
  const completedRequestId = typeof message.requestId === "string" ? message.requestId : pendingRequestId;
23766
+ if (completedRequestId) pendingPiEditorDraftSnapshots.delete(completedRequestId);
23673
23767
  const responseKind =
23674
23768
  typeof message.kind === "string"
23675
23769
  ? message.kind
@@ -23804,17 +23898,46 @@
23804
23898
  return;
23805
23899
  }
23806
23900
 
23901
+ if (message.type === "pi_editor_draft_result") {
23902
+ const requestId = typeof message.requestId === "string" ? message.requestId : "";
23903
+ if (!settleReservedPiEditorDraftSnapshot(requestId, message.piEditorDraftSnapshot)) return;
23904
+ const outcome = String(message.outcome || "");
23905
+ syncActionButtons();
23906
+ if (outcome === "cleared") {
23907
+ setStatus("Submission accepted; cleared the unchanged Pi draft.", "success");
23908
+ } else if (outcome === "already-empty") {
23909
+ setStatus("Submission accepted; the Pi draft was already empty.", "success");
23910
+ } else if (outcome !== "changed") {
23911
+ setStatus("Submission accepted, but Studio could not confirm Pi-draft cleanup; check the terminal draft.", "warning");
23912
+ }
23913
+ return;
23914
+ }
23915
+
23807
23916
  if (message.type === "editor_loaded") {
23808
23917
  if (typeof message.requestId === "string" && pendingRequestId === message.requestId) {
23809
23918
  pendingRequestId = null;
23810
23919
  pendingKind = null;
23811
23920
  }
23921
+ const snapshot = normalizePiEditorDraftSnapshot(message.piEditorDraftSnapshot);
23922
+ setLinkedPiEditorDraftSnapshot(snapshot, Boolean(snapshot && snapshot.byteLength > 0));
23812
23923
  setBusy(false);
23813
23924
  setWsState("Ready");
23814
23925
  setStatus(typeof message.message === "string" ? message.message : "Loaded into pi editor.", "success");
23815
23926
  return;
23816
23927
  }
23817
23928
 
23929
+ if (message.type === "pi_editor_cleared") {
23930
+ if (typeof message.requestId === "string" && pendingRequestId === message.requestId) {
23931
+ pendingRequestId = null;
23932
+ pendingKind = null;
23933
+ }
23934
+ clearLinkedPiEditorDraftSnapshot();
23935
+ setBusy(false);
23936
+ setWsState("Ready");
23937
+ setStatus(message.cleared ? "Cleared Pi editor text." : "Pi editor text was already empty.", "success");
23938
+ return;
23939
+ }
23940
+
23818
23941
  if (message.type === "editor_snapshot") {
23819
23942
  if (isWatchedFilePreview) {
23820
23943
  setStatus("Ignored editor snapshot because this preview follows its watched file on disk.", "warning");
@@ -23830,12 +23953,14 @@
23830
23953
 
23831
23954
  const content = typeof message.content === "string" ? message.content : "";
23832
23955
  setEditorText(content, { preserveScroll: false, preserveSelection: false });
23956
+ const snapshot = normalizePiEditorDraftSnapshot(message.piEditorDraftSnapshot);
23957
+ setLinkedPiEditorDraftSnapshot(snapshot, Boolean(content.length > 0 && snapshot));
23833
23958
  setSourceState({ source: "pi-editor", label: "pi editor draft", path: null });
23834
23959
  setBusy(false);
23835
23960
  setWsState("Ready");
23836
23961
  setStatus(
23837
23962
  content.trim()
23838
- ? "Loaded draft from pi editor."
23963
+ ? "Loaded draft from pi editor. A Studio Run will clear it if the Pi draft remains unchanged."
23839
23964
  : "pi editor is empty. Loaded blank text.",
23840
23965
  content.trim() ? "success" : "warning",
23841
23966
  );
@@ -24032,6 +24157,7 @@
24032
24157
 
24033
24158
  if (message.type === "busy") {
24034
24159
  if (typeof message.requestId === "string") {
24160
+ restoreReservedPiEditorDraftSnapshot(message.requestId);
24035
24161
  pendingSaveOperations.delete(message.requestId);
24036
24162
  failPendingCompanionLaunch(message.requestId, "Studio could not start the companion editor because another request was busy.");
24037
24163
  }
@@ -24055,6 +24181,7 @@
24055
24181
 
24056
24182
  if (message.type === "error") {
24057
24183
  if (typeof message.requestId === "string") {
24184
+ restoreReservedPiEditorDraftSnapshot(message.requestId);
24058
24185
  pendingSaveOperations.delete(message.requestId);
24059
24186
  failPendingCompanionLaunch(message.requestId, "Studio could not prepare the companion editor. Return to the originating Studio page for details.");
24060
24187
  }
@@ -24203,6 +24330,7 @@
24203
24330
  quartoPreviewCheckSourcePath = "";
24204
24331
  quartoPreviewActionRequestId = null;
24205
24332
  sideQuestionMarkdownExportRequest = null;
24333
+ pendingPiEditorDraftSnapshots.clear();
24206
24334
  failAllPendingCompanionLaunches("The originating Studio connection was lost before the companion editor was ready.");
24207
24335
  if (rightView === "editor-quarto-preview") renderQuartoPreviewView();
24208
24336
  setBusy(true);
@@ -24473,7 +24601,7 @@
24473
24601
 
24474
24602
  if (stripped.hadHeader) {
24475
24603
  const updated = stripped.body;
24476
- setEditorText(updated, { preserveScroll: true, preserveSelection: true });
24604
+ setEditorText(updated, { preserveScroll: true, preserveSelection: true, preservePiEditorDraftLink: true });
24477
24605
  updateResultActionButtons();
24478
24606
  setStatus("Removed annotated reply header.", "success");
24479
24607
  return;
@@ -24486,7 +24614,7 @@
24486
24614
  return;
24487
24615
  }
24488
24616
 
24489
- setEditorText(updated, { preserveScroll: true, preserveSelection: true });
24617
+ setEditorText(updated, { preserveScroll: true, preserveSelection: true, preservePiEditorDraftLink: true });
24490
24618
  updateResultActionButtons();
24491
24619
  setStatus("Inserted annotated reply header.", "success");
24492
24620
  }
@@ -25356,6 +25484,29 @@
25356
25484
  }
25357
25485
  });
25358
25486
 
25487
+ if (clearPiEditorBtn) {
25488
+ clearPiEditorBtn.addEventListener("click", async () => {
25489
+ closeStudioUiRefreshMenus();
25490
+ const confirmed = await requestStudioConfirmation(
25491
+ "Clear the current Pi input draft? This removes text waiting in the terminal editor, including context added from Neovim. Studio text and Pi conversation history will not change.",
25492
+ { title: "Clear Pi editor text?", confirmLabel: "Clear text", destructive: true },
25493
+ );
25494
+ if (!confirmed) {
25495
+ setStatus("Pi editor text was not cleared.");
25496
+ return;
25497
+ }
25498
+
25499
+ const requestId = beginUiAction("clear_pi_editor");
25500
+ if (!requestId) return;
25501
+ const sent = sendMessage({ type: "clear_pi_editor_request", requestId });
25502
+ if (!sent) {
25503
+ pendingRequestId = null;
25504
+ pendingKind = null;
25505
+ setBusy(false);
25506
+ }
25507
+ });
25508
+ }
25509
+
25359
25510
  if (openCompanionBtn) {
25360
25511
  openCompanionBtn.addEventListener("click", () => {
25361
25512
  requestOpenEditorOnlyDocument("", {
@@ -25423,14 +25574,17 @@
25423
25574
 
25424
25575
  const requestId = beginUiAction("direct");
25425
25576
  if (!requestId) return;
25577
+ const piEditorDraftSnapshot = reserveLinkedPiEditorDraftSnapshot(requestId);
25426
25578
 
25427
25579
  const sent = sendMessage({
25428
25580
  type: "send_run_request",
25429
25581
  requestId,
25430
25582
  text: prepared,
25583
+ piEditorDraftSnapshot: piEditorDraftSnapshot || undefined,
25431
25584
  });
25432
25585
 
25433
25586
  if (!sent) {
25587
+ restoreReservedPiEditorDraftSnapshot(requestId);
25434
25588
  pendingRequestId = null;
25435
25589
  pendingKind = null;
25436
25590
  setBusy(false);
@@ -25451,12 +25605,17 @@
25451
25605
 
25452
25606
  const requestId = makeRequestId();
25453
25607
  clearTitleAttention();
25608
+ const piEditorDraftSnapshot = reserveLinkedPiEditorDraftSnapshot(requestId);
25454
25609
  const sent = sendMessage({
25455
25610
  type: "send_run_request",
25456
25611
  requestId,
25457
25612
  text: prepared,
25613
+ piEditorDraftSnapshot: piEditorDraftSnapshot || undefined,
25458
25614
  });
25459
- if (!sent) return;
25615
+ if (!sent) {
25616
+ restoreReservedPiEditorDraftSnapshot(requestId);
25617
+ return;
25618
+ }
25460
25619
  setStatus("Queueing steering…", "warning");
25461
25620
  });
25462
25621
  }
@@ -25892,7 +26051,7 @@
25892
26051
  if (!confirmed) return;
25893
26052
 
25894
26053
  const strippedContent = stripAnnotationMarkers(content);
25895
- setEditorText(strippedContent, { preserveScroll: true, preserveSelection: false });
26054
+ setEditorText(strippedContent, { preserveScroll: true, preserveSelection: false, preservePiEditorDraftLink: true });
25896
26055
  setStatus("Removed annotation markers from editor text.", "success");
25897
26056
  });
25898
26057
  }
package/client/studio.css CHANGED
@@ -569,6 +569,7 @@
569
569
  body[data-studio-mode="editor-only"] #sendRunBtn,
570
570
  body[data-studio-mode="editor-only"] #queueSteerBtn,
571
571
  body[data-studio-mode="editor-only"] #sendEditorBtn,
572
+ body[data-studio-mode="editor-only"] #clearPiEditorBtn,
572
573
  body[data-studio-mode="editor-only"] #lensSelect,
573
574
  body[data-studio-mode="editor-only"] #critiqueBtn,
574
575
  body[data-studio-mode="editor-only"] #showMeBtn,
package/index.ts CHANGED
@@ -29,6 +29,11 @@ import {
29
29
  saveStudioDiskFileAs,
30
30
  saveStudioDiskFileIfRevision,
31
31
  } from "./shared/studio-disk-revisions.js";
32
+ import {
33
+ consumeStudioPiEditorDraftSnapshot,
34
+ createStudioPiEditorDraftSnapshot,
35
+ normalizeStudioPiEditorDraftSnapshot,
36
+ } from "./shared/studio-pi-editor-draft.js";
32
37
  import { normalizeStudioMarkdownSmartFences } from "./shared/studio-markdown-fences.js";
33
38
  import {
34
39
  extractStandaloneLatexDefinitionsFromMarkdown,
@@ -41,6 +46,13 @@ import { createStudioPandocHtmlResourceFlagResolver } from "./shared/studio-pand
41
46
  import { createStudioResourceGrantRegistry } from "./shared/studio-resource-grants.js";
42
47
  import { prepareStudioLatexForPandoc } from "./shared/studio-latex-pandoc-compat.js";
43
48
  import { isStudioCmuxSession, openStudioUrlInBrowser } from "./shared/studio-browser-launcher.js";
49
+ import {
50
+ buildStudioListenAllWarning,
51
+ isStudioWebSocketOriginAllowed,
52
+ parseStudioRequestTarget,
53
+ resolveStudioNetworkBinding,
54
+ STUDIO_ADVERTISED_HOST,
55
+ } from "./shared/studio-network-binding.js";
44
56
  import { buildStudioReplTmuxStartArgs } from "./shared/studio-repl-tmux.js";
45
57
  import {
46
58
  REPL_SESSION_RECORD_ID_OPTION,
@@ -192,6 +204,8 @@ interface StudioServerState {
192
204
  clients: Set<WebSocket>;
193
205
  clientModes: Map<WebSocket, StudioUiMode>;
194
206
  port: number;
207
+ bindHost: string;
208
+ listenAll: boolean;
195
209
  token: string;
196
210
  }
197
211
 
@@ -646,10 +660,16 @@ interface AnnotationRequestMessage {
646
660
  text: string;
647
661
  }
648
662
 
663
+ interface StudioPiEditorDraftSnapshot {
664
+ fingerprint: string;
665
+ byteLength: number;
666
+ }
667
+
649
668
  interface SendRunRequestMessage {
650
669
  type: "send_run_request";
651
670
  requestId: string;
652
671
  text: string;
672
+ piEditorDraftSnapshot?: StudioPiEditorDraftSnapshot;
653
673
  }
654
674
 
655
675
  interface CompletionSuggestionRequestMessage {
@@ -839,6 +859,11 @@ interface GetFromEditorRequestMessage {
839
859
  requestId: string;
840
860
  }
841
861
 
862
+ interface ClearPiEditorRequestMessage {
863
+ type: "clear_pi_editor_request";
864
+ requestId: string;
865
+ }
866
+
842
867
  interface QuartoPreviewCheckRequestMessage {
843
868
  type: "quarto_preview_check_request";
844
869
  requestId: string;
@@ -922,6 +947,7 @@ type IncomingStudioMessage =
922
947
  | WatchFileSubscribeMessage
923
948
  | SendToEditorRequestMessage
924
949
  | GetFromEditorRequestMessage
950
+ | ClearPiEditorRequestMessage
925
951
  | QuartoPreviewCheckRequestMessage
926
952
  | QuartoPreviewStartRequestMessage
927
953
  | QuartoPreviewStopRequestMessage
@@ -9917,10 +9943,15 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
9917
9943
  }
9918
9944
 
9919
9945
  if (msg.type === "send_run_request" && typeof msg.requestId === "string" && typeof msg.text === "string") {
9946
+ const piEditorDraftSnapshot = msg.piEditorDraftSnapshot === undefined
9947
+ ? undefined
9948
+ : normalizeStudioPiEditorDraftSnapshot(msg.piEditorDraftSnapshot);
9949
+ if (msg.piEditorDraftSnapshot !== undefined && !piEditorDraftSnapshot) return null;
9920
9950
  return {
9921
9951
  type: "send_run_request",
9922
9952
  requestId: msg.requestId,
9923
9953
  text: msg.text,
9954
+ piEditorDraftSnapshot: piEditorDraftSnapshot ?? undefined,
9924
9955
  };
9925
9956
  }
9926
9957
 
@@ -10200,6 +10231,13 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
10200
10231
  };
10201
10232
  }
10202
10233
 
10234
+ if (msg.type === "clear_pi_editor_request" && typeof msg.requestId === "string") {
10235
+ return {
10236
+ type: "clear_pi_editor_request",
10237
+ requestId: msg.requestId,
10238
+ };
10239
+ }
10240
+
10203
10241
  if (
10204
10242
  msg.type === "quarto_preview_check_request"
10205
10243
  && typeof msg.requestId === "string"
@@ -11836,13 +11874,6 @@ function interruptStudioReplSession(sessionName: string): { ok: true; message: s
11836
11874
  return { ok: true, message: `Interrupted ${sessionName}.` };
11837
11875
  }
11838
11876
 
11839
- function isAllowedOrigin(_origin: string | undefined, _port: number): boolean {
11840
- // For local-only studio, token auth is the primary guard. In practice,
11841
- // browser origin headers can vary (or be omitted) across wrappers/browsers,
11842
- // so we avoid brittle origin-based rejection here.
11843
- return true;
11844
- }
11845
-
11846
11877
  function normalizeStudioUiMode(raw: string | null | undefined): StudioUiMode {
11847
11878
  return raw === "editor-only" ? "editor-only" : "full";
11848
11879
  }
@@ -11907,7 +11938,7 @@ function buildStudioUrl(
11907
11938
  docId?: string,
11908
11939
  options?: StudioUrlOptions,
11909
11940
  ): string {
11910
- return `http://127.0.0.1:${port}${buildStudioRelativeUrl(token, mode, doc, docId, options)}`;
11941
+ return `http://${STUDIO_ADVERTISED_HOST}:${port}${buildStudioRelativeUrl(token, mode, doc, docId, options)}`;
11911
11942
  }
11912
11943
 
11913
11944
  interface StudioLaunchFlags {
@@ -11915,17 +11946,19 @@ interface StudioLaunchFlags {
11915
11946
  openRemoteBrowser: boolean;
11916
11947
  noBrowser: boolean;
11917
11948
  watchPdf: boolean;
11949
+ listenAll: boolean;
11918
11950
  port?: number;
11919
11951
  error?: string;
11920
11952
  }
11921
11953
 
11922
11954
  function parseStudioLaunchOpenFlags(rawArgs: string): StudioLaunchFlags {
11923
11955
  const parsed = tokenizeStudioCommandArgs(rawArgs);
11924
- if (parsed.error) return { args: rawArgs, openRemoteBrowser: false, noBrowser: false, watchPdf: false, error: parsed.error };
11956
+ if (parsed.error) return { args: rawArgs, openRemoteBrowser: false, noBrowser: false, watchPdf: false, listenAll: false, error: parsed.error };
11925
11957
  const remaining: string[] = [];
11926
11958
  let openRemoteBrowser = false;
11927
11959
  let noBrowser = false;
11928
11960
  let watchPdf = false;
11961
+ let listenAll = false;
11929
11962
  let port: number | undefined;
11930
11963
  for (let i = 0; i < parsed.tokens.length; i += 1) {
11931
11964
  const token = parsed.tokens[i]!;
@@ -11941,14 +11974,18 @@ function parseStudioLaunchOpenFlags(rawArgs: string): StudioLaunchFlags {
11941
11974
  watchPdf = true;
11942
11975
  continue;
11943
11976
  }
11977
+ if (token === "--listen-all") {
11978
+ listenAll = true;
11979
+ continue;
11980
+ }
11944
11981
  if (token === "--port" || token.startsWith("--port=")) {
11945
11982
  const rawPort = token.startsWith("--port=") ? token.slice("--port=".length) : parsed.tokens[++i];
11946
11983
  if (!rawPort) {
11947
- return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, error: "Missing value for --port." };
11984
+ return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, listenAll, error: "Missing value for --port." };
11948
11985
  }
11949
11986
  const requestedPort = Number(rawPort);
11950
11987
  if (!Number.isInteger(requestedPort) || requestedPort < 1 || requestedPort > 65535) {
11951
- return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, error: `Invalid --port value: ${rawPort}. Use an integer from 1 to 65535.` };
11988
+ return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, listenAll, error: `Invalid --port value: ${rawPort}. Use an integer from 1 to 65535.` };
11952
11989
  }
11953
11990
  port = requestedPort;
11954
11991
  continue;
@@ -11956,9 +11993,9 @@ function parseStudioLaunchOpenFlags(rawArgs: string): StudioLaunchFlags {
11956
11993
  remaining.push(token);
11957
11994
  }
11958
11995
  if (openRemoteBrowser && noBrowser) {
11959
- return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, port, error: "Use either --no-browser or --open-browser, not both." };
11996
+ return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, listenAll, port, error: "Use either --no-browser or --open-browser, not both." };
11960
11997
  }
11961
- return { args: remaining.join(" "), openRemoteBrowser, noBrowser, watchPdf, port };
11998
+ return { args: remaining.join(" "), openRemoteBrowser, noBrowser, watchPdf, listenAll, port };
11962
11999
  }
11963
12000
 
11964
12001
  function shouldAutoOpenStudioBrowser(options?: { openRemoteBrowser?: boolean; noBrowser?: boolean }): boolean {
@@ -12385,7 +12422,7 @@ ${cssVarsBlock}
12385
12422
  <button id="clearWorkspaceBtn" type="button" title="Clear editor text and reset this tab to a fresh blank draft. Saved files and responses are not changed.">Reset editor</button>
12386
12423
  <button id="importFileBtn" type="button" title="Import a file as an editable copy.">Import file copy…</button>
12387
12424
  <input id="fileInput" class="file-input-hidden" type="file" tabindex="-1" aria-hidden="true" accept=".md,.markdown,.mdx,.qmd,.js,.mjs,.cjs,.jsx,.ts,.mts,.cts,.tsx,.py,.pyw,.sh,.bash,.zsh,.json,.jsonc,.json5,.rs,.c,.h,.cpp,.cxx,.cc,.hpp,.hxx,.jl,.f90,.f95,.f03,.f,.for,.r,.R,.m,.tex,.latex,.diff,.patch,.java,.go,.rb,.swift,.html,.htm,.css,.xml,.yaml,.yml,.toml,.lua,.txt,.rst,.adoc" />
12388
- <button id="getEditorBtn" type="button" title="Load the current terminal editor draft into Studio.">Load from pi editor</button>
12425
+ <button id="getEditorBtn" type="button" aria-keyshortcuts="Meta+Shift+L Control+Shift+L" title="Copy the current Pi terminal input draft into Studio without clearing it. A later Studio Run clears that draft only if it remains unchanged. Shortcut: Cmd/Ctrl+Shift+L.">Load from pi editor</button>
12389
12426
  <button id="hideStudioHeaderBtn" class="header-visibility-btn" type="button" aria-controls="studioHeader" title="Hide the global Studio header. Restore it from the top-right edge.">Hide header</button>
12390
12427
  <button id="zenModeBtn" class="zen-mode-btn" type="button" title="Hide the Studio header and secondary controls. Shortcut: F9.">Zen</button>
12391
12428
  </div>
@@ -12426,7 +12463,7 @@ ${cssVarsBlock}
12426
12463
  </div>
12427
12464
  <div class="source-actions">
12428
12465
  <div class="source-actions-row">
12429
- <button id="sendRunBtn" type="button" title="Run editor text. While a direct run is active, this button becomes Stop. Cmd/Ctrl+Enter queues steering from the current editor text. Stop the active request with Esc.">Run editor text</button>
12466
+ <button id="sendRunBtn" type="button" title="Run editor text. If Studio loaded or staged an unchanged Pi terminal draft, clear that draft after Pi accepts the run. While a direct run is active, this button becomes Stop. Cmd/Ctrl+Enter queues steering from the current editor text. Stop the active request with Esc.">Run editor text</button>
12430
12467
  <button id="queueSteerBtn" type="button" title="Queue steering is available while Run editor text is active." disabled>Queue steering</button>
12431
12468
  </div>
12432
12469
  <div class="source-actions-row repl-action-line" hidden>
@@ -12453,7 +12490,8 @@ ${cssVarsBlock}
12453
12490
  <option value="current" selected>Suggestion model: current Pi model</option>
12454
12491
  </select>
12455
12492
  <button id="openCompanionBtn" type="button" title="Open a blank editor-only Studio tab.">New editor tab</button>
12456
- <button id="sendEditorBtn" type="button">Send current text to Pi editor</button>
12493
+ <button id="sendEditorBtn" type="button" title="Replace the Pi terminal input draft with the current Studio text. A later Studio Run clears that staged draft only if it remains unchanged.">Send current text to Pi editor</button>
12494
+ <button id="clearPiEditorBtn" type="button" title="Clear text currently waiting in the Pi terminal input editor without changing Studio text or conversation history.">Clear Pi editor text…</button>
12457
12495
  </div>
12458
12496
  <div class="source-actions-row">
12459
12497
  <button id="insertHeaderBtn" type="button" title="Insert annotated-reply protocol header (source metadata, [an: ...] syntax hint, precedence note, and end marker).">Annotation header</button>
@@ -12759,6 +12797,7 @@ ${cssVarsBlock}
12759
12797
  <dl>
12760
12798
  <div><dt>Cmd/Ctrl+S</dt><dd>Save editor when the disk revision still matches</dd></div>
12761
12799
  <div><dt>Cmd/Ctrl+Shift+S</dt><dd>Save editor as a new file</dd></div>
12800
+ <div><dt>Cmd/Ctrl+Shift+L</dt><dd>Load the current Pi terminal input draft without clearing it</dd></div>
12762
12801
  <div class="shortcuts-full-only"><dt>Cmd/Ctrl+Enter</dt><dd>Run editor text, or queue steering during an active run</dd></div>
12763
12802
  <div><dt>Option/Alt+Tab or Cmd/Ctrl+Shift+Space</dt><dd>Suggest a completion at the editor cursor</dd></div>
12764
12803
  <div><dt>Tab</dt><dd>Insert a visible completion suggestion; otherwise indent selected editor text</dd></div>
@@ -13649,6 +13688,23 @@ export default function (pi: ExtensionAPI) {
13649
13688
  }
13650
13689
  };
13651
13690
 
13691
+ const reportPiEditorDraftDisposition = (
13692
+ client: WebSocket,
13693
+ requestId: string,
13694
+ snapshot: StudioPiEditorDraftSnapshot | undefined,
13695
+ ) => {
13696
+ if (!snapshot) return;
13697
+ const result = consumeStudioPiEditorDraftSnapshot(lastCommandCtx?.ui, snapshot);
13698
+ if (result.status === "not-requested") return;
13699
+ emitDebugEvent("pi_editor_draft_disposition", { requestId, outcome: result.status });
13700
+ sendToClient(client, {
13701
+ type: "pi_editor_draft_result",
13702
+ requestId,
13703
+ outcome: result.status,
13704
+ piEditorDraftSnapshot: snapshot,
13705
+ });
13706
+ };
13707
+
13652
13708
  const broadcast = (payload: unknown) => {
13653
13709
  if (!serverState) return;
13654
13710
  const serialized = JSON.stringify(payload);
@@ -15743,6 +15799,7 @@ export default function (pi: ExtensionAPI) {
15743
15799
  queuedSteeringCount: getQueuedStudioSteeringCount(),
15744
15800
  });
15745
15801
  broadcastState();
15802
+ reportPiEditorDraftDisposition(client, msg.requestId, msg.piEditorDraftSnapshot);
15746
15803
  } catch (error) {
15747
15804
  queuedStudioDirectRequests = queuedStudioDirectRequests.filter((request) => request.requestId !== msg.requestId);
15748
15805
  if (studioDirectRunChain?.steeringPrompts.length) {
@@ -15766,6 +15823,7 @@ export default function (pi: ExtensionAPI) {
15766
15823
 
15767
15824
  try {
15768
15825
  pi.sendUserMessage(msg.text);
15826
+ reportPiEditorDraftDisposition(client, msg.requestId, msg.piEditorDraftSnapshot);
15769
15827
  } catch (error) {
15770
15828
  clearStudioDirectRunState();
15771
15829
  clearActiveRequest();
@@ -16510,7 +16568,8 @@ export default function (pi: ExtensionAPI) {
16510
16568
  sendToClient(client, {
16511
16569
  type: "editor_loaded",
16512
16570
  requestId: msg.requestId,
16513
- message: "Draft loaded into pi editor.",
16571
+ message: "Draft loaded into pi editor. A Studio Run will clear it if it remains unchanged.",
16572
+ piEditorDraftSnapshot: createStudioPiEditorDraftSnapshot(msg.content),
16514
16573
  });
16515
16574
  } catch (error) {
16516
16575
  sendToClient(client, {
@@ -16546,6 +16605,7 @@ export default function (pi: ExtensionAPI) {
16546
16605
  type: "editor_snapshot",
16547
16606
  requestId: msg.requestId,
16548
16607
  content,
16608
+ piEditorDraftSnapshot: createStudioPiEditorDraftSnapshot(content),
16549
16609
  });
16550
16610
  } catch (error) {
16551
16611
  sendToClient(client, {
@@ -16556,6 +16616,43 @@ export default function (pi: ExtensionAPI) {
16556
16616
  }
16557
16617
  return;
16558
16618
  }
16619
+
16620
+ if (msg.type === "clear_pi_editor_request") {
16621
+ if (!isValidRequestId(msg.requestId)) {
16622
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
16623
+ return;
16624
+ }
16625
+ if (isStudioBusy()) {
16626
+ sendToClient(client, { type: "busy", requestId: msg.requestId, message: "Studio is busy." });
16627
+ return;
16628
+ }
16629
+ if (!lastCommandCtx || !lastCommandCtx.hasUI) {
16630
+ sendToClient(client, {
16631
+ type: "error",
16632
+ requestId: msg.requestId,
16633
+ message: "No interactive pi editor context is available.",
16634
+ });
16635
+ return;
16636
+ }
16637
+
16638
+ try {
16639
+ const hadContent = lastCommandCtx.ui.getEditorText().length > 0;
16640
+ if (hadContent) lastCommandCtx.ui.setEditorText("");
16641
+ lastCommandCtx.ui.notify(hadContent ? "Pi editor text cleared from Studio." : "Pi editor text is already empty.", "info");
16642
+ sendToClient(client, {
16643
+ type: "pi_editor_cleared",
16644
+ requestId: msg.requestId,
16645
+ cleared: hadContent,
16646
+ });
16647
+ } catch (error) {
16648
+ sendToClient(client, {
16649
+ type: "error",
16650
+ requestId: msg.requestId,
16651
+ message: `Failed to clear pi editor text: ${error instanceof Error ? error.message : String(error)}`,
16652
+ });
16653
+ }
16654
+ return;
16655
+ }
16559
16656
  };
16560
16657
 
16561
16658
  const disposePreparedPdfExport = (entry: PreparedStudioPdfExport | null | undefined) => {
@@ -17357,12 +17454,9 @@ export default function (pi: ExtensionAPI) {
17357
17454
  return;
17358
17455
  }
17359
17456
 
17360
- let requestUrl: URL;
17361
- try {
17362
- const host = req.headers.host ?? `127.0.0.1:${serverState.port}`;
17363
- requestUrl = new URL(req.url ?? "/", `http://${host}`);
17364
- } catch (error) {
17365
- respondText(res, 400, `Invalid request URL: ${error instanceof Error ? error.message : String(error)}`);
17457
+ const requestUrl = parseStudioRequestTarget(req.url);
17458
+ if (!requestUrl) {
17459
+ respondText(res, 400, "Invalid request URL");
17366
17460
  return;
17367
17461
  }
17368
17462
 
@@ -17955,9 +18049,10 @@ export default function (pi: ExtensionAPI) {
17955
18049
  res.end(buildStudioHtml(requestInitialDocument, serverState.token, lastCommandCtx?.ui.theme, currentModelLabel, terminalSessionLabel, terminalSessionDetail, contextUsageSnapshot, studioMode));
17956
18050
  };
17957
18051
 
17958
- const ensureServer = async (requestedPort?: number): Promise<StudioServerState> => {
18052
+ const ensureServer = async (requestedPort?: number, listenAll = false): Promise<StudioServerState> => {
17959
18053
  if (serverState) return serverState;
17960
18054
 
18055
+ const binding = resolveStudioNetworkBinding(listenAll);
17961
18056
  const server = createServer(handleHttpRequest);
17962
18057
  const wsServer = new WebSocketServer({ noServer: true });
17963
18058
  const clients = new Set<WebSocket>();
@@ -17969,12 +18064,18 @@ export default function (pi: ExtensionAPI) {
17969
18064
  clients,
17970
18065
  clientModes,
17971
18066
  port: 0,
18067
+ bindHost: binding.bindHost,
18068
+ listenAll: binding.listenAll,
17972
18069
  token: createSessionToken(),
17973
18070
  };
17974
18071
 
17975
18072
  server.on("upgrade", (req, socket, head) => {
17976
- const host = req.headers.host ?? `127.0.0.1:${state.port}`;
17977
- const requestUrl = new URL(req.url ?? "/", `http://${host}`);
18073
+ const requestUrl = parseStudioRequestTarget(req.url);
18074
+ if (!requestUrl) {
18075
+ socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
18076
+ socket.destroy();
18077
+ return;
18078
+ }
17978
18079
 
17979
18080
  if (requestUrl.pathname !== "/ws") {
17980
18081
  socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
@@ -17989,7 +18090,9 @@ export default function (pi: ExtensionAPI) {
17989
18090
  return;
17990
18091
  }
17991
18092
 
17992
- if (!isAllowedOrigin(req.headers.origin, state.port)) {
18093
+ const origin = typeof req.headers.origin === "string" ? req.headers.origin : undefined;
18094
+ const requestHost = typeof req.headers.host === "string" ? req.headers.host : undefined;
18095
+ if (!isStudioWebSocketOriginAllowed(origin, requestHost, state.listenAll)) {
17993
18096
  socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
17994
18097
  socket.destroy();
17995
18098
  return;
@@ -18001,8 +18104,15 @@ export default function (pi: ExtensionAPI) {
18001
18104
  });
18002
18105
 
18003
18106
  wsServer.on("connection", (ws, req) => {
18004
- const host = req.headers.host ?? `127.0.0.1:${state.port}`;
18005
- const requestUrl = new URL(req.url ?? "/ws", `http://${host}`);
18107
+ const requestUrl = parseStudioRequestTarget(req.url);
18108
+ if (!requestUrl) {
18109
+ try {
18110
+ ws.close(1008, "Invalid request URL");
18111
+ } catch {
18112
+ // Ignore close errors.
18113
+ }
18114
+ return;
18115
+ }
18006
18116
  const clientMode = normalizeStudioUiMode(requestUrl.searchParams.get("mode"));
18007
18117
  const requestedWatchedPath = (requestUrl.searchParams.get("watchPath") ?? "").trim();
18008
18118
  const canonicalWatchedPath = requestedWatchedPath ? resolve(requestedWatchedPath) : "";
@@ -18101,7 +18211,7 @@ export default function (pi: ExtensionAPI) {
18101
18211
  };
18102
18212
  server.once("error", onError);
18103
18213
  server.once("listening", onListening);
18104
- server.listen(listenPort, "127.0.0.1");
18214
+ server.listen(listenPort, state.bindHost);
18105
18215
  });
18106
18216
 
18107
18217
  const address = server.address();
@@ -18974,6 +19084,10 @@ export default function (pi: ExtensionAPI) {
18974
19084
  if (serverState && launchOpenFlags.port && serverState.port !== launchOpenFlags.port) {
18975
19085
  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");
18976
19086
  }
19087
+ if (serverState && launchOpenFlags.listenAll && !serverState.listenAll) {
19088
+ ctx.ui.notify(`Studio server is already bound to localhost at 127.0.0.1:${serverState.port}. Use /studio --stop, then restart it with --listen-all.`, "warning");
19089
+ return;
19090
+ }
18977
19091
 
18978
19092
  const parsedLaunchPath = options?.allowPdfPreview ? parsePathArgument(launchArgs) : null;
18979
19093
  const launchesPdfPreview = parsedLaunchPath
@@ -18993,9 +19107,12 @@ export default function (pi: ExtensionAPI) {
18993
19107
  if (serverState) {
18994
19108
  const url = buildStudioUrl(serverState.port, serverState.token, "full");
18995
19109
  ctx.ui.notify(`Studio URL: ${url}`, "info");
18996
- const tunnelHint = buildStudioSshTunnelHint(serverState.port, url)
18997
- ?? (launchOpenFlags.noBrowser ? buildStudioForwardingHint(serverState.port, url, { prefix: "Browser auto-open was skipped because --no-browser was used." }) : null);
19110
+ const tunnelHint = serverState.listenAll
19111
+ ? null
19112
+ : buildStudioSshTunnelHint(serverState.port, url)
19113
+ ?? (launchOpenFlags.noBrowser ? buildStudioForwardingHint(serverState.port, url, { prefix: "Browser auto-open was skipped because --no-browser was used." }) : null);
18998
19114
  if (tunnelHint) ctx.ui.notify(tunnelHint, "info");
19115
+ if (serverState.listenAll) ctx.ui.notify(buildStudioListenAllWarning(serverState.port, url), "warning");
18999
19116
  }
19000
19117
  return;
19001
19118
  }
@@ -19028,7 +19145,7 @@ export default function (pi: ExtensionAPI) {
19028
19145
 
19029
19146
  let state: StudioServerState;
19030
19147
  try {
19031
- state = await ensureServer(launchOpenFlags.port);
19148
+ state = await ensureServer(launchOpenFlags.port, launchOpenFlags.listenAll);
19032
19149
  } catch (error) {
19033
19150
  const message = error instanceof Error ? error.message : String(error);
19034
19151
  const portText = launchOpenFlags.port ? ` on port ${launchOpenFlags.port}` : "";
@@ -19041,8 +19158,10 @@ export default function (pi: ExtensionAPI) {
19041
19158
  skipWorkspaceRestore: selection.skipWorkspaceRestore,
19042
19159
  paneFocus: selection.paneFocus,
19043
19160
  });
19044
- const tunnelHint = buildStudioSshTunnelHint(state.port, url)
19045
- ?? (launchOpenFlags.noBrowser ? buildStudioForwardingHint(state.port, url, { prefix: "Browser auto-open was skipped because --no-browser was used." }) : null);
19161
+ const tunnelHint = state.listenAll
19162
+ ? null
19163
+ : buildStudioSshTunnelHint(state.port, url)
19164
+ ?? (launchOpenFlags.noBrowser ? buildStudioForwardingHint(state.port, url, { prefix: "Browser auto-open was skipped because --no-browser was used." }) : null);
19046
19165
  const openedLabel = selection.kind === "pdf-preview"
19047
19166
  ? "pi Studio PDF preview"
19048
19167
  : (selection.kind === "watched-preview"
@@ -19082,11 +19201,12 @@ export default function (pi: ExtensionAPI) {
19082
19201
  } finally {
19083
19202
  ctx.ui.notify(`Studio URL: ${url}`, "info");
19084
19203
  if (tunnelHint) ctx.ui.notify(tunnelHint, "info");
19204
+ if (state.listenAll) ctx.ui.notify(buildStudioListenAllWarning(state.port, url), "warning");
19085
19205
  }
19086
19206
  };
19087
19207
 
19088
19208
  pi.registerCommand("studio", {
19089
- 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)",
19209
+ 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)",
19090
19210
  handler: async (args: string, ctx: ExtensionCommandContext) => {
19091
19211
  const trimmed = args.trim();
19092
19212
 
@@ -19104,11 +19224,12 @@ export default function (pi: ExtensionAPI) {
19104
19224
  const counts = getStudioClientCounts();
19105
19225
  const url = buildStudioUrl(serverState.port, serverState.token, "full");
19106
19226
  ctx.ui.notify(
19107
- `Studio running at ${url} (busy: ${isStudioBusy() ? "yes" : "no"}; full views: ${counts.full}; editor-only views: ${counts.editorOnly})`,
19227
+ `Studio running at ${url} (listening on ${serverState.bindHost}:${serverState.port}; busy: ${isStudioBusy() ? "yes" : "no"}; full views: ${counts.full}; editor-only views: ${counts.editorOnly})`,
19108
19228
  "info",
19109
19229
  );
19110
- const sshTunnelHint = buildStudioSshTunnelHint(serverState.port, url);
19230
+ const sshTunnelHint = serverState.listenAll ? null : buildStudioSshTunnelHint(serverState.port, url);
19111
19231
  if (sshTunnelHint) ctx.ui.notify(sshTunnelHint, "info");
19232
+ if (serverState.listenAll) ctx.ui.notify(buildStudioListenAllWarning(serverState.port, url), "warning");
19112
19233
  return;
19113
19234
  }
19114
19235
 
@@ -19121,7 +19242,8 @@ export default function (pi: ExtensionAPI) {
19121
19242
  + " /studio --blank Open with blank editor\n"
19122
19243
  + " /studio --last Open with last model response\n"
19123
19244
  + " /studio --no-browser Print the Studio URL without opening a browser\n"
19124
- + " /studio --port <port> Bind Studio to a fixed localhost port when starting\n"
19245
+ + " /studio --port <port> Bind Studio to a fixed port when starting\n"
19246
+ + " /studio --listen-all Explicitly bind to 0.0.0.0 instead of localhost (security-sensitive)\n"
19125
19247
  + " /studio --open-remote Over SSH, open the remote browser anyway\n"
19126
19248
  + " /studio --status Show studio status\n"
19127
19249
  + " /studio --stop Stop studio server\n"
@@ -19152,7 +19274,8 @@ export default function (pi: ExtensionAPI) {
19152
19274
  + " /studio-replace --blank Replace with blank editor\n"
19153
19275
  + " /studio-replace --last Replace with last model response\n"
19154
19276
  + " /studio-replace --no-browser Print URL without opening a browser\n"
19155
- + " /studio-replace --port <port> Bind Studio to a fixed localhost port when starting\n"
19277
+ + " /studio-replace --port <port> Bind Studio to a fixed port when starting\n"
19278
+ + " /studio-replace --listen-all Explicitly bind to 0.0.0.0 instead of localhost\n"
19156
19279
  + "Editor-only Studio views stay open.",
19157
19280
  "info",
19158
19281
  );
@@ -19180,7 +19303,8 @@ export default function (pi: ExtensionAPI) {
19180
19303
  + " /studio-editor-only --blank Open with blank editor\n"
19181
19304
  + " /studio-editor-only --last Open with last model response loaded into the editor\n"
19182
19305
  + " /studio-editor-only --no-browser Print URL without opening a browser\n"
19183
- + " /studio-editor-only --port <port> Bind Studio to a fixed localhost port when starting\n"
19306
+ + " /studio-editor-only --port <port> Bind Studio to a fixed port when starting\n"
19307
+ + " /studio-editor-only --listen-all Explicitly bind to 0.0.0.0 instead of localhost\n"
19184
19308
  + "Multiple editor-only views are allowed in the same Pi session.",
19185
19309
  "info",
19186
19310
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.9.58",
3
+ "version": "0.9.60-rc.0",
4
4
  "description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, active quiz, prompt/response history, live previews, and tmux-backed REPL/literate REPL workflows",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,103 @@
1
+ export const STUDIO_LOOPBACK_BIND_HOST = "127.0.0.1";
2
+ export const STUDIO_ALL_INTERFACES_BIND_HOST = "0.0.0.0";
3
+ export const STUDIO_ADVERTISED_HOST = STUDIO_LOOPBACK_BIND_HOST;
4
+
5
+ /**
6
+ * Resolve Studio's server-lifetime network binding. Listening on every IPv4
7
+ * interface is deliberately opt-in; generated browser URLs remain loopback URLs
8
+ * so same-port container publishing does not require URL editing.
9
+ *
10
+ * @param {boolean} [listenAll]
11
+ * @returns {{ bindHost: string, advertisedHost: string, listenAll: boolean }}
12
+ */
13
+ export function resolveStudioNetworkBinding(listenAll = false) {
14
+ const enabled = listenAll === true;
15
+ return {
16
+ bindHost: enabled ? STUDIO_ALL_INTERFACES_BIND_HOST : STUDIO_LOOPBACK_BIND_HOST,
17
+ advertisedHost: STUDIO_ADVERTISED_HOST,
18
+ listenAll: enabled,
19
+ };
20
+ }
21
+
22
+ /**
23
+ * Parse only an HTTP origin-form request target. The inbound Host header is not
24
+ * needed for Studio routing and must not become the base URL authority.
25
+ *
26
+ * @param {string | undefined} requestTarget
27
+ * @returns {URL | null}
28
+ */
29
+ export function parseStudioRequestTarget(requestTarget) {
30
+ const value = typeof requestTarget === "string" ? requestTarget : "/";
31
+ if (!value.startsWith("/") || value.startsWith("//")) return null;
32
+ try {
33
+ const expectedOrigin = `http://${STUDIO_ADVERTISED_HOST}`;
34
+ const parsed = new URL(value, expectedOrigin);
35
+ return parsed.origin === expectedOrigin ? parsed : null;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Preserve the existing permissive Origin behavior for the localhost server.
43
+ * In wildcard mode, an omitted Origin remains compatible with embedded and
44
+ * non-browser clients; a supplied browser Origin must be HTTP(S) and match the
45
+ * request's Host authority so a cross-origin page cannot drive the WebSocket.
46
+ *
47
+ * @param {string | undefined} originHeader
48
+ * @param {string | undefined} requestHost
49
+ * @param {boolean} [requireSameHost]
50
+ * @returns {boolean}
51
+ */
52
+ export function isStudioWebSocketOriginAllowed(originHeader, requestHost, requireSameHost = false) {
53
+ if (!originHeader || !requireSameHost) return true;
54
+ let originUrl;
55
+ try {
56
+ originUrl = new URL(originHeader);
57
+ } catch {
58
+ return false;
59
+ }
60
+ if (originUrl.protocol !== "http:" && originUrl.protocol !== "https:") return false;
61
+ if (
62
+ originUrl.username
63
+ || originUrl.password
64
+ || originUrl.pathname !== "/"
65
+ || originUrl.search
66
+ || originUrl.hash
67
+ ) return false;
68
+ if (typeof requestHost !== "string" || requestHost.length === 0) return false;
69
+
70
+ try {
71
+ const requestOrigin = new URL(`http://${requestHost}`);
72
+ if (
73
+ requestOrigin.username
74
+ || requestOrigin.password
75
+ || requestOrigin.pathname !== "/"
76
+ || requestOrigin.search
77
+ || requestOrigin.hash
78
+ ) return false;
79
+ return originUrl.host.toLowerCase() === requestOrigin.host.toLowerCase();
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * @param {number} port
87
+ * @param {string} [studioUrl]
88
+ * @returns {string}
89
+ */
90
+ export function buildStudioListenAllWarning(port, studioUrl) {
91
+ const normalizedPort = Number.isInteger(port) && port > 0 && port <= 65535
92
+ ? port
93
+ : "<port>";
94
+ const urlLine = typeof studioUrl === "string" && studioUrl.length > 0
95
+ ? `\nStudio URL: ${studioUrl}`
96
+ : "";
97
+ return "Security warning: pi Studio is listening on all IPv4 interfaces at "
98
+ + `${STUDIO_ALL_INTERFACES_BIND_HOST}:${normalizedPort}. `
99
+ + "Anyone who obtains the tokenized Studio URL can control Studio for this Pi process, including submitting prompts, reading or writing files through Studio, and expanding local-resource access. "
100
+ + "Treat the URL like a password. Expose this port only through a trusted local mapping or private network, and prefer SSH tunnelling on untrusted networks. "
101
+ + "Use /studio --stop before restarting without --listen-all to return to localhost-only mode."
102
+ + urlLine;
103
+ }
@@ -0,0 +1,49 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ export const STUDIO_PI_EDITOR_DRAFT_FINGERPRINT_PATTERN = /^sha256:[a-f0-9]{64}$/;
4
+
5
+ export function createStudioPiEditorDraftSnapshot(content) {
6
+ const buffer = Buffer.from(String(content ?? ""), "utf8");
7
+ return Object.freeze({
8
+ fingerprint: `sha256:${createHash("sha256").update(buffer).digest("hex")}`,
9
+ byteLength: buffer.length,
10
+ });
11
+ }
12
+
13
+ export function normalizeStudioPiEditorDraftSnapshot(value) {
14
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
15
+ const fingerprint = typeof value.fingerprint === "string"
16
+ ? value.fingerprint.trim().toLowerCase()
17
+ : "";
18
+ const byteLength = value.byteLength;
19
+ if (!STUDIO_PI_EDITOR_DRAFT_FINGERPRINT_PATTERN.test(fingerprint)) return null;
20
+ if (typeof byteLength !== "number" || !Number.isSafeInteger(byteLength) || byteLength < 0) return null;
21
+ return Object.freeze({ fingerprint, byteLength });
22
+ }
23
+
24
+ export function studioPiEditorDraftMatchesSnapshot(content, snapshot) {
25
+ const expected = normalizeStudioPiEditorDraftSnapshot(snapshot);
26
+ if (!expected) return false;
27
+ const current = createStudioPiEditorDraftSnapshot(content);
28
+ return current.byteLength === expected.byteLength && current.fingerprint === expected.fingerprint;
29
+ }
30
+
31
+ export function consumeStudioPiEditorDraftSnapshot(ui, snapshot) {
32
+ const expected = normalizeStudioPiEditorDraftSnapshot(snapshot);
33
+ if (!expected) return Object.freeze({ status: "not-requested" });
34
+ if (!ui || typeof ui.getEditorText !== "function" || typeof ui.setEditorText !== "function") {
35
+ return Object.freeze({ status: "unavailable" });
36
+ }
37
+
38
+ try {
39
+ const current = String(ui.getEditorText() ?? "");
40
+ if (current.length === 0) return Object.freeze({ status: "already-empty" });
41
+ if (!studioPiEditorDraftMatchesSnapshot(current, expected)) {
42
+ return Object.freeze({ status: "changed" });
43
+ }
44
+ ui.setEditorText("");
45
+ return Object.freeze({ status: "cleared" });
46
+ } catch {
47
+ return Object.freeze({ status: "unavailable" });
48
+ }
49
+ }