pi-studio 0.5.55 → 0.5.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.5.56] — 2026-04-15
8
+
9
+ ### Removed
10
+ - Removed Studio's standalone npm update checker and footer update badge now that package update tracking is handled in pi.
11
+
12
+ ### Fixed
13
+ - Studio now always prints its localhost URL even when automatic browser open fails, and SSH sessions get a localhost-only port-forwarding hint instead of needing non-local binding.
14
+
7
15
  ## [0.5.54] — 2026-04-13
8
16
 
9
17
  ### Fixed
package/README.md CHANGED
@@ -74,6 +74,7 @@ pi -e https://github.com/omaclaren/pi-studio
74
74
  ## Notes
75
75
 
76
76
  - Local-only server (`127.0.0.1`) with tokenized Studio URLs.
77
+ - For remote SSH sessions, keep Studio bound to localhost and use SSH local port forwarding; `/studio` prints the localhost URL and an SSH tunnel hint when SSH is detected.
77
78
  - 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.
78
79
  - Studio is designed as a complement to terminal pi, not a replacement.
79
80
  - Editor/code font uses a best-effort terminal-monospace match when the current terminal config exposes it; set `PI_STUDIO_FONT_MONO` to force a specific CSS `font-family` stack.
@@ -202,8 +202,6 @@
202
202
  let contextTokens = null;
203
203
  let contextWindow = null;
204
204
  let contextPercent = null;
205
- let updateInstalledVersion = null;
206
- let updateLatestVersion = null;
207
205
  let windowHasFocus = typeof document.hasFocus === "function" ? document.hasFocus() : true;
208
206
  let titleAttentionMessage = "";
209
207
  let titleAttentionRequestId = null;
@@ -664,8 +662,6 @@
664
662
  if (typeof message.contextTokens === "number") summary.contextTokens = message.contextTokens;
665
663
  if (typeof message.contextWindow === "number") summary.contextWindow = message.contextWindow;
666
664
  if (typeof message.contextPercent === "number") summary.contextPercent = message.contextPercent;
667
- if (typeof message.updateInstalledVersion === "string") summary.updateInstalledVersion = message.updateInstalledVersion;
668
- if (typeof message.updateLatestVersion === "string") summary.updateLatestVersion = message.updateLatestVersion;
669
665
  if (message.document && typeof message.document === "object" && typeof message.document.text === "string") {
670
666
  summary.documentLength = message.document.text.length;
671
667
  if (typeof message.document.label === "string") summary.documentLabel = message.document.label;
@@ -926,30 +922,6 @@
926
922
  return changed;
927
923
  }
928
924
 
929
- function applyUpdateInfoFromMessage(message) {
930
- if (!message || typeof message !== "object") return false;
931
-
932
- let changed = false;
933
-
934
- if (Object.prototype.hasOwnProperty.call(message, "updateInstalledVersion")) {
935
- const nextInstalled = parseNonEmptyString(message.updateInstalledVersion);
936
- if (nextInstalled !== updateInstalledVersion) {
937
- updateInstalledVersion = nextInstalled;
938
- changed = true;
939
- }
940
- }
941
-
942
- if (Object.prototype.hasOwnProperty.call(message, "updateLatestVersion")) {
943
- const nextLatest = parseNonEmptyString(message.updateLatestVersion);
944
- if (nextLatest !== updateLatestVersion) {
945
- updateLatestVersion = nextLatest;
946
- changed = true;
947
- }
948
- }
949
-
950
- return changed;
951
- }
952
-
953
925
  function isTitleAttentionRequestKind(kind) {
954
926
  return kind === "annotation" || kind === "critique" || kind === "direct";
955
927
  }
@@ -1134,13 +1106,7 @@
1134
1106
  const modelText = modelLabel && modelLabel.trim() ? modelLabel.trim() : "none";
1135
1107
  const terminalText = terminalSessionLabel && terminalSessionLabel.trim() ? terminalSessionLabel.trim() : "unknown";
1136
1108
  const contextText = formatContextUsageText();
