pi-studio 0.9.59 → 0.9.60

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,30 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.60] — 2026-09-07
8
+
9
+ ### Added
10
+ - Promote the opt-in `--listen-all` networking support from `0.9.60-rc.0` to stable: default binding remains localhost, wildcard binding requires explicit intent and token authentication, and status reports the actual bind address. See the RC security notes below for the exposure and Origin-check boundaries.
11
+ - Add `Cmd/Ctrl+Option/Alt+A` to toggle the browser-stored **Follow activity** preference from the main editable Studio workspace.
12
+ - Add `Cmd/Ctrl+Option/Alt+Enter` for **Annotate response** from either main pane, retaining the existing action's availability and modal guards without changing Run, REPL, or Side question shortcuts. Add Shift (`Cmd/Ctrl+Option/Alt+Shift+Enter`) for **Load response into editor** without switching views, through the existing button and the same replacement safeguards. All three shortcuts ignore held-key repeats and text composition. View-preserving Load refuses to detach the file required by Quarto Preview; choose another view first or use Annotate response.
13
+
14
+ ### Changed
15
+ - Use **Option/Alt** consistently in current shortcut labels, tooltips, and documentation. Keyboard behavior and standard accessibility key names are unchanged.
16
+
17
+ ### Fixed
18
+ - Protect unsubmitted editor changes and unsaved file edits before **Annotate response** or **Load response into editor** replaces them. Keep unchanged accepted prompts confirmation-free using bounded, page-memory-only request-correlated snapshots; failed submissions and late acknowledgements cannot mark newer edits as submitted, and submission never marks file text saved. Cancel preserves text, source identity, and view; confirmation is revalidated against the editor and selected response before replacement. Pi-terminal draft-cleanup provenance remains separate.
19
+
20
+ ### Validation note
21
+ - Production network lifecycle and TCP-forwarding checks pass, but exact `sbx` confirmation from the issue #4 reporter is still outstanding. The issue remains open for confirmation or a follow-up fix; this release does not claim that environment has been tested directly.
22
+
23
+ ## [0.9.60-rc.0] — 2026-09-03
24
+
25
+ ### Added
26
+ - 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.
27
+
28
+ ### Security
29
+ - 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.
30
+
7
31
  ## [0.9.59] — 2026-09-03
8
32
 
9
33
  ### Added
