Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,18 @@ All notable user-visible changes are recorded here. Versions follow [Semantic Ve

## Unreleased

## [0.15.1] — 2026-09-14

### Changed

- Made account deletion a visible danger-zone action while keeping the existing
reauthentication and typed-email confirmation safeguards.
- Marked Refstream clearly as an unstable alpha when selected and replaced its
translucent app chrome with an opaque terminal-native palette.
- Prevented historical terminal capability queries in a restored snapshot from
being answered into the live process, which could corrupt tmux input.
- Updated the optional Refstream renderer to `v0.1.0-alpha.5` from its verified
browser release. xterm.js remains the default.

## [0.15.0] — 2026-09-14

Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ shell kill <id> # stop a session
Press `Ctrl-X`, then `D`, to detach from an attached session. See
[`shell help reference`](https://shell.online/cli/) for every command and option.

Full-screen and nested terminals are supported. Release checks exercise tmux,
Herdr, and mosh-style redraw, input, split-pane, and reconnect behavior; a
restored browser snapshot cannot answer historical terminal queries into the
live process.

File sharing is disabled unless `--files` or `--files-root` is present. Once
enabled, browsers can browse that root and open referenced files on demand.
Paths and contents use the session's E2EE WebSocket; the CLI rejects traversal,
Expand All @@ -94,7 +99,7 @@ survive panel changes and reconnection. Reload recovery is kept only in that
browser tab for up to four hours and still requires the local process to be
running. Revoking access is immediate and does not stop the terminal. Files stay
unavailable unless the host separately used `--files` or `--files-root`.
See the [agent handoff protocol](https://github.com/TeoSlayer/refstream.js/blob/v0.1.0-alpha.4/docs/agents.md).
See the [agent handoff protocol](https://github.com/TeoSlayer/refstream.js/blob/v0.1.0-alpha.5/docs/agents.md).

## Security

Expand Down
20 changes: 15 additions & 5 deletions app/src/terminal/TerminalPane.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
import { ArrowClockwise, LockKey } from "@phosphor-icons/react";
import "@xterm/xterm/css/xterm.css";
import "../../../web/vendor/refstream/v0.1.0-alpha.4/refstream.css";
import "../../../web/vendor/refstream/v0.1.0-alpha.4/ui.css";
import "../../../web/vendor/refstream/v0.1.0-alpha.5/refstream.css";
import "../../../web/vendor/refstream/v0.1.0-alpha.5/ui.css";
import { TerminalConnection, type ConnectionStatus } from "./connection";
import { DESKTOP_TERMINAL_GRID, type TerminalGrid } from "./terminal-grid";
import { fittedTerminal, type TerminalCell } from "./terminal-fit";
Expand Down Expand Up @@ -261,6 +261,8 @@ export function TerminalPane({
});
}

let snapshotGeneration = 0;
let rendererInputSuppressed = false;
connected = new TerminalConnection({
url: target.url,
fragment: encryptionFragment(shareUrl),
Expand Down Expand Up @@ -311,8 +313,16 @@ export function TerminalPane({
if (worked.source !== "vault") void keepIfMissing(sessionId, worked.password);
},
onData: (bytes, reset) => {
if (reset) term.reset();
term.write(bytes);
if (!reset) {
term.write(bytes);
return;
}
const generation = ++snapshotGeneration;
rendererInputSuppressed = true;
term.reset();
term.write(bytes, () => {
if (generation === snapshotGeneration) rendererInputSuppressed = false;
});
},
onFileFrame: (frame) => { fileClient.handle(frame); },
onReadOnly: (value) => {
Expand Down Expand Up @@ -357,7 +367,7 @@ export function TerminalPane({
: null;

const typed = term.onData((data) => {
if (!canTypeRef.current) return;
if (!canTypeRef.current || rendererInputSuppressed) return;
connected.send(data);
sink?.observe(data);
});
Expand Down
2 changes: 1 addition & 1 deletion app/src/terminal/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
type ITerminalInitOnlyOptions,
type ITerminalOptions,
} from "@xterm/xterm";
import { Terminal as RefstreamTerminal } from "../../../web/vendor/refstream/v0.1.0-alpha.4/refstream.js";
import { Terminal as RefstreamTerminal } from "../../../web/vendor/refstream/v0.1.0-alpha.5/refstream.js";

export type TerminalRenderer = "xterm" | "refstream";
export const DEFAULT_TERMINAL_RENDERER: TerminalRenderer = "xterm";
Expand Down
4 changes: 2 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
services:
shell-online:
image: ghcr.io/teoslayer/shell.online:0.15.0
image: ghcr.io/teoslayer/shell.online:0.15.1
build:
context: .
args:
VERSION: "0.15.0"
VERSION: "0.15.1"
restart: unless-stopped
environment:
SHELL_ONLINE_E2EE_PASSWORD: "${SHELL_ONLINE_E2EE_PASSWORD:-}"
Expand Down
8 changes: 6 additions & 2 deletions docs/content.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "0.15.0",
"version": "0.15.1",
"pages": {
"docs": {
"eyebrow": "Documentation",
Expand Down Expand Up @@ -103,6 +103,10 @@
"Screen recovery",
"A bounded local ring buffer restores new or returning viewers. If live output outruns either network or rendering, shell.online drops stale display work and sends one authoritative screen snapshot."
],
[
"Nested terminals and multiplexers",
"Snapshot restoration suppresses replies to historical terminal queries, so joining or reconnecting cannot inject capability responses into the live PTY. tmux, Herdr, and mosh-style input, redraw, split-pane, and second-viewer flows are exercised as compatibility checks."
],
[
"Backpressure by design",
"PTY reads never wait indefinitely for Cloudflare. WebSocket writes time out, queues are bounded, frames have size and traffic limits, and high-output processes keep running locally."
Expand Down Expand Up @@ -645,7 +649,7 @@
"cards": [
[
"Start once",
"Pull ghcr.io/teoslayer/shell.online:0.15.0 or run docker compose up --build -d, then docker compose logs shell-online. First launch prints the stable URL and a generated ten-character password. The tagged amd64/arm64 image includes an SBOM and build provenance."
"Pull ghcr.io/teoslayer/shell.online:0.15.1 or run docker compose up --build -d, then docker compose logs shell-online. First launch prints the stable URL and a generated ten-character password. The tagged amd64/arm64 image includes an SBOM and build provenance."
],
[
"Two durable volumes",
Expand Down
4 changes: 2 additions & 2 deletions docs/third-party-notices.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
## Refstream.js — MIT

Copyright (c) 2026 Calin Teodor. The MIT license above applies. The vendored
browser build is v0.1.0-alpha.4; its complete license and provenance are in
[`web/vendor/refstream/v0.1.0-alpha.4/`](../web/vendor/refstream/v0.1.0-alpha.4/).
browser build is v0.1.0-alpha.5; its complete license and provenance are in
[`web/vendor/refstream/v0.1.0-alpha.5/`](../web/vendor/refstream/v0.1.0-alpha.5/).

## ws — MIT

Expand Down
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
},
"applicationCategory": "DeveloperApplication",
"operatingSystem": "macOS, Windows, Linux, FreeBSD, OpenBSD, NetBSD, DragonFly BSD, Solaris",
"softwareVersion": "0.15.0",
"softwareVersion": "0.15.1",
"softwareRequirements": "A supported architecture, outbound HTTPS and WebSockets, and a PTY or Windows ConPTY",
"isAccessibleForFree": true,
"downloadUrl": "https://shell.online/install",
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "shell-online",
"version": "0.15.0",
"version": "0.15.1",
"description": "Turn any terminal process into a collaborative browser link.",
"private": true,
"license": "MIT",
Expand Down
6 changes: 4 additions & 2 deletions public/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ The Homebrew formula builds locally: Brew fetches the checksum-pinned tagged sou

The standalone installer uses a checksum-pinned release binary. To compile and run the tagged source outside either installation path:

git clone --depth 1 --branch v0.15.0 https://github.com/TeoSlayer/shell.online.git
git clone --depth 1 --branch v0.15.1 https://github.com/TeoSlayer/shell.online.git
cd shell.online
go build -trimpath -ldflags="-X main.version=0.15.0" -o ./shell ./cmd/shell
go build -trimpath -ldflags="-X main.version=0.15.1" -o ./shell ./cmd/shell
./shell --version

The source-build path requires Go 1.26.8 or newer, except Go 1.27.x is intentionally unsupported on MIPS64 because of go.dev/issue/80978. Keep using ./shell or move it to a directory on PATH.
Expand Down Expand Up @@ -99,6 +99,8 @@ shell kill --all

`shell attach` opens the existing PTY locally. Its terminal title keeps a Ctrl-X D to detach reminder visible even when a full-screen TUI redraws the terminal. Press Ctrl-X, release it, then press D to detach without stopping it (Ctrl-] remains a legacy alternative); the wrapper intercepts the sequence before the child process receives it. Input and output remain mirrored to browser viewers. Anonymous presence and a brief per-typist input lease prevent remote keystrokes from interleaving.

Full-screen and nested terminals are supported. Snapshot restoration suppresses replies to historical terminal queries, so a joining or reconnecting browser cannot inject old capability responses into tmux, Herdr, mosh, or another live TUI.

Sessions close immediately when their task exits. `--auto-close 5m` or an absolute/local date adds an earlier deadline; supported duration units are ms, s, m, h, d, w, mo, and y.

ROS 1 and ROS 2 processes need no plugin: source the ROS environment, then run `shell roscore`, `shell roslaunch <package> <launch-file>`, `shell ros2 run <package> <executable>`, or `shell ros2 launch <package> <launch-file>`. shell.online platform support does not expand the supported platforms of the installed ROS distribution.
Expand Down
2 changes: 1 addition & 1 deletion tests/refstream-session.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { bindRefstreamSessionPersistence } from "../web/refstream-session";
import type { SessionSnapshot } from "../web/vendor/refstream/v0.1.0-alpha.4/refstream.js";
import type { SessionSnapshot } from "../web/vendor/refstream/v0.1.0-alpha.5/refstream.js";

function snapshot(sequence: number): SessionSnapshot {
return { version: 1, sequence, terminal: {}, commands: [] };
Expand Down
8 changes: 4 additions & 4 deletions tests/refstream-vendor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";

const root = dirname(dirname(fileURLToPath(import.meta.url)));
const vendor = join(root, "web/vendor/refstream/v0.1.0-alpha.4");
const vendor = join(root, "web/vendor/refstream/v0.1.0-alpha.5");

interface Manifest {
version: string;
Expand All @@ -16,10 +16,10 @@ interface Manifest {
}

describe("vendored Refstream browser release", () => {
it("matches every file in the signed alpha.4 release manifest", async () => {
it("matches every file in the signed alpha.5 release manifest", async () => {
const manifest = JSON.parse(await readFile(join(vendor, "manifest.json"), "utf8")) as Manifest;
expect(manifest.version).toBe("0.1.0-alpha.4");
expect(manifest.gitRevision).toBe("44dd17cf69e067b859b9ab09ba988088beaa4fd6");
expect(manifest.version).toBe("0.1.0-alpha.5");
expect(manifest.gitRevision).toBe("131a71335f05344f3018c43a76ffbe7376493a34");

for (const [name, expected] of Object.entries(manifest.files)) {
const contents = await readFile(join(vendor, name));
Expand Down
12 changes: 12 additions & 0 deletions tests/terminal-writes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ describe("mobile terminal write queue", () => {
expect(terminal.writes).toEqual([[1, 2, 3, 4], [5, 6]]);
});

it("completes a snapshot only after its final render job", () => {
const terminal = new FakeTerminal();
const queue = new TerminalWriteQueue(terminal, 4);
let completed = 0;

queue.enqueue(new Uint8Array([1, 2, 3, 4, 5]), true, () => { completed += 1; });
terminal.completeNext();
expect(completed).toBe(0);
terminal.completeNext();
expect(completed).toBe(1);
});

it("drops stale queued output when a fresh snapshot arrives", () => {
const terminal = new FakeTerminal();
const queue = new TerminalWriteQueue(terminal, 8);
Expand Down
16 changes: 13 additions & 3 deletions web/main.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { ITheme } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
import "./vendor/refstream/v0.1.0-alpha.4/refstream.css";
import "./vendor/refstream/v0.1.0-alpha.4/ui.css";
import "./vendor/refstream/v0.1.0-alpha.5/refstream.css";
import "./vendor/refstream/v0.1.0-alpha.5/ui.css";
import {
decodeLatencyProbe,
encodeFrame,
Expand Down Expand Up @@ -892,6 +892,8 @@ function renderTerminal(sessionId: string): void {
let presenceTimer: number | undefined;
let readOnly = false;
let snapshotRequestPending = false;
let terminalSnapshotGeneration = 0;
let rendererInputSuppressed = false;
const terminalInput = new TerminalInputQueue(() => socket);
const destructiveInput = new DestructiveInputGuard();
let destructiveInputTimer: number | undefined;
Expand Down Expand Up @@ -1432,7 +1434,11 @@ function renderTerminal(sessionId: string): void {
if (fileClient.handle(frame)) return;
if (receiveLatencyResponse(frame)) return;
if (isSnapshotOpcode(frame[0])) {
terminalWrites.enqueue(frame.subarray(1), true);
const generation = ++terminalSnapshotGeneration;
rendererInputSuppressed = true;
terminalWrites.enqueue(frame.subarray(1), true, () => {
if (generation === terminalSnapshotGeneration) rendererInputSuppressed = false;
});
snapshotRequestPending = false;
} else if (frame[0] === Opcode.Output) {
if (!terminalWrites.enqueue(frame.subarray(1)) && !snapshotRequestPending) {
Expand Down Expand Up @@ -1612,6 +1618,10 @@ function renderTerminal(sessionId: string): void {
});

const sendTerminalData = (bytes: Uint8Array): void => {
// A raw terminal snapshot can contain old device-attribute queries from a
// TUI startup. Replaying it must not answer those queries into the live
// PTY after the application has already moved on.
if (rendererInputSuppressed) return;
if (bytes.byteLength === 1 && bytes[0] === 4) {
confirmEOF();
return;
Expand Down
2 changes: 1 addition & 1 deletion web/refstream-session.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type {
SessionSnapshot,
TerminalSession,
} from "./vendor/refstream/v0.1.0-alpha.4/refstream.js";
} from "./vendor/refstream/v0.1.0-alpha.5/refstream.js";

const SNAPSHOT_VERSION = 1;
const MAX_SNAPSHOT_AGE_MS = 4 * 60 * 60 * 1_000;
Expand Down
4 changes: 2 additions & 2 deletions web/refstream-tools.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { getTerminalSession } from "./vendor/refstream/v0.1.0-alpha.4/refstream.js";
import { attachTerminalTools, type TerminalTools } from "./vendor/refstream/v0.1.0-alpha.4/ui.js";
import { getTerminalSession } from "./vendor/refstream/v0.1.0-alpha.5/refstream.js";
import { attachTerminalTools, type TerminalTools } from "./vendor/refstream/v0.1.0-alpha.5/ui.js";
import { bindRefstreamSessionPersistence } from "./refstream-session";

const REFSTREAM_THEMES = [
Expand Down
2 changes: 1 addition & 1 deletion web/terminal-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
type ITerminalInitOnlyOptions,
type ITerminalOptions,
} from "@xterm/xterm";
import { Terminal as RefstreamTerminal } from "./vendor/refstream/v0.1.0-alpha.4/refstream.js";
import { Terminal as RefstreamTerminal } from "./vendor/refstream/v0.1.0-alpha.5/refstream.js";

export type TerminalRenderer = "xterm" | "refstream";
export const DEFAULT_TERMINAL_RENDERER: TerminalRenderer = "xterm";
Expand Down
9 changes: 7 additions & 2 deletions web/terminal-writes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface TerminalWriteTarget {
interface PendingTerminalWrite {
data: Uint8Array;
reset: boolean;
complete?: () => void;
}

export class TerminalWriteQueue {
Expand All @@ -20,7 +21,7 @@ export class TerminalWriteQueue {
private readonly maximumPendingBytes = 1024 * 1024,
) {}

enqueue(data: Uint8Array, reset = false): boolean {
enqueue(data: Uint8Array, reset = false, complete?: () => void): boolean {
if (reset) {
this.pending.length = 0;
this.pendingBytes = 0;
Expand All @@ -33,13 +34,14 @@ export class TerminalWriteQueue {
}

if (data.byteLength === 0) {
this.pending.push({ data: new Uint8Array(), reset });
this.pending.push({ data: new Uint8Array(), reset, complete });
} else {
for (let offset = 0; offset < data.byteLength; offset += this.maximumBatchBytes) {
const end = Math.min(data.byteLength, offset + this.maximumBatchBytes);
this.pending.push({
data: new Uint8Array(data.subarray(offset, end)),
reset: reset && offset === 0,
complete: end === data.byteLength ? complete : undefined,
});
this.pendingBytes += end - offset;
}
Expand All @@ -57,6 +59,7 @@ export class TerminalWriteQueue {
if (first.reset) this.target.reset();

const chunks = [first.data];
const completions: Array<() => void> = first.complete ? [first.complete] : [];
let byteLength = first.data.byteLength;
while (
this.pending.length > 0 &&
Expand All @@ -66,6 +69,7 @@ export class TerminalWriteQueue {
const next = this.pending.shift()!;
this.pendingBytes -= next.data.byteLength;
chunks.push(next.data);
if (next.complete) completions.push(next.complete);
byteLength += next.data.byteLength;
}

Expand All @@ -81,6 +85,7 @@ export class TerminalWriteQueue {

this.writing = true;
this.target.write(output, () => {
for (const complete of completions) complete();
this.writing = false;
this.flush();
});
Expand Down
9 changes: 0 additions & 9 deletions web/vendor/refstream/v0.1.0-alpha.4/README.md

This file was deleted.

9 changes: 0 additions & 9 deletions web/vendor/refstream/v0.1.0-alpha.4/README.txt

This file was deleted.

Loading
Loading