1137
- let updateText = "";
1138
- if (updateLatestVersion) {
1139
- updateText = updateInstalledVersion
1140
- ? "Update: " + updateInstalledVersion + " → " + updateLatestVersion
1141
- : "Update: " + updateLatestVersion + " available";
1142
- }
1143
- const text = "Model: " + modelText + " · Terminal: " + terminalText + " · " + contextText + (updateText ? " · " + updateText : "");
1109
+ const text = "Model: " + modelText + " · Terminal: " + terminalText + " · " + contextText;
1144
1110
  if (footerMetaTextEl) {
1145
1111
  footerMetaTextEl.textContent = text;
1146
1112
  footerMetaTextEl.title = text;
@@ -9000,8 +8966,7 @@
9000
8966
  debugTrace("server_message", summarizeServerMessage(message));
9001
8967
 
9002
8968
  const contextChanged = applyContextUsageFromMessage(message);
9003
- const updateInfoChanged = applyUpdateInfoFromMessage(message);
9004
- if (contextChanged || updateInfoChanged) {
8969
+ if (contextChanged) {
9005
8970
  updateFooterMeta();
9006
8971
  }
9007
8972
 
package/index.ts CHANGED
@@ -272,7 +272,6 @@ const PREVIEW_RENDER_MAX_CHARS = 400_000;
272
272
  const PDF_EXPORT_MAX_CHARS = 400_000;
273
273
  const REQUEST_BODY_MAX_BYTES = 1_000_000;
274
274
  const RESPONSE_HISTORY_LIMIT = 30;
275
- const UPDATE_CHECK_TIMEOUT_MS = 1800;
276
275
  const CMUX_NOTIFY_TIMEOUT_MS = 1200;
277
276
  const PREPARED_PDF_EXPORT_TTL_MS = 5 * 60 * 1000;
278
277
  const MAX_PREPARED_PDF_EXPORTS = 8;
@@ -2737,93 +2736,6 @@ function readStudioGitDiff(baseDir: string):
2737
2736
  return { ok: true, text: fullDiff, label };
2738
2737
  }
2739
2738
 
2740
- function readLocalPackageMetadata(): { name: string; version: string } | null {
2741
- try {
2742
- const raw = readFileSync(new URL("./package.json", import.meta.url), "utf-8");
2743
- const parsed = JSON.parse(raw) as { name?: unknown; version?: unknown };
2744
- const name = typeof parsed.name === "string" ? parsed.name.trim() : "";
2745
- const version = typeof parsed.version === "string" ? parsed.version.trim() : "";
2746
- if (!name || !version) return null;
2747
- return { name, version };
2748
- } catch {
2749
- return null;
2750
- }
2751
- }
2752
-
2753
- interface ParsedSemver {
2754
- major: number;
2755
- minor: number;
2756
- patch: number;
2757
- prerelease: string | null;
2758
- }
2759
-
2760
- function parseSemverLoose(version: string): ParsedSemver | null {
2761
- const normalized = String(version || "").trim().replace(/^v/i, "");
2762
- const match = normalized.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?/);
2763
- if (!match) return null;
2764
- const major = Number.parseInt(match[1] ?? "", 10);
2765
- const minor = Number.parseInt(match[2] ?? "0", 10);
2766
- const patch = Number.parseInt(match[3] ?? "0", 10);
2767
- if (!Number.isFinite(major) || !Number.isFinite(minor) || !Number.isFinite(patch)) return null;
2768
- const prerelease = typeof match[4] === "string" && match[4].trim() ? match[4].trim() : null;
2769
- return { major, minor, patch, prerelease };
2770
- }
2771
-
2772
- function compareSemverLoose(a: string, b: string): number {
2773
- const pa = parseSemverLoose(a);
2774
- const pb = parseSemverLoose(b);
2775
- if (!pa || !pb) {
2776
- return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
2777
- }
2778
- if (pa.major !== pb.major) return pa.major - pb.major;
2779
- if (pa.minor !== pb.minor) return pa.minor - pb.minor;
2780
- if (pa.patch !== pb.patch) return pa.patch - pb.patch;
2781
- if (pa.prerelease && !pb.prerelease) return -1;
2782
- if (!pa.prerelease && pb.prerelease) return 1;
2783
- if (!pa.prerelease && !pb.prerelease) return 0;
2784
- return (pa.prerelease ?? "").localeCompare(pb.prerelease ?? "", undefined, {
2785
- numeric: true,
2786
- sensitivity: "base",
2787
- });
2788
- }
2789
-
2790
- function isVersionBehind(installedVersion: string, latestVersion: string): boolean {
2791
- return compareSemverLoose(installedVersion, latestVersion) < 0;
2792
- }
2793
-
2794
- async function fetchLatestNpmVersion(packageName: string, timeoutMs = UPDATE_CHECK_TIMEOUT_MS): Promise<string | null> {
2795
- const pkg = String(packageName || "").trim();
2796
- if (!pkg) return null;
2797
- const encodedPackage = encodeURIComponent(pkg).replace(/^%40/, "@");
2798
- const endpoint = `https://registry.npmjs.org/${encodedPackage}/latest`;
2799
- const controller = typeof AbortController === "function" ? new AbortController() : null;
2800
- const timer = controller
2801
- ? setTimeout(() => {
2802
- try {
2803
- controller.abort();
2804
- } catch {
2805
- // ignore abort race
2806
- }
2807
- }, timeoutMs)
2808
- : null;
2809
-
2810
- try {
2811
- const response = await fetch(endpoint, {
2812
- method: "GET",
2813
- headers: { Accept: "application/json" },
2814
- signal: controller?.signal,
2815
- });
2816
- if (!response.ok) return null;
2817
- const payload = await response.json() as { version?: unknown };
2818
- const version = typeof payload.version === "string" ? payload.version.trim() : "";
2819
- return version || null;
2820
- } catch {
2821
- return null;
2822
- } finally {
2823
- if (timer) clearTimeout(timer);
2824
- }
2825
- }
2826
-
2827
2739
  function isLikelyMathExpression(expr: string): boolean {
2828
2740
  const content = expr.trim();
2829
2741
  if (content.length === 0) return false;
@@ -5842,6 +5754,17 @@ function buildStudioUrl(
5842
5754
  return `http://127.0.0.1:${port}/?${params.toString()}`;
5843
5755
  }
5844
5756
 
5757
+ function isSshSession(): boolean {
5758
+ return Boolean(
5759
+ String(process.env.SSH_CONNECTION ?? process.env.SSH_CLIENT ?? process.env.SSH_TTY ?? "").trim(),
5760
+ );
5761
+ }
5762
+
5763
+ function buildStudioSshTunnelHint(port: number): string | null {
5764
+ if (!isSshSession()) return null;
5765
+ return `SSH detected. If Studio is running on a remote machine, forward the port from your local machine, then open the Studio URL locally: ssh -L ${port}:127.0.0.1:${port} <remote-host>`;
5766
+ }
5767
+
5845
5768
  function resolveRequestedStudioDocumentFromUrl(
5846
5769
  requestUrl: URL,
5847
5770
  fallback: InitialStudioDocument | null,
@@ -6382,11 +6305,6 @@ export default function (pi: ExtensionAPI) {
6382
6305
  };
6383
6306
  let compactInProgress = false;
6384
6307
  let compactRequestId: string | null = null;
6385
- let updateCheckStarted = false;
6386
- let updateCheckCompleted = false;
6387
- const packageMetadata = readLocalPackageMetadata();
6388
- const installedPackageVersion = packageMetadata?.version ?? null;
6389
- let updateAvailableLatestVersion: string | null = null;
6390
6308
 
6391
6309
  const isStudioDirectRunChainActive = () => Boolean(studioDirectRunChain);
6392
6310
  const getQueuedStudioSteeringCount = () => queuedStudioDirectRequests.length;
@@ -6766,28 +6684,6 @@ export default function (pi: ExtensionAPI) {
6766
6684
  });
6767
6685
  };