package/README.md CHANGED
@@ -37,7 +37,7 @@ _The video shows an earlier version of the Studio interface. The basic workflow
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
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
39
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.
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
+ - 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** (`Cmd/Ctrl+Option/Alt+A`; 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+Option/Alt+1–8` switches directly between right-pane views while `Cmd/Ctrl+Option/Alt+P` / `Cmd/Ctrl+Option/Alt+E` / `Cmd/Ctrl+Option/Alt+W` / `Cmd/Ctrl+Option/Alt+F` / `Cmd/Ctrl+Option/Alt+R` / `Cmd/Ctrl+Option/Alt+Q` keep quick mnemonic shortcuts for Response Preview, Editor Preview, Working, Files, REPL, and Side questions
41
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
42
42
  - Includes a right-pane **Files** view rooted in folders allowed for the current Studio session, with an **Allow folder…** action and location selector; exact-file grants can still be opened independently without exposing their parent folders. Files supports sorting by name/modified time/size, opening folders or the current root in Finder/the system file manager, loading text/code/CSV/TSV documents into the editor, opening a read-only **Preview (follow)** tab whose disk content is authoritative, previewing PDFs/images, opening previews in new Studio tabs, converting DOCX/ODT documents to editable Markdown when Pandoc is available after confirmation, copying paths, setting the current folder as the Studio working directory, and revealing files in the file manager
43
43
  - Imports detached text-file copies either through the browser's file picker or from a path on the computer running Pi, keeping the path option available in embedded and remote browser views; imported copies remain detached until saved or opened as file-backed documents
@@ -50,9 +50,10 @@ _The video shows an earlier version of the Studio interface. The basic workflow
50
50
  - Turns local preview links, including links inside sandboxed HTML previews, into Studio actions: PDFs open in the embedded viewer, images open in a zoomable focus viewer, PDF/image links can open in a new Studio preview tab, text/code/CSV/TSV document links can open in a new editor tab, DOCX/ODT links can be converted to editable Markdown, and right-click menus provide **Open here**, **Reveal in file manager**, and **Copy path** for local resources
51
51
  - Includes local comments anchored to selections/lines, shown in a docked **Comments** rail, with transient **Comment** / **Jump** actions from raw-editor selections plus editor-preview selections for Markdown, LaTeX, code/text/diff previews, and an opt-in comment mode for editor HTML previews; source-anchored comments can be toggled into inline `[an: ...]` annotations when you want comments reflected in the document text
52
52
  - Browses response history (`Prev/Next/Last`) and loads either:
53
- - response text, with a one-click **Annotate response** action that also opens **Editor (Preview)**
53
+ - response text, with **Annotate response** (`Cmd/Ctrl+Option/Alt+Enter` from either main pane) also opening **Editor (Preview)**; add Shift (`Cmd/Ctrl+Option/Alt+Shift+Enter`) for **Load response into editor** without switching views (choose another view first if file-backed Quarto Preview is active)
54
54
  - critique notes/full critique
55
55
  - the prompt that generated a selected response
56
+ - Keeps the prompt → accepted Run → **Annotate response** loop confirmation-free when the submitted editor text is unchanged. **Annotate response** and **Load response into editor** ask before replacing unsubmitted edits or unsaved file changes; Cancel preserves the editor and its view so you can save or copy first. A failed submission or late acknowledgement cannot mark later typing as submitted, and sending file text does not save it. This bounded accepted-prompt baseline stays in page memory, is not restored after a page reload, and is separate from Pi-terminal draft cleanup.
56
57
  - Supports an annotation workflow for `[an: ...]` markers:
57
58
  - inserts/removes the annotated-reply header
58
59
  - shows/hides annotation markers in preview
@@ -83,8 +84,9 @@ When Pi accepts **Run editor text**, Studio clears the linked terminal draft onl
83
84
  | `/studio --last` | Force last response |
84
85
  | `/studio --blank` | Force blank editor |
85
86
  | `/studio --no-browser` | Start/print the Studio URL without opening a browser, useful for forwarded or phone/browser sessions |
86
- | `/studio --port <port>` | Bind Studio to a fixed localhost port instead of a random free port |
87
- | `/studio --status` | Show studio server status |
87
+ | `/studio --port <port>` | Bind Studio to a fixed port instead of a random free port; the default host remains localhost |
88
+ | `/studio --listen-all` | Explicitly bind Studio to all IPv4 interfaces (`0.0.0.0`) for a container or trusted private-network workflow |
89
+ | `/studio --status` | Show studio server status, including its listening address |
88
90
  | `/studio --stop` | Stop studio server |
89
91
  | `/studio --help` | Show help |
90
92
  | `/studio-replace [path\|--blank\|--last]` | Replace the current full Studio view with a new full Studio view |
@@ -118,6 +120,18 @@ Run once without installing:
118
120
  pi -e https://github.com/omaclaren/pi-studio
119
121
  ```
120
122
 
123
+ ## Container and network access
124
+
125
+ 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:
126
+
127
+ ```text
128
+ /studio --no-browser --listen-all --port 4321
129
+ ```
130
+
131
+ 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.
132
+
133
+ 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.
134
+
121
135
  ## Shared REPL record
122
136
 
123
137
  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.
@@ -146,7 +160,7 @@ See [`shared/REPL_SESSION_RECORD_PROTOCOL.md`](./shared/REPL_SESSION_RECORD_PROT
146
160
 
147
161
  ## Side questions and research context
148
162
 
149
- Open **Review → Side question**, select **Side questions** in the right pane, or press **Cmd/Ctrl+Alt+Q**. A new side thread captures its starting text while keeping related-file access separate:
163
+ Open **Review → Side question**, select **Side questions** in the right pane, or press **Cmd/Ctrl+Option/Alt+Q**. A new side thread captures its starting text while keeping related-file access separate:
150
164
 
151
165
  - **Starting text** controls what unsaved text begins the thread. The compact **Automatic: selection → heading block at cursor → nearby text** disclosure explains the fallback order; when expanded, it defines the heading boundary. The summary below reports the resolved starting text, editor line range when applicable, and character count separately from the related-file scope.
152
166
  - Explicit **Editor selection only** no longer falls back to the whole document when no text is selected; the summary instead reports that no editor text is selected.
@@ -186,7 +200,7 @@ caption: Optional caption
186
200
 
187
201
  `path` must point to a local `.pdf`. Relative paths resolve from the opened document's directory, or from Studio's working directory for non-file-backed content; the resolved file must be inside a location allowed for the current Studio session before it loads. `page` is an initial page hint for the browser PDF viewer, `height` controls the embedded frame height in pixels, and `watch: true` enables auto-refresh after Studio observes the changed file in a stable state. Use normal Markdown links for PDFs when embedding is not useful.
188
202
 
189
- To view an existing PDF directly, run `/studio report.pdf` (or `/studio "path with spaces/report.pdf"`). The PDF opens read-only in a focused companion preview, even when the full Studio workspace is already open. A `#page=N` suffix selects the initial page. Auto-refresh is off by default; use `/studio --watch report.pdf` to start with it on, or toggle **Auto-refresh** in the PDF card or focused viewer. Studio checks only while the tab is visible and waits for two matching file-version observations before reloading, which avoids reading a PDF while LaTeX is still writing it. **Cmd/Ctrl+Alt+Shift+R** manually refreshes the focused or visible PDF. This is distinct from `/studio-pdf`, which exports Markdown, LaTeX, code, or the last response to a new PDF.
203
+ To view an existing PDF directly, run `/studio report.pdf` (or `/studio "path with spaces/report.pdf"`). The PDF opens read-only in a focused companion preview, even when the full Studio workspace is already open. A `#page=N` suffix selects the initial page. Auto-refresh is off by default; use `/studio --watch report.pdf` to start with it on, or toggle **Auto-refresh** in the PDF card or focused viewer. Studio checks only while the tab is visible and waits for two matching file-version observations before reloading, which avoids reading a PDF while LaTeX is still writing it. **Cmd/Ctrl+Option/Alt+Shift+R** manually refreshes the focused or visible PDF. This is distinct from `/studio-pdf`, which exports Markdown, LaTeX, code, or the last response to a new PDF.
190
204
 
191
205
  For a disk-authoritative text or code preview, run `/studio --watch notes.md` or choose **Preview file (follow changes)** from a local-link menu / **Preview (follow)** in Files. This opens a transient read-only companion tab with the preview pane focused; leaving pane focus reveals the same disk-following text as **Source** with a visible **Read-only · follows disk** badge rather than as an editable buffer. Studio polls the canonical file, debounces change bursts, compares SHA-256 content revisions, accepts ordinary and atomic-replacement saves, and never writes back. Each watcher socket is bound to the exact path in a bounded, expiring server-created tab capability; if that identity expires before a reload or reconnect, the tab stays read-only and asks you to open a fresh watched preview. A missing, unreadable, oversized, binary, or path-replaced source leaves the last good preview visible and automatically recovers when the same canonical file becomes readable again. Render failures likewise retain the last successfully staged document rather than replacing it with partial output. On successful updates, Studio restores a nearby stable content anchor where possible and falls back to the previous scroll ratio.
192
206
 
@@ -220,7 +234,7 @@ Thanks to [Hal Gumbert / CampSoftware](https://github.com/campsoftware) for publ
220
234
 
221
235
  ## Notes
222
236
 
223
- - Local-only server (`127.0.0.1`) with tokenized Studio URLs.
237
+ - 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.
224
238
  - 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.
225
239
  - 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`.
226
240
  - 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
@@ -6,7 +6,7 @@ This is the current planning source of truth for Studio work. `NEXT-STAGE.md` is
6
6
 
7
7
  Studio development follows two tracks:
8
8
 
9
- - **Stable `0.9.x` releases** contain one coherent family of fixes or incremental features and keep `main` releasable.
9
+ - **Stable `0.9.x` releases** contain a bounded, explicitly scoped batch of fixes or incremental features and keep `main` releasable.
10
10
  - **`0.10.0`** is reserved for the buffer-first document architecture. Risky prereleases should use the npm `next` tag before replacing `latest`.
11
11
 
12
12
  Changes should be based on capabilities rather than browser, terminal host, or user-agent detection. Muxy, ordinary Chromium browsers, embedded WebKit views, and SSH/headless use are validation environments, not separate product variants.
@@ -19,7 +19,19 @@ Each release should finish with:
19
19
  4. exact package-content and byte-level artifact audit;
20
20
  5. a fresh npm installation and naturally rendered Studio smoke test.
21
21
 
22
- Publishing, pushing, and tagging remain explicit release actions rather than automatic consequences of merging work.
22
+ Committing, publishing, pushing, and tagging remain explicit actions rather than automatic consequences of implementation or planning approval.
23
+
24
+ ## Agreed next sequence (2026-09-07)
25
+
26
+ 1. Ship **`0.9.60`** as the bounded networking/shortcut release. Do not start buffer architecture in this batch.
27
+ 2. After that release, implement **`0.10.0`** around an explicit active Prompt, independently editable documents, context transfer, and parked prompt drafts. The design is in [`BUFFER-DESIGN.md`](https://github.com/omaclaren/pi-studio/blob/main/BUFFER-DESIGN.md).
28
+ 3. Keep Derived REPL Transcript, editor-engine migration, and arbitrary pane arrangements outside both batches.
29
+
30
+ Current status:
31
+
32
+ - `0.9.60` combines the networking fix with Follow activity, Annotate response, and view-preserving Load response shortcuts, plus accurate draft-replacement protection. The published networking RC remains unchanged.
33
+ - The reporter has not replied to the RC invitation on [issue #4](https://github.com/omaclaren/pi-studio/issues/4) as of 2026-09-07. Exact `sbx` validation remains outstanding; the release proceeds with that limitation documented and the issue open for confirmation or a follow-up fix.
34
+ - `0.10.0` remains design-only. Approval to release `0.9.60` does not authorize buffer implementation.
23
35
 
24
36
  ## 0.9.52 — Rendering and media consistency (shipped 2026-08-28)
25
37
 
@@ -116,18 +128,62 @@ A small workflow refinement for moving Neovim-provided context through Pi and St
116
128
  - provide `Cmd/Ctrl+Shift+L` for loading and a confirmed **Clear Pi editor text…** fallback without changing Studio text or conversation history;
117
129
  - keep `pi-nvim-context`, protocol v1, networking, and the buffer-first architecture unchanged.
118
130
 
119
- ## 0.10.0Buffer-first editing
131
+ ## 0.9.60Networking and prompt-workflow shortcuts (2026-09-07)
132
+
133
+ This release combines the networking portion shipped as `0.9.60-rc.0` on 2026-09-03 with the small shortcut batch below. The published RC remains a reproducible networking test target.
134
+
135
+ ### Networking
136
+
137
+ A bounded implementation of GitHub issue #4:
138
+
139
+ - retain `127.0.0.1` as the default and require explicit `--listen-all` intent before binding to `0.0.0.0`;
140
+ - keep the server-lifetime bearer token mandatory and the generated URL on loopback for same-port container publishing;
141
+ - keep inbound `Host` values out of route parsing and require wildcard-mode browser origins to match the request authority;
142
+ - report the actual listening address, emit a prominent exposure warning, and require `/studio --stop` before changing a running server's binding;
143
+ - document loopback-only host publishing, private-network limits, and SSH forwarding as the safer option on untrusted networks;
144
+ - validate both default loopback and opt-in wildcard sockets without changing the buffer model, protocol v1, or `pi-repl`.
145
+
146
+ ### Shortcut and draft-replacement batch
147
+
148
+ The shortcuts and replacement safeguards below are implemented, with focused regression tests and browser workflow checks using controlled model replies. The buffer architecture remains deferred.
149
+
150
+ - Include `Cmd/Ctrl+Option/Alt+A` **Follow activity** toggle: retain opt-in persistence, full-workspace scope, modal protections, and key-repeat suppression.
151
+ - Add `Cmd/Ctrl+Option/Alt+Enter` for **Annotate response** from either main pane when the existing action is available. Invoke the same action as the button and retain its busy, response-type, and mode guards. Add Shift (`Cmd/Ctrl+Option/Alt+Shift+Enter`) to invoke **Load response into editor** without switching views, with the same replacement safeguards. Do not reinterpret `Cmd/Ctrl+Enter` as the right pane's primary action.
152
+ - Keep Run's existing active-pane behaviour and REPL/Side question submission shortcuts unchanged in this release.
153
+ - Protect **unsubmitted or unsaved work**, not every non-empty editor. The normal write prompt → accepted Run → response → Annotate response loop must remain confirmation-free when the submitted prompt is still unchanged. Ask before replacing post-submission edits, a new unsent draft, edited response annotations, or unsaved file changes; Cancel preserves text, source identity, and view state. Keep the decision/save-or-copy workflow bounded rather than adding a hidden second editor or a new recovery schema to `0.9.60`.
154
+ - Correlate an editor-text/source snapshot with the accepted submission and compare the current editor against that baseline; do not mark later typing as submitted when an acknowledgement or response arrives. Failed submissions do not establish the baseline, and submitting file text does not mark it saved.
155
+ - Capture the intended response and revalidate the editor after an asynchronous replacement decision; later typing or a changed target must not be overwritten by stale consent.
156
+ - Test both modifier variants, option-modified key values, key repeat, modal ownership, unavailable actions, and unchanged Run/REPL/Side question behaviour. Update the shortcut overlay, tooltips, accessibility metadata, and changelog only as behaviour is implemented.
157
+
158
+ ### Release gate
159
+
160
+ Run the normal full validation and fresh-package browser smoke over the combined batch, including a confirmation-free submitted-prompt → Annotate response loop and protected replacement/cancellation for genuinely new work. The previous RC verification does not substitute for checking the final artifact.
161
+
162
+ The networking review and production lifecycle tests support the reported fix; a separate TCP-forward smoke also verified HTML, client JavaScript, and WebSocket state delivery through a non-loopback destination. Neither is an exact `sbx` test. On 2026-09-07, Oliver approved shipping after final validation with this limitation documented and issue #4 left open. Reporter feedback can confirm the fix or lead to a follow-up patch. Any updated prerelease must use a new version, never replace `rc.0`.
163
+
164
+ Do not include buffer tabs, context-transfer messages, recovery schema v2, or REPL transcript work in this release.
165
+
166
+ ## 0.10.0 — Active Prompt and editable document buffers (planned)
167
+
168
+ Studio's primary loop is prompt construction and response reading. Documents remain useful editable sources, not implicit submission targets or a replacement for Neovim. The design is **one clearly active Prompt, other prompt drafts that can be parked, and independently editable documents**.
169
+
170
+ The first architectural release should add:
171
+
172
+ - a `StudioBufferStore` with stable buffer identity, explicit prompt/document roles, and separate selected-buffer and active-prompt identities;
173
+ - a prominent **Prompt** view with response/Working alongside it, plus editable document tabs with their own previews;
174
+ - recovery schema v2 with non-destructive migration from the single-document schema;
175
+ - buffer-specific text/baseline, dirty state, cursor, selection, scroll, preview context, annotations, and resource directory, preserving existing disk-revision checks;
176
+ - **Add selection to prompt** and **Copy document to prompt**, including an acknowledged handoff from editable companion views to the full workspace's explicit prompt destination;
177
+ - editable context snapshots of the visible text, including unsaved edits and source labels/ranges where known; no automatic submission, replacement, file write, or later live update of the copied text;
178
+ - a lightweight parked-prompt picker, with one active submission draft at a time and no per-draft conversation forks;
179
+ - tabs at ordinary widths, a compact searchable picker at narrow widths, and MRU/next/previous/close/reopen navigation;
180
+ - originating buffer ID, revision, and request ID targeting for asynchronous work, so switching tabs cannot redirect results or destructive actions.
120
181
 
121
- The first architectural `0.10` release should add:
182
+ Opening a document never changes the active prompt. Initially, document views should offer a clear return to Prompt rather than implicitly submit the document or send a hidden prompt. Keep Run's precise shortcut behaviour separate from the buffer model; settle it in the interaction tests before shipping.
122
183
 
123
- - a `StudioBufferStore` with stable document identity;
124
- - recovery schema v2 with migration from the single-document schema;
125
- - buffer-specific text/baseline, dirty state, cursor, selection, scroll, preview context, annotations, and resource directory;
126
- - tabs at ordinary widths and a compact searchable buffer picker at narrow widths;
127
- - MRU cycling, next/previous, close, and reopen commands;
128
- - originating buffer ID and revision targeting for asynchronous Pi work.
184
+ **Acceptance workflow:** start a prompt → inspect and edit a document with preview → add context to the prompt → annotate that detached copy → inspect another document → return and submit, without losing edits, changing the original through prompt annotations, or changing the submission destination accidentally.
129
185
 
130
- PDFs remain preview documents, and conversation history remains session-global. Arbitrary multi-pane layouts, project trees, and simultaneous per-buffer Pi conversations are outside the initial `0.10.0` scope.
186
+ PDFs remain preview documents; watched previews remain disk-authoritative and read-only. Conversation history, resource grants, and the exact-session Shared REPL Record remain outside individual buffers. Arbitrary multi-pane layouts, project trees, simultaneous per-buffer Pi conversations, editor-engine migration, and Derived REPL Transcript are outside the initial scope. See [`BUFFER-DESIGN.md`](https://github.com/omaclaren/pi-studio/blob/main/BUFFER-DESIGN.md) for the state, recovery, handoff, and implementation sequence.
131
187
 
132
188
  ## Validation matrix
133
189
 
package/WORKFLOW.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # pi-studio workflow/spec note
2
2
 
3
+ This note describes the original single-editor workflow; it is not the current release backlog. See [ROADMAP.md](ROADMAP.md) for the active `0.9.60` networking/shortcut batch and planned `0.10.0` work. The future active-Prompt/editable-document model is recorded in [BUFFER-DESIGN.md](https://github.com/omaclaren/pi-studio/blob/main/BUFFER-DESIGN.md); buffer tabs and context transfer are not implemented yet.
4
+
3
5
  ## Goal
4
6
 
5
7
  Keep Studio simple while supporting both loops:
@@ -117,8 +119,8 @@ Rules of thumb:
117
119
 
118
120
  ---
119
121
 
120
- ## Non-goals (for now)
122
+ ## Non-goals for the single-editor `0.9.x` workflow
121
123
 
122
- - Multi-document tabs
124
+ - Multi-document tabs (planned separately for `0.10.0`)
123
125
  - Multi-user collaboration
124
126
  - Heavy schema validation
@@ -255,6 +255,14 @@
255
255
  ) {
256
256
  throw new Error("Studio preview resource helpers failed to load.");
257
257
  }
258
+ const editorDraftHelpers = globalThis.PiStudioEditorDraftHelpers;
259
+ if (!editorDraftHelpers || typeof editorDraftHelpers.createSubmittedEditorDraftTracker !== "function"
260
+ || typeof editorDraftHelpers.needsDraftReplacementConfirmation !== "function") {
261
+ throw new Error("Studio editor draft helpers failed to load.");
262
+ }
263
+ const submittedEditorDrafts = editorDraftHelpers.createSubmittedEditorDraftTracker();
264
+ let editorSourceGeneration = 0;
265
+ let responseReplacementPending = false;
258
266
  const showMeHelpers = globalThis.PiStudioShowMeHelpers;
259
267
  if (!showMeHelpers || typeof showMeHelpers.chooseStudioShowMeFocus !== "function") {
260
268
  throw new Error("Studio Show me helpers failed to load.");
@@ -433,8 +441,8 @@
433
441
  rightViewSelect.title = isWatchedFilePreview
434
442
  ? "Read-only watched preview follows this file on disk."
435
443
  : (isEditorOnlyMode
436
- ? "Editor-only views: Editor Preview, contextual Quarto Preview for .qmd/.md/.markdown files, Changes, Files, REPL, or Side questions. F7 cycles; Cmd/Ctrl+Alt+3/5/6/7/8 switch directly to numbered right-pane views, and Cmd/Ctrl+Alt+F/R/Q open Files/REPL/Side questions."
437
- : "Right pane view mode. F7 cycles, including contextual Quarto Preview for file-backed .qmd, .md, and .markdown documents; Cmd/Ctrl+Alt+1–8 switches directly between the numbered views. Cmd/Ctrl+Alt+P/E/W/F/R/Q keep mnemonic shortcuts for Preview, Editor Preview, Working, Files, REPL, and Side questions.");
444
+ ? "Editor-only views: Editor Preview, contextual Quarto Preview for .qmd/.md/.markdown files, Changes, Files, REPL, or Side questions. F7 cycles; Cmd/Ctrl+Option/Alt+3/5/6/7/8 switch directly to numbered right-pane views, and Cmd/Ctrl+Option/Alt+F/R/Q open Files/REPL/Side questions."
445
+ : "Right pane view mode. F7 cycles, including contextual Quarto Preview for file-backed .qmd, .md, and .markdown documents; Cmd/Ctrl+Option/Alt+1–8 switches directly between the numbered views. Cmd/Ctrl+Option/Alt+P/E/W/F/R/Q keep mnemonic shortcuts for Preview, Editor Preview, Working, Files, REPL, and Side questions.");
438
446
  }
439
447
 
440
448
  function getInitialRightView(source) {
@@ -4524,6 +4532,11 @@
4524
4532
  return true;
4525
4533
  }
4526
4534
 
4535
+ function getEditorDraftSourceKey() {
4536
+ return JSON.stringify([editorSourceGeneration, sourceState.source, sourceState.path,
4537
+ sourceState.draftId, getCurrentResourceDirValue()]);
4538
+ }
4539
+
4527
4540
  function markFileBackedBaseline(text, diskRevision) {
4528
4541
  fileBackedBaselineText = String(text || "");
4529
4542
  fileBackedDiskRevision = normalizeStudioDiskRevision(diskRevision);
@@ -5096,6 +5109,35 @@
5096
5109
  switchRightPaneToView("editor-preview");
5097
5110
  }
5098
5111
 
5112
+ function studioModalBlocksDraftAction() {
5113
+ return Boolean(studioDecisionState || isScratchpadOpen() || isShortcutsOpen() || isQuizOpen()
5114
+ || isStudioPdfFocusOpen() || isStudioHtmlFocusOpen() || isStudioImageFocusOpen());
5115
+ }
5116
+
5117
+ function triggerLoadResponseShortcut() {
5118
+ if (isEditorOnlyMode || isWatchedFilePreview || studioModalBlocksDraftAction()) return false;
5119
+ if (!loadResponseBtn || loadResponseBtn.hidden || loadResponseBtn.disabled) return false;
5120
+ loadResponseBtn.click();
5121
+ return true;
5122
+ }
5123
+
5124
+ function triggerAnnotateResponseShortcut() {
5125
+ if (isEditorOnlyMode || isWatchedFilePreview || studioModalBlocksDraftAction()) return false;
5126
+ if (!annotateResponseBtn || annotateResponseBtn.hidden || annotateResponseBtn.disabled) return false;
5127
+ annotateResponseBtn.click();
5128
+ return true;
5129
+ }
5130
+
5131
+ function triggerActivityTrackingShortcut() {
5132
+ if (studioModalBlocksDraftAction()) return false;
5133
+ if (isEditorOnlyMode || isWatchedFilePreview) {
5134
+ setStatus("Follow activity is available only in the main editable Studio workspace.", "warning");
5135
+ return false;
5136
+ }
5137
+ setActivityTrackingEnabled(!activityTrackingEnabled);
5138
+ return true;
5139
+ }
5140
+
5099
5141
  function cycleActivePaneView(direction) {
5100
5142
  if (activePane === "right") {
5101
5143
  if (!rightViewSelect || rightViewSelect.disabled) {
@@ -5495,6 +5537,36 @@
5495
5537
  return;
5496
5538
  }
5497
5539
 
5540
+ const isLoadResponseShortcut = key === "Enter"
5541
+ && (event.metaKey || event.ctrlKey)
5542
+ && event.altKey
5543
+ && event.shiftKey;
5544
+ if (isLoadResponseShortcut) {
5545
+ event.preventDefault();
5546
+ if (!event.repeat && !event.isComposing) triggerLoadResponseShortcut();
5547
+ return;
5548
+ }
5549
+
5550
+ const isAnnotateResponseShortcut = key === "Enter"
5551
+ && (event.metaKey || event.ctrlKey)
5552
+ && event.altKey
5553
+ && !event.shiftKey;
5554
+ if (isAnnotateResponseShortcut) {
5555
+ event.preventDefault();
5556
+ if (!event.repeat && !event.isComposing) triggerAnnotateResponseShortcut();
5557
+ return;
5558
+ }
5559
+
5560
+ const isActivityTrackingShortcut = (key.toLowerCase() === "a" || code === "KeyA")
5561
+ && (event.metaKey || event.ctrlKey)
5562
+ && event.altKey
5563
+ && !event.shiftKey;
5564
+ if (isActivityTrackingShortcut) {
5565
+ event.preventDefault();
5566
+ if (!event.repeat && !event.isComposing) triggerActivityTrackingShortcut();
5567
+ return;
5568
+ }
5569
+
5498
5570
  const isContentFocusShortcut = key === "F8" && !event.metaKey && !event.ctrlKey && !event.altKey;
5499
5571
  if (isContentFocusShortcut) {
5500
5572
  event.preventDefault();
@@ -7993,7 +8065,7 @@
7993
8065
  refreshBtn.type = "button";
7994
8066
  refreshBtn.className = "studio-pdf-focus-btn studio-pdf-focus-refresh";
7995
8067
  refreshBtn.textContent = "Refresh";
7996
- refreshBtn.title = "Reload this PDF preview from disk. Shortcut: Cmd/Ctrl+Alt+Shift+R.";
8068
+ refreshBtn.title = "Reload this PDF preview from disk. Shortcut: Cmd/Ctrl+Option/Alt+Shift+R.";
7997
8069
  refreshBtn.setAttribute("aria-label", "Refresh PDF preview from disk");
7998
8070
  refreshBtn.addEventListener("click", () => refreshStudioPdfFocusViewer());
7999
8071
  actions.appendChild(refreshBtn);
@@ -9312,7 +9384,7 @@
9312
9384
  refreshBtn.type = "button";
9313
9385
  refreshBtn.className = "studio-pdf-card-action studio-pdf-card-refresh";
9314
9386
  refreshBtn.textContent = "Refresh";
9315
- refreshBtn.title = "Reload this PDF preview from disk. Shortcut: Cmd/Ctrl+Alt+Shift+R.";
9387
+ refreshBtn.title = "Reload this PDF preview from disk. Shortcut: Cmd/Ctrl+Option/Alt+Shift+R.";
9316
9388
  refreshBtn.addEventListener("click", (event) => {
9317
9389
  event.preventDefault();
9318
9390
  event.stopPropagation();
@@ -14250,7 +14322,7 @@
14250
14322
  annotateResponseBtn.textContent = annotationWorkspaceReady ? "Response ready to annotate" : "Annotate response";
14251
14323
  annotateResponseBtn.title = annotationWorkspaceReady
14252
14324
  ? "The selected response is exactly in the raw editor with Editor Preview open."
14253
- : "Load the selected response into the raw editor and show Editor Preview. This replaces the current editor text.";
14325
+ : "Load the selected response into the raw editor and show Editor Preview. Ask before replacing unsubmitted or unsaved work. Shortcut: Cmd/Ctrl+Option/Alt+Enter.";
14254
14326
 
14255
14327
  loadCritiqueNotesBtn.disabled = uiBusy || !isCritiqueResponse || !critiqueNotes || critiqueNotesLoaded;
14256
14328
  loadCritiqueNotesBtn.textContent = critiqueNotesLoaded ? "Critique notes already in editor" : "Load critique notes into editor";
@@ -14649,6 +14721,7 @@
14649
14721
  setStatus("This read-only preview remains bound to its watched file.", "warning");
14650
14722
  return false;
14651
14723
  }
14724
+ editorSourceGeneration += 1;
14652
14725
  sourceState = {
14653
14726
  source: next && next.source ? next.source : "blank",
14654
14727
  label: next && next.label ? next.label : "blank",
@@ -23701,6 +23774,11 @@
23701
23774
  return;
23702
23775
  }
23703
23776
 
23777
+ if (message.type === "run_accepted") {
23778
+ submittedEditorDrafts.accept(message.requestId);
23779
+ return;
23780
+ }
23781
+
23704
23782
  if (message.type === "request_started") {
23705
23783
  pendingRequestId = typeof message.requestId === "string" ? message.requestId : pendingRequestId;
23706
23784
  pendingKind = typeof message.kind === "string" ? message.kind : "unknown";
@@ -24157,6 +24235,7 @@
24157
24235
 
24158
24236
  if (message.type === "busy") {
24159
24237
  if (typeof message.requestId === "string") {
24238
+ submittedEditorDrafts.discard(message.requestId);
24160
24239
  restoreReservedPiEditorDraftSnapshot(message.requestId);
24161
24240
  pendingSaveOperations.delete(message.requestId);
24162
24241
  failPendingCompanionLaunch(message.requestId, "Studio could not start the companion editor because another request was busy.");
@@ -24181,6 +24260,7 @@
24181
24260
 
24182
24261
  if (message.type === "error") {
24183
24262
  if (typeof message.requestId === "string") {
24263
+ submittedEditorDrafts.discard(message.requestId);
24184
24264
  restoreReservedPiEditorDraftSnapshot(message.requestId);
24185
24265
  pendingSaveOperations.delete(message.requestId);
24186
24266
  failPendingCompanionLaunch(message.requestId, "Studio could not prepare the companion editor. Return to the originating Studio page for details.");
@@ -24331,6 +24411,7 @@
24331
24411
  quartoPreviewActionRequestId = null;
24332
24412
  sideQuestionMarkdownExportRequest = null;
24333
24413
  pendingPiEditorDraftSnapshots.clear();
24414
+ submittedEditorDrafts.clearPending();
24334
24415
  failAllPendingCompanionLaunches("The originating Studio connection was lost before the companion editor was ready.");
24335
24416
  if (rightView === "editor-quarto-preview") renderQuartoPreviewView();
24336
24417
  setBusy(true);
@@ -25034,27 +25115,56 @@
25034
25115
  }
25035
25116
 
25036
25117
  async function loadSelectedResponseIntoEditor(options) {
25037
- if (!latestResponseMarkdown.trim()) {
25118
+ if (isEditorOnlyMode || isWatchedFilePreview || uiBusy || responseReplacementPending
25119
+ || studioModalBlocksDraftAction()) return false;
25120
+ const prepareForAnnotation = Boolean(options && options.annotate);
25121
+ if (prepareForAnnotation && latestResponseIsStructuredCritique) return false;
25122
+ if (!prepareForAnnotation && rightView === "editor-quarto-preview") {
25123
+ setStatus("Choose another right-pane view before loading a response without switching views, or use Annotate response.", "warning");
25124
+ return false;
25125
+ }
25126
+ const responseText = latestResponseMarkdown;
25127
+ if (!responseText.trim()) {
25038
25128
  setStatus("No response available yet.", "warning");
25039
25129
  return false;
25040
25130
  }
25041
- const prepareForAnnotation = Boolean(options && options.annotate);
25042
25131
  const currentEditorText = String(sourceTextEl.value || "");
25043
- const replacingEditedResponse = prepareForAnnotation
25044
- && sourceState.source === "last-response"
25045
- && Boolean(currentEditorText.trim())
25046
- && normalizeForCompare(currentEditorText) !== latestResponseNormalized;
25047
- if (replacingEditedResponse) {
25048
- const confirmed = await requestStudioConfirmation(
25049
- "Replace your edited response with a fresh copy? Existing edits and annotations will be lost.",
25050
- { title: "Replace edited response?", confirmLabel: "Replace", destructive: true },
25051
- );
25132
+ const sourceKey = getEditorDraftSourceKey();
25133
+ const responseIndex = responseHistoryIndex;
25134
+ const responseTimestamp = latestResponseTimestamp;
25135
+ const diskRevision = fileBackedDiskRevision;
25136
+ const needsConfirmation = editorDraftHelpers.needsDraftReplacementConfirmation({
25137
+ text: currentEditorText,
25138
+ responseText,
25139
+ fileBacked: hasRefreshableFilePath(),
25140
+ dirty: editorDiffersFromFileBackedBaseline(),
25141
+ submitted: submittedEditorDrafts.matches(currentEditorText, sourceKey),
25142
+ });
25143
+ if (needsConfirmation) {
25144
+ responseReplacementPending = true;
25145
+ let confirmed;
25146
+ try {
25147
+ confirmed = await requestStudioConfirmation(
25148
+ "Replace the current editor text with the selected response? Unsubmitted edits or unsaved file changes will be lost. Cancel to save or copy them first.",
25149
+ { title: "Replace editor text?", confirmLabel: "Replace", destructive: true },
25150
+ );
25151
+ } finally {
25152
+ responseReplacementPending = false;
25153
+ }
25052
25154
  if (!confirmed) {
25053
25155
  setStatus("Kept the current editor text.");
25054
25156
  return false;
25055
25157
  }
25158
+ if (uiBusy || studioModalBlocksDraftAction() || currentEditorText !== sourceTextEl.value
25159
+ || sourceKey !== getEditorDraftSourceKey() || diskRevision !== fileBackedDiskRevision
25160
+ || responseText !== latestResponseMarkdown || responseIndex !== responseHistoryIndex
25161
+ || responseTimestamp !== latestResponseTimestamp
25162
+ || (!prepareForAnnotation && rightView === "editor-quarto-preview")) {
25163
+ setStatus("Editor, response, or view changed while confirmation was open. Kept the editor text; try again.", "warning");
25164
+ return false;
25165
+ }
25056
25166
  }
25057
- setEditorText(latestResponseMarkdown, { preserveScroll: false, preserveSelection: false });
25167
+ setEditorText(responseText, { preserveScroll: false, preserveSelection: false });
25058
25168
  setSourceState({ source: "last-response", label: "last model response", path: null });
25059
25169
  if (prepareForAnnotation) {
25060
25170
  if (editorView !== "markdown") setEditorView("markdown");
@@ -25574,6 +25684,7 @@
25574
25684
 
25575
25685
  const requestId = beginUiAction("direct");
25576
25686
  if (!requestId) return;
25687
+ submittedEditorDrafts.remember(requestId, sourceTextEl.value, getEditorDraftSourceKey());
25577
25688
  const piEditorDraftSnapshot = reserveLinkedPiEditorDraftSnapshot(requestId);
25578
25689
 
25579
25690
  const sent = sendMessage({
@@ -25584,6 +25695,7 @@
25584
25695
  });
25585
25696
 
25586
25697
  if (!sent) {
25698
+ submittedEditorDrafts.discard(requestId);
25587
25699
  restoreReservedPiEditorDraftSnapshot(requestId);
25588
25700
  pendingRequestId = null;
25589
25701
  pendingKind = null;
@@ -25605,6 +25717,7 @@
25605
25717
 
25606
25718
  const requestId = makeRequestId();
25607
25719
  clearTitleAttention();
25720
+ submittedEditorDrafts.remember(requestId, sourceTextEl.value, getEditorDraftSourceKey());
25608
25721
  const piEditorDraftSnapshot = reserveLinkedPiEditorDraftSnapshot(requestId);
25609
25722
  const sent = sendMessage({
25610
25723
  type: "send_run_request",
@@ -25613,6 +25726,7 @@
25613
25726
  piEditorDraftSnapshot: piEditorDraftSnapshot || undefined,
25614
25727
  });
25615
25728
  if (!sent) {
25729
+ submittedEditorDrafts.discard(requestId);
25616
25730
  restoreReservedPiEditorDraftSnapshot(requestId);
25617
25731
  return;
25618
25732
  }
@@ -0,0 +1,64 @@
1
+ (() => {
2
+ // Page-memory-only. No persistence or authority over Pi's terminal composer.
3
+ const MAX_TEXT_CHARS = 900_000;
4
+ const MAX_PENDING = 8;
5
+ const MAX_PENDING_TEXT_CHARS = 2_700_000;
6
+
7
+ function createSubmittedEditorDraftTracker() {
8
+ const pending = new Map();
9
+ let pendingChars = 0;
10
+ let sequence = 0;
11
+ let accepted = null;
12
+
13
+ function discard(requestId) {
14
+ const snapshot = pending.get(requestId);
15
+ if (!snapshot) return false;
16
+ pendingChars -= snapshot.text.length;
17
+ pending.delete(requestId);
18
+ return true;
19
+ }
20
+
21
+ return Object.freeze({
22
+ remember(requestId, text, sourceKey) {
23
+ if (typeof requestId !== "string" || !requestId || requestId.length > 256
24
+ || pending.has(requestId) || (accepted && accepted.requestId === requestId)) return false;
25
+ if (typeof text !== "string" || text.length > MAX_TEXT_CHARS
26
+ || typeof sourceKey !== "string" || !sourceKey || sourceKey.length > 24_000) return false;
27
+ while (pending.size >= MAX_PENDING || pendingChars + text.length > MAX_PENDING_TEXT_CHARS) {
28
+ discard(pending.keys().next().value);
29
+ }
30
+ pending.set(requestId, { requestId, text, sourceKey, sequence: ++sequence });
31
+ pendingChars += text.length;
32
+ return true;
33
+ },
34
+ accept(requestId) {
35
+ const snapshot = pending.get(requestId);
36
+ if (!snapshot) return false;
37
+ discard(requestId);
38
+ // Late/duplicate acknowledgements cannot replace a newer accepted baseline.
39
+ if (accepted && snapshot.sequence <= accepted.sequence) return false;
40
+ accepted = snapshot;
41
+ return true;
42
+ },
43
+ matches(text, sourceKey) {
44
+ return Boolean(accepted && accepted.text === text && accepted.sourceKey === sourceKey);
45
+ },
46
+ discard,
47
+ clearPending() {
48
+ pending.clear();
49
+ pendingChars = 0;
50
+ },
51
+ });
52
+ }
53
+
54
+ function needsDraftReplacementConfirmation({ text, responseText, fileBacked, dirty, submitted }) {
55
+ // Even an empty file buffer can contain an unsaved deletion. Sending it is not saving it.
56
+ if (fileBacked) return Boolean(dirty);
57
+ return Boolean(text.trim() && text !== responseText && !submitted);
58
+ }
59
+
60
+ globalThis.PiStudioEditorDraftHelpers = Object.freeze({
61
+ createSubmittedEditorDraftTracker,
62
+ needsDraftReplacementConfirmation,
63
+ });
64
+ })();
package/index.ts CHANGED
@@ -46,6 +46,13 @@ import { createStudioPandocHtmlResourceFlagResolver } from "./shared/studio-pand
46
46
  import { createStudioResourceGrantRegistry } from "./shared/studio-resource-grants.js";
47
47
  import { prepareStudioLatexForPandoc } from "./shared/studio-latex-pandoc-compat.js";
48
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";
49
56
  import { buildStudioReplTmuxStartArgs } from "./shared/studio-repl-tmux.js";
50
57
  import {
51
58
  REPL_SESSION_RECORD_ID_OPTION,
@@ -189,6 +196,7 @@ const STUDIO_NAVIGATION_HELPERS_URL = new URL("./client/studio-navigation-helper
189
196
  const STUDIO_PREVIEW_RESOURCE_HELPERS_URL = new URL("./client/studio-preview-resource-helpers.js", import.meta.url);
190
197
  const STUDIO_SHOW_ME_HELPERS_URL = new URL("./client/studio-show-me-helpers.js", import.meta.url);
191
198
  const STUDIO_SIDE_QUESTION_HELPERS_URL = new URL("./client/studio-side-question-helpers.js", import.meta.url);
199
+ const STUDIO_EDITOR_DRAFT_HELPERS_URL = new URL("./client/studio-editor-draft-helpers.js", import.meta.url);
192
200
  const STUDIO_CLIENT_URL = new URL("./client/studio-client.js", import.meta.url);
193
201
 
194
202
  interface StudioServerState {
@@ -197,6 +205,8 @@ interface StudioServerState {
197
205
  clients: Set<WebSocket>;
198
206
  clientModes: Map<WebSocket, StudioUiMode>;
199
207
  port: number;
208
+ bindHost: string;
209
+ listenAll: boolean;
200
210
  token: string;
201
211
  }
202
212
 
@@ -11865,13 +11875,6 @@ function interruptStudioReplSession(sessionName: string): { ok: true; message: s
11865
11875
  return { ok: true, message: `Interrupted ${sessionName}.` };
11866
11876
  }
11867
11877
 
11868
- function isAllowedOrigin(_origin: string | undefined, _port: number): boolean {
11869
- // For local-only studio, token auth is the primary guard. In practice,
11870
- // browser origin headers can vary (or be omitted) across wrappers/browsers,
11871
- // so we avoid brittle origin-based rejection here.
11872
- return true;
11873
- }
11874
-
11875
11878
  function normalizeStudioUiMode(raw: string | null | undefined): StudioUiMode {
11876
11879
  return raw === "editor-only" ? "editor-only" : "full";
11877
11880
  }
@@ -11936,7 +11939,7 @@ function buildStudioUrl(
11936
11939
  docId?: string,
11937
11940
  options?: StudioUrlOptions,
11938
11941
  ): string {
11939
- return `http://127.0.0.1:${port}${buildStudioRelativeUrl(token, mode, doc, docId, options)}`;
11942
+ return `http://${STUDIO_ADVERTISED_HOST}:${port}${buildStudioRelativeUrl(token, mode, doc, docId, options)}`;
11940
11943
  }
11941
11944
 
11942
11945
  interface StudioLaunchFlags {
@@ -11944,17 +11947,19 @@ interface StudioLaunchFlags {
11944
11947
  openRemoteBrowser: boolean;
11945
11948
  noBrowser: boolean;
11946
11949
  watchPdf: boolean;
11950
+ listenAll: boolean;
11947
11951
  port?: number;
11948
11952
  error?: string;
11949
11953
  }
11950
11954
 
11951
11955
  function parseStudioLaunchOpenFlags(rawArgs: string): StudioLaunchFlags {
11952
11956
  const parsed = tokenizeStudioCommandArgs(rawArgs);
11953
- if (parsed.error) return { args: rawArgs, openRemoteBrowser: false, noBrowser: false, watchPdf: false, error: parsed.error };
11957
+ if (parsed.error) return { args: rawArgs, openRemoteBrowser: false, noBrowser: false, watchPdf: false, listenAll: false, error: parsed.error };
11954
11958
  const remaining: string[] = [];
11955
11959
  let openRemoteBrowser = false;
11956
11960
  let noBrowser = false;
11957
11961
  let watchPdf = false;
11962
+ let listenAll = false;
11958
11963
  let port: number | undefined;
11959
11964
  for (let i = 0; i < parsed.tokens.length; i += 1) {
11960
11965
  const token = parsed.tokens[i]!;
@@ -11970,14 +11975,18 @@ function parseStudioLaunchOpenFlags(rawArgs: string): StudioLaunchFlags {
11970
11975
  watchPdf = true;
11971
11976
  continue;
11972
11977
  }
11978
+ if (token === "--listen-all") {
11979
+ listenAll = true;
11980
+ continue;
11981
+ }
11973
11982
  if (token === "--port" || token.startsWith("--port=")) {
11974
11983
  const rawPort = token.startsWith("--port=") ? token.slice("--port=".length) : parsed.tokens[++i];
11975
11984
  if (!rawPort) {
11976
- return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, error: "Missing value for --port." };
11985
+ return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, listenAll, error: "Missing value for --port." };
11977
11986
  }
11978
11987
  const requestedPort = Number(rawPort);
11979
11988
  if (!Number.isInteger(requestedPort) || requestedPort < 1 || requestedPort > 65535) {
11980
- return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, error: `Invalid --port value: ${rawPort}. Use an integer from 1 to 65535.` };
11989
+ return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, listenAll, error: `Invalid --port value: ${rawPort}. Use an integer from 1 to 65535.` };
11981
11990
  }
11982
11991
  port = requestedPort;
11983
11992
  continue;
@@ -11985,9 +11994,9 @@ function parseStudioLaunchOpenFlags(rawArgs: string): StudioLaunchFlags {
11985
11994
  remaining.push(token);
11986
11995
  }
11987
11996
  if (openRemoteBrowser && noBrowser) {
11988
- return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, port, error: "Use either --no-browser or --open-browser, not both." };
11997
+ return { args: rawArgs, openRemoteBrowser, noBrowser, watchPdf, listenAll, port, error: "Use either --no-browser or --open-browser, not both." };
11989
11998
  }
11990
- return { args: remaining.join(" "), openRemoteBrowser, noBrowser, watchPdf, port };
11999
+ return { args: remaining.join(" "), openRemoteBrowser, noBrowser, watchPdf, listenAll, port };
11991
12000
  }