6768
6686
 
6769
- const maybeNotifyUpdateAvailable = async (ctx: ExtensionCommandContext) => {
6770
- if (updateCheckStarted || updateCheckCompleted) return;
6771
- updateCheckStarted = true;
6772
- try {
6773
- const metadata = packageMetadata;
6774
- if (!metadata) return;
6775
- const latest = await fetchLatestNpmVersion(metadata.name, UPDATE_CHECK_TIMEOUT_MS);
6776
- if (!latest) return;
6777
- if (!isVersionBehind(metadata.version, latest)) return;
6778
-
6779
- updateAvailableLatestVersion = latest;
6780
- broadcastState();
6781
-
6782
- const notification =
6783
- `Update available for ${metadata.name}: ${metadata.version} → ${latest}. Run: pi install npm:${metadata.name}`;
6784
- ctx.ui.notify(notification, "info");
6785
- broadcast({ type: "info", message: notification, level: "info" });
6786
- } finally {
6787
- updateCheckCompleted = true;
6788
- }
6789
- };
6790
-
6791
6687
  const sendToClient = (client: WebSocket, payload: unknown) => {
6792
6688
  if (client.readyState !== WebSocket.OPEN) return;
6793
6689
  try {
@@ -7067,8 +6963,6 @@ export default function (pi: ExtensionAPI) {
7067
6963
  contextTokens: contextUsageSnapshot.tokens,
7068
6964
  contextWindow: contextUsageSnapshot.contextWindow,
7069
6965
  contextPercent: contextUsageSnapshot.percent,
7070
- updateInstalledVersion: installedPackageVersion,
7071
- updateLatestVersion: updateAvailableLatestVersion,
7072
6966
  compactInProgress,
7073
6967
  activeRequestId: activeRequest?.id ?? compactRequestId ?? null,
7074
6968
  activeRequestKind: activeRequest?.kind ?? (compactInProgress ? "compact" : null),
@@ -7349,8 +7243,6 @@ export default function (pi: ExtensionAPI) {
7349
7243
  contextTokens: contextUsageSnapshot.tokens,
7350
7244
  contextWindow: contextUsageSnapshot.contextWindow,
7351
7245
  contextPercent: contextUsageSnapshot.percent,
7352
- updateInstalledVersion: installedPackageVersion,
7353
- updateLatestVersion: updateAvailableLatestVersion,
7354
7246
  compactInProgress,
7355
7247
  activeRequestId: activeRequest?.id ?? compactRequestId ?? null,
7356
7248
  activeRequestKind: activeRequest?.kind ?? (compactInProgress ? "compact" : null),
@@ -9018,6 +8910,8 @@ export default function (pi: ExtensionAPI) {
9018
8910
  ctx.ui.notify("A full pi Studio view is already open for this session. Close it first, use /studio-replace for a fresh full Studio view, or use /studio-editor-only for a concurrent editor-only Studio view.", "warning");
9019
8911
  if (serverState) {
9020
8912
  ctx.ui.notify(`Studio URL: ${buildStudioUrl(serverState.port, serverState.token, "full")}`, "info");
8913
+ const sshTunnelHint = buildStudioSshTunnelHint(serverState.port);
8914
+ if (sshTunnelHint) ctx.ui.notify(sshTunnelHint, "info");
9021
8915
  }
9022
8916
  return;
9023
8917
  }
@@ -9043,6 +8937,7 @@ export default function (pi: ExtensionAPI) {
9043
8937
 
9044
8938
  const state = await ensureServer();
9045
8939
  const url = buildStudioUrl(state.port, state.token, mode, selected);
8940
+ const sshTunnelHint = buildStudioSshTunnelHint(state.port);
9046
8941
  const openedLabel = mode === "editor-only" ? "pi Studio editor-only view" : "pi Studio";
9047
8942
 
9048
8943
  try {
@@ -9054,11 +8949,16 @@ export default function (pi: ExtensionAPI) {
9054
8949
  } else {
9055
8950
  ctx.ui.notify(`Opened ${openedLabel} with blank editor.`, "info");
9056
8951
  }
9057
- ctx.ui.notify(`Studio URL: ${url}`, "info");
9058
8952
  } catch (error) {
9059
- ctx.ui.notify(`Failed to open browser: ${error instanceof Error ? error.message : String(error)}`, "error");
8953
+ const message = error instanceof Error ? error.message : String(error);
8954
+ if (isSshSession()) {
8955
+ ctx.ui.notify(`Failed to open browser automatically over SSH: ${message}`, "warning");
8956
+ } else {
8957
+ ctx.ui.notify(`Failed to open browser: ${message}`, "error");
8958
+ }
9060
8959
  } finally {
9061
- void maybeNotifyUpdateAvailable(ctx);
8960
+ ctx.ui.notify(`Studio URL: ${url}`, "info");
8961
+ if (sshTunnelHint) ctx.ui.notify(sshTunnelHint, "info");
9062
8962
  }
9063
8963
  };
9064
8964
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.5.55",
3
+ "version": "0.5.56",
4
4
  "description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, prompt/response history, and live Markdown/LaTeX/code preview",
5
5
  "type": "module",
6
6
  "license": "MIT",