11992
12001
 
11993
12002
  function shouldAutoOpenStudioBrowser(options?: { openRemoteBrowser?: boolean; noBrowser?: boolean }): boolean {
@@ -12380,6 +12389,7 @@ function buildStudioHtml(
12380
12389
  const mermaidHelpersScriptHref = `/studio-mermaid-helpers.js?token=${encodeURIComponent(studioToken ?? "")}`;
12381
12390
  const navigationHelpersScriptHref = `/studio-navigation-helpers.js?token=${encodeURIComponent(studioToken ?? "")}`;
12382
12391
  const previewResourceHelpersScriptHref = `/studio-preview-resource-helpers.js?token=${encodeURIComponent(studioToken ?? "")}`;
12392
+ const editorDraftHelpersScriptHref = `/studio-editor-draft-helpers.js?token=${encodeURIComponent(studioToken ?? "")}`;
12383
12393
  const showMeHelpersScriptHref = `/studio-show-me-helpers.js?token=${encodeURIComponent(studioToken ?? "")}`;
12384
12394
  const sideQuestionHelpersScriptHref = `/studio-side-question-helpers.js?token=${encodeURIComponent(studioToken ?? "")}`;
12385
12395
  const clientScriptHref = `/studio-client.js?token=${encodeURIComponent(studioToken ?? "")}`;
@@ -12544,7 +12554,7 @@ ${cssVarsBlock}
12544
12554
  <option value="editor-top">Layout: Editor above</option>
12545
12555
  <option value="response-top">Layout: Response above</option>
12546
12556
  </select>
12547
- <select id="activityTrackingSelect" class="studio-flat-select" aria-label="Follow main Pi activity" title="Optionally follow main Pi activity from Working to the response view; off by default.">
12557
+ <select id="activityTrackingSelect" class="studio-flat-select" aria-label="Follow main Pi activity" aria-keyshortcuts="Meta+Alt+A Control+Alt+A" title="Optionally follow main Pi activity from Working to the response view; off by default. Shortcut: Cmd/Ctrl+Option/Alt+A.">
12548
12558
  <option value="off">Follow activity: Off</option>
12549
12559
  <option value="on">Follow activity: On</option>
12550
12560
  </select>
@@ -12647,7 +12657,7 @@ ${cssVarsBlock}
12647
12657
  <div id="rightSectionHeader" class="section-header">
12648
12658
  <div class="section-header-main">
12649
12659
  <span id="rightViewSelectWrap" class="studio-header-select-wrap">
12650
- <select id="rightViewSelect" aria-label="Response view mode" title="Right pane view mode. F7 cycles when the right pane is active; Cmd/Ctrl+Alt+1–8 switches directly between all right-pane views. Cmd/Ctrl+Alt+P/E/W/F/R/Q keep mnemonic shortcuts for Preview, Editor Preview, Working, Files, REPL, and Side questions.">
12660
+ <select id="rightViewSelect" aria-label="Response view mode" title="Right pane view mode. F7 cycles when the right pane is active; Cmd/Ctrl+Option/Alt+1–8 switches directly between all right-pane views. Cmd/Ctrl+Option/Alt+P/E/W/F/R/Q keep mnemonic shortcuts for Preview, Editor Preview, Working, Files, REPL, and Side questions.">
12651
12661
  <option value="markdown">Response (Raw)</option>
12652
12662
  <option value="preview" selected>Response (Preview)</option>
12653
12663
  <option value="editor-preview">Editor (Preview)</option>
@@ -12722,8 +12732,8 @@ ${cssVarsBlock}
12722
12732
  <button id="historyLastBtn" type="button" title="Jump to the latest loaded response in the current branch history.">Last response ▶|</button>
12723
12733
  </div>
12724
12734
  <div class="response-actions-row response-result-row">
12725
- <button id="annotateResponseBtn" type="button" title="Load the selected response into the raw editor and show Editor Preview. This replaces the current editor text.">Annotate response</button>
12726
- <button id="loadResponseBtn" type="button">Load response into editor</button>
12735
+ <button id="annotateResponseBtn" type="button" aria-keyshortcuts="Meta+Alt+Enter Control+Alt+Enter" title="Load the selected response into the raw editor and show Editor Preview. Ask before replacing unsubmitted or unsaved work. Shortcut: Cmd/Ctrl+Option/Alt+Enter.">Annotate response</button>
12736
+ <button id="loadResponseBtn" type="button" aria-keyshortcuts="Meta+Alt+Shift+Enter Control+Alt+Shift+Enter" title="Load the selected response into the editor without switching views. Choose another view first if Quarto Preview is active. Ask before replacing unsubmitted or unsaved work. Shortcut: Cmd/Ctrl+Option/Alt+Shift+Enter.">Load response into editor</button>
12727
12737
  <button id="loadCritiqueNotesBtn" type="button" hidden>Load critique notes into editor</button>
12728
12738
  <button id="loadCritiqueFullBtn" type="button" hidden>Load full critique into editor</button>
12729
12739
  <button id="loadHistoryPromptBtn" type="button" title="Load the prompt that generated the selected response into the editor.">Load response prompt into editor</button>
@@ -12760,13 +12770,13 @@ ${cssVarsBlock}
12760
12770
  <dl>
12761
12771
  <div><dt>F6</dt><dd>Switch between editor and right pane</dd></div>
12762
12772
  <div><dt>F7 / Shift+F7</dt><dd>Cycle the active pane's view</dd></div>
12763
- <div><dt>Cmd/Ctrl+Alt+1–8</dt><dd>Switch the right pane directly: Response Raw, Response Preview, Editor Preview, Working, Changes, Files, REPL, Side questions</dd></div>
12764
- <div><dt>Cmd/Ctrl+Alt+P</dt><dd>Switch the right pane directly to Response Preview; in editor-only views, Editor Preview</dd></div>
12765
- <div><dt>Cmd/Ctrl+Alt+E</dt><dd>Switch the right pane directly to Editor Preview</dd></div>
12766
- <div><dt>Cmd/Ctrl+Alt+W</dt><dd>Switch the right pane directly to Working</dd></div>
12767
- <div><dt>Cmd/Ctrl+Alt+F</dt><dd>Switch the right pane directly to Files</dd></div>
12768
- <div><dt>Cmd/Ctrl+Alt+R</dt><dd>Switch the right pane directly to REPL and focus Quick send when a session is selected</dd></div>
12769
- <div><dt>Cmd/Ctrl+Alt+Q</dt><dd>Switch the right pane directly to Side questions</dd></div>
12773
+ <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
+ <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
+ <div><dt>Cmd/Ctrl+Option/Alt+E</dt><dd>Switch the right pane directly to Editor Preview</dd></div>
12776
+ <div><dt>Cmd/Ctrl+Option/Alt+W</dt><dd>Switch the right pane directly to Working</dd></div>
12777
+ <div><dt>Cmd/Ctrl+Option/Alt+F</dt><dd>Switch the right pane directly to Files</dd></div>
12778
+ <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
+ <div><dt>Cmd/Ctrl+Option/Alt+Q</dt><dd>Switch the right pane directly to Side questions</dd></div>
12770
12780
  <div><dt>F8</dt><dd>Focus editor text</dd></div>
12771
12781
  <div><dt>Shift+F8</dt><dd>Focus right-pane content</dd></div>
12772
12782
  <div><dt>F9</dt><dd>Toggle Zen mode and hide or restore the Studio header</dd></div>
@@ -12778,10 +12788,11 @@ ${cssVarsBlock}
12778
12788
  <section class="shortcuts-group">
12779
12789
  <h3>View</h3>
12780
12790
  <dl>
12781
- <div><dt>Alt/Option+=</dt><dd>Increase the active pane's text size when not editing text</dd></div>
12782
- <div><dt>Alt/Option+-</dt><dd>Decrease the active pane's text size when not editing text</dd></div>
12783
- <div><dt>Alt/Option+0</dt><dd>Reset the active pane's text size when not editing text</dd></div>
12784
- <div><dt>Cmd/Ctrl+Alt+Shift+R</dt><dd>Refresh the focused or visible PDF preview from disk</dd></div>
12791
+ <div><dt>Option/Alt+=</dt><dd>Increase the active pane's text size when not editing text</dd></div>
12792
+ <div><dt>Option/Alt+-</dt><dd>Decrease the active pane's text size when not editing text</dd></div>
12793
+ <div><dt>Option/Alt+0</dt><dd>Reset the active pane's text size when not editing text</dd></div>
12794
+ <div class="shortcuts-full-only"><dt>Cmd/Ctrl+Option/Alt+A</dt><dd>Toggle Follow activity in the main editable Studio workspace</dd></div>
12795
+ <div><dt>Cmd/Ctrl+Option/Alt+Shift+R</dt><dd>Refresh the focused or visible PDF preview from disk</dd></div>
12785
12796
  </dl>
12786
12797
  </section>
12787
12798
  <section class="shortcuts-group">
@@ -12800,9 +12811,11 @@ ${cssVarsBlock}
12800
12811
  <section class="shortcuts-group shortcuts-full-only">
12801
12812
  <h3>Response</h3>
12802
12813
  <dl>
12803
- <div><dt>Alt/Option+←</dt><dd>Previous response when not editing text</dd></div>
12804
- <div><dt>Alt/Option+→</dt><dd>Next response when not editing text</dd></div>
12805
- <div><dt>Alt/Option+l</dt><dd>Latest response when not editing text</dd></div>
12814
+ <div><dt>Cmd/Ctrl+Option/Alt+Enter</dt><dd>Annotate the selected response from either main pane; protect unsubmitted or unsaved work</dd></div>
12815
+ <div><dt>Cmd/Ctrl+Option/Alt+Shift+Enter</dt><dd>Load the selected response into the editor without switching views; same replacement safeguards</dd></div>
12816
+ <div><dt>Option/Alt+←</dt><dd>Previous response when not editing text</dd></div>
12817
+ <div><dt>Option/Alt+→</dt><dd>Next response when not editing text</dd></div>
12818
+ <div><dt>Option/Alt+l</dt><dd>Latest response when not editing text</dd></div>
12806
12819
  </dl>
12807
12820
  </section>
12808
12821
  <section class="shortcuts-group">
@@ -12855,6 +12868,7 @@ ${cssVarsBlock}
12855
12868
  <script src="${mermaidHelpersScriptHref}"></script>
12856
12869
  <script src="${navigationHelpersScriptHref}"></script>
12857
12870
  <script src="${previewResourceHelpersScriptHref}"></script>
12871
+ <script src="${editorDraftHelpersScriptHref}"></script>
12858
12872
  <script src="${showMeHelpersScriptHref}"></script>
12859
12873
  <script src="${sideQuestionHelpersScriptHref}"></script>
12860
12874
  <script src="${clientScriptHref}"></script>
@@ -15782,6 +15796,7 @@ export default function (pi: ExtensionAPI) {
15782
15796
 
15783
15797
  try {
15784
15798
  pi.sendUserMessage(msg.text, { deliverAs: "steer" });
15799
+ sendToClient(client, { type: "run_accepted", requestId: msg.requestId });
15785
15800
  broadcast({
15786
15801
  type: "request_queued",
15787
15802
  requestId: msg.requestId,
@@ -15815,6 +15830,7 @@ export default function (pi: ExtensionAPI) {
15815
15830
 
15816
15831
  try {
15817
15832
  pi.sendUserMessage(msg.text);
15833
+ sendToClient(client, { type: "run_accepted", requestId: msg.requestId });
15818
15834
  reportPiEditorDraftDisposition(client, msg.requestId, msg.piEditorDraftSnapshot);
15819
15835
  } catch (error) {
15820
15836
  clearStudioDirectRunState();
@@ -17446,12 +17462,9 @@ export default function (pi: ExtensionAPI) {
17446
17462
  return;
17447
17463
  }
17448
17464
 
17449
- let requestUrl: URL;
17450
- try {
17451
- const host = req.headers.host ?? `127.0.0.1:${serverState.port}`;
17452
- requestUrl = new URL(req.url ?? "/", `http://${host}`);
17453
- } catch (error) {
17454
- respondText(res, 400, `Invalid request URL: ${error instanceof Error ? error.message : String(error)}`);
17465
+ const requestUrl = parseStudioRequestTarget(req.url);
17466
+ if (!requestUrl) {
17467
+ respondText(res, 400, "Invalid request URL");
17455
17468
  return;
17456
17469
  }
17457
17470
 
@@ -17535,6 +17548,7 @@ export default function (pi: ExtensionAPI) {
17535
17548
  || requestUrl.pathname === "/studio-preview-resource-helpers.js"
17536
17549
  || requestUrl.pathname === "/studio-show-me-helpers.js"
17537
17550
  || requestUrl.pathname === "/studio-side-question-helpers.js"
17551
+ || requestUrl.pathname === "/studio-editor-draft-helpers.js"
17538
17552
  || requestUrl.pathname === "/studio-client.js"
17539
17553
  ) {
17540
17554
  const token = requestUrl.searchParams.get("token") ?? "";
@@ -17562,7 +17576,9 @@ export default function (pi: ExtensionAPI) {
17562
17576
  ? STUDIO_SHOW_ME_HELPERS_URL
17563
17577
  : requestUrl.pathname === "/studio-side-question-helpers.js"
17564
17578
  ? STUDIO_SIDE_QUESTION_HELPERS_URL
17565
- : STUDIO_CLIENT_URL;
17579
+ : requestUrl.pathname === "/studio-editor-draft-helpers.js"
17580
+ ? STUDIO_EDITOR_DRAFT_HELPERS_URL
17581
+ : STUDIO_CLIENT_URL;
17566
17582
  const targetLabel = requestUrl.pathname === "/studio-annotation-helpers.js"
17567
17583
  ? "studio annotation helper script"
17568
17584
  : requestUrl.pathname === "/studio-mermaid-helpers.js"
@@ -17575,7 +17591,9 @@ export default function (pi: ExtensionAPI) {
17575
17591
  ? "studio Show me helper script"
17576
17592
  : requestUrl.pathname === "/studio-side-question-helpers.js"
17577
17593
  ? "studio side-question helper script"
17578
- : "studio client script";
17594
+ : requestUrl.pathname === "/studio-editor-draft-helpers.js"
17595
+ ? "studio editor draft helper script"
17596
+ : "studio client script";
17579
17597
 
17580
17598
  try {
17581
17599
  const clientScript = readFileSync(targetUrl, "utf-8");
@@ -18044,9 +18062,10 @@ export default function (pi: ExtensionAPI) {
18044
18062
  res.end(buildStudioHtml(requestInitialDocument, serverState.token, lastCommandCtx?.ui.theme, currentModelLabel, terminalSessionLabel, terminalSessionDetail, contextUsageSnapshot, studioMode));
18045
18063
  };
18046
18064
 
18047
- const ensureServer = async (requestedPort?: number): Promise<StudioServerState> => {
18065
+ const ensureServer = async (requestedPort?: number, listenAll = false): Promise<StudioServerState> => {
18048
18066
  if (serverState) return serverState;
18049
18067
 
18068
+ const binding = resolveStudioNetworkBinding(listenAll);
18050
18069
  const server = createServer(handleHttpRequest);
18051
18070
  const wsServer = new WebSocketServer({ noServer: true });
18052
18071
  const clients = new Set<WebSocket>();
@@ -18058,12 +18077,18 @@ export default function (pi: ExtensionAPI) {
18058
18077
  clients,
18059
18078
  clientModes,
18060
18079
  port: 0,
18080
+ bindHost: binding.bindHost,
18081
+ listenAll: binding.listenAll,
18061
18082
  token: createSessionToken(),
18062
18083
  };
18063
18084
 
18064
18085
  server.on("upgrade", (req, socket, head) => {
18065
- const host = req.headers.host ?? `127.0.0.1:${state.port}`;
18066
- const requestUrl = new URL(req.url ?? "/", `http://${host}`);
18086
+ const requestUrl = parseStudioRequestTarget(req.url);
18087
+ if (!requestUrl) {
18088
+ socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
18089
+ socket.destroy();
18090
+ return;
18091
+ }
18067
18092
 
18068
18093
  if (requestUrl.pathname !== "/ws") {
18069
18094
  socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
@@ -18078,7 +18103,9 @@ export default function (pi: ExtensionAPI) {
18078
18103
  return;
18079
18104
  }
18080
18105
 
18081
- if (!isAllowedOrigin(req.headers.origin, state.port)) {
18106
+ const origin = typeof req.headers.origin === "string" ? req.headers.origin : undefined;
18107
+ const requestHost = typeof req.headers.host === "string" ? req.headers.host : undefined;
18108
+ if (!isStudioWebSocketOriginAllowed(origin, requestHost, state.listenAll)) {
18082
18109
  socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
18083
18110
  socket.destroy();
18084
18111
  return;
@@ -18090,8 +18117,15 @@ export default function (pi: ExtensionAPI) {
18090
18117
  });
18091
18118
 
18092
18119
  wsServer.on("connection", (ws, req) => {
18093
- const host = req.headers.host ?? `127.0.0.1:${state.port}`;
18094
- const requestUrl = new URL(req.url ?? "/ws", `http://${host}`);
18120
+ const requestUrl = parseStudioRequestTarget(req.url);
18121
+ if (!requestUrl) {
18122
+ try {
18123
+ ws.close(1008, "Invalid request URL");
18124
+ } catch {
18125
+ // Ignore close errors.
18126
+ }
18127
+ return;
18128
+ }
18095
18129
  const clientMode = normalizeStudioUiMode(requestUrl.searchParams.get("mode"));
18096
18130
  const requestedWatchedPath = (requestUrl.searchParams.get("watchPath") ?? "").trim();
18097
18131
  const canonicalWatchedPath = requestedWatchedPath ? resolve(requestedWatchedPath) : "";
@@ -18190,7 +18224,7 @@ export default function (pi: ExtensionAPI) {
18190
18224
  };
18191
18225
  server.once("error", onError);
18192
18226
  server.once("listening", onListening);
18193
- server.listen(listenPort, "127.0.0.1");
18227
+ server.listen(listenPort, state.bindHost);
18194
18228
  });
18195
18229
 
18196
18230
  const address = server.address();
@@ -19063,6 +19097,10 @@ export default function (pi: ExtensionAPI) {
19063
19097
  if (serverState && launchOpenFlags.port && serverState.port !== launchOpenFlags.port) {
19064
19098
  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");
19065
19099
  }
19100
+ if (serverState && launchOpenFlags.listenAll && !serverState.listenAll) {
19101
+ 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");
19102
+ return;
19103
+ }
19066
19104
 
19067
19105
  const parsedLaunchPath = options?.allowPdfPreview ? parsePathArgument(launchArgs) : null;
19068
19106
  const launchesPdfPreview = parsedLaunchPath
@@ -19082,9 +19120,12 @@ export default function (pi: ExtensionAPI) {
19082
19120
  if (serverState) {
19083
19121
  const url = buildStudioUrl(serverState.port, serverState.token, "full");
19084
19122
  ctx.ui.notify(`Studio URL: ${url}`, "info");
19085
- const tunnelHint = buildStudioSshTunnelHint(serverState.port, url)
19086
- ?? (launchOpenFlags.noBrowser ? buildStudioForwardingHint(serverState.port, url, { prefix: "Browser auto-open was skipped because --no-browser was used." }) : null);
19123
+ const tunnelHint = serverState.listenAll
19124
+ ? null
19125
+ : buildStudioSshTunnelHint(serverState.port, url)
19126
+ ?? (launchOpenFlags.noBrowser ? buildStudioForwardingHint(serverState.port, url, { prefix: "Browser auto-open was skipped because --no-browser was used." }) : null);
19087
19127
  if (tunnelHint) ctx.ui.notify(tunnelHint, "info");
19128
+ if (serverState.listenAll) ctx.ui.notify(buildStudioListenAllWarning(serverState.port, url), "warning");
19088
19129
  }
19089
19130
  return;
19090
19131
  }
@@ -19117,7 +19158,7 @@ export default function (pi: ExtensionAPI) {
19117
19158
 
19118
19159
  let state: StudioServerState;
19119
19160
  try {
19120
- state = await ensureServer(launchOpenFlags.port);
19161
+ state = await ensureServer(launchOpenFlags.port, launchOpenFlags.listenAll);
19121
19162
  } catch (error) {
19122
19163
  const message = error instanceof Error ? error.message : String(error);
19123
19164
  const portText = launchOpenFlags.port ? ` on port ${launchOpenFlags.port}` : "";
@@ -19130,8 +19171,10 @@ export default function (pi: ExtensionAPI) {
19130
19171
  skipWorkspaceRestore: selection.skipWorkspaceRestore,
19131
19172
  paneFocus: selection.paneFocus,
19132
19173
  });
19133
- const tunnelHint = buildStudioSshTunnelHint(state.port, url)
19134
- ?? (launchOpenFlags.noBrowser ? buildStudioForwardingHint(state.port, url, { prefix: "Browser auto-open was skipped because --no-browser was used." }) : null);
19174
+ const tunnelHint = state.listenAll
19175
+ ? null
19176
+ : buildStudioSshTunnelHint(state.port, url)
19177
+ ?? (launchOpenFlags.noBrowser ? buildStudioForwardingHint(state.port, url, { prefix: "Browser auto-open was skipped because --no-browser was used." }) : null);
19135
19178
  const openedLabel = selection.kind === "pdf-preview"
19136
19179
  ? "pi Studio PDF preview"
19137
19180
  : (selection.kind === "watched-preview"
@@ -19171,11 +19214,12 @@ export default function (pi: ExtensionAPI) {
19171
19214
  } finally {
19172
19215
  ctx.ui.notify(`Studio URL: ${url}`, "info");
19173
19216
  if (tunnelHint) ctx.ui.notify(tunnelHint, "info");
19217
+ if (state.listenAll) ctx.ui.notify(buildStudioListenAllWarning(state.port, url), "warning");
19174
19218
  }
19175
19219
  };
19176
19220
 
19177
19221
  pi.registerCommand("studio", {
19178
- 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)",
19222
+ 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)",
19179
19223
  handler: async (args: string, ctx: ExtensionCommandContext) => {
19180
19224
  const trimmed = args.trim();
19181
19225
 
@@ -19193,11 +19237,12 @@ export default function (pi: ExtensionAPI) {
19193
19237
  const counts = getStudioClientCounts();
19194
19238
  const url = buildStudioUrl(serverState.port, serverState.token, "full");
19195
19239
  ctx.ui.notify(
19196
- `Studio running at ${url} (busy: ${isStudioBusy() ? "yes" : "no"}; full views: ${counts.full}; editor-only views: ${counts.editorOnly})`,
19240
+ `Studio running at ${url} (listening on ${serverState.bindHost}:${serverState.port}; busy: ${isStudioBusy() ? "yes" : "no"}; full views: ${counts.full}; editor-only views: ${counts.editorOnly})`,
19197
19241
  "info",
19198
19242
  );
19199
- const sshTunnelHint = buildStudioSshTunnelHint(serverState.port, url);
19243
+ const sshTunnelHint = serverState.listenAll ? null : buildStudioSshTunnelHint(serverState.port, url);
19200
19244
  if (sshTunnelHint) ctx.ui.notify(sshTunnelHint, "info");
19245
+ if (serverState.listenAll) ctx.ui.notify(buildStudioListenAllWarning(serverState.port, url), "warning");
19201
19246
  return;
19202
19247
  }
19203
19248
 
@@ -19210,7 +19255,8 @@ export default function (pi: ExtensionAPI) {
19210
19255
  + " /studio --blank Open with blank editor\n"
19211
19256
  + " /studio --last Open with last model response\n"
19212
19257
  + " /studio --no-browser Print the Studio URL without opening a browser\n"
19213
- + " /studio --port <port> Bind Studio to a fixed localhost port when starting\n"
19258
+ + " /studio --port <port> Bind Studio to a fixed port when starting\n"
19259
+ + " /studio --listen-all Explicitly bind to 0.0.0.0 instead of localhost (security-sensitive)\n"
19214
19260
  + " /studio --open-remote Over SSH, open the remote browser anyway\n"
19215
19261
  + " /studio --status Show studio status\n"
19216
19262
  + " /studio --stop Stop studio server\n"
@@ -19241,7 +19287,8 @@ export default function (pi: ExtensionAPI) {
19241
19287
  + " /studio-replace --blank Replace with blank editor\n"
19242
19288
  + " /studio-replace --last Replace with last model response\n"
19243
19289
  + " /studio-replace --no-browser Print URL without opening a browser\n"
19244
- + " /studio-replace --port <port> Bind Studio to a fixed localhost port when starting\n"
19290
+ + " /studio-replace --port <port> Bind Studio to a fixed port when starting\n"
19291
+ + " /studio-replace --listen-all Explicitly bind to 0.0.0.0 instead of localhost\n"
19245
19292
  + "Editor-only Studio views stay open.",
19246
19293
  "info",
19247
19294
  );
@@ -19269,7 +19316,8 @@ export default function (pi: ExtensionAPI) {
19269
19316
  + " /studio-editor-only --blank Open with blank editor\n"
19270
19317
  + " /studio-editor-only --last Open with last model response loaded into the editor\n"
19271
19318
  + " /studio-editor-only --no-browser Print URL without opening a browser\n"
19272
- + " /studio-editor-only --port <port> Bind Studio to a fixed localhost port when starting\n"
19319
+ + " /studio-editor-only --port <port> Bind Studio to a fixed port when starting\n"
19320
+ + " /studio-editor-only --listen-all Explicitly bind to 0.0.0.0 instead of localhost\n"
19273
19321
  + "Multiple editor-only views are allowed in the same Pi session.",
19274
19322
  "info",
19275
19323
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.9.59",
3
+ "version": "0.9.60",
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
+ }