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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@

### Changed

- Add basic terminal compatibility with Herdr 0.9.0 (protocol 22), retaining
legacy support and rejecting unknown protocols. Terminal-program OSC 52 and
independent client navigation remain unsupported on 0.9.0.
- Refresh external layout changes and reconcile event subscription reconnects;
explain grouped-workspace close refusals without silently closing the group.
- Count only conversation messages toward the 200-entry History window so
tool-heavy turns no longer evict user messages, and fetch tool call/output
payloads on demand instead of transmitting them with every refresh. Tool
Expand Down
33 changes: 33 additions & 0 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,39 @@ see the [hands-on tutorial](./TUTORIAL.md#networking).
- [Bun](https://bun.sh) 1.4 or newer for source builds. Standalone binaries do
not require Bun on the target machine.

### Herdr compatibility

This source build supports the verified legacy protocols 14-20 (including
Herdr 0.8.2 / protocol 20) and **tagged Herdr 0.9.0 / protocol 22**. Protocol
21 and unknown versions are rejected at the control probe and binary handshake.
Use a Studio build explicitly supporting your server, or a separate compatible
server; do not downgrade a live server. These changes target a future Studio
0.5.3 and do not change already-published binaries.

Herdr 0.9.0 support is **basic direct-terminal compatibility**, not migration to
stable endpoint generation 1 (which is distinct from terminal protocol 22):

- ANSI rendering, ordinary input/paste, cell-based resizing, and Studio's shared
browser terminal sessions retain the existing per-terminal attachment path.
Attaching uses takeover and can disconnect another direct-terminal owner.
- Terminal-program OSC 52 clipboard delivery is unavailable on 0.9.0: the legacy
app relay is disabled because only shell endpoints receive these messages.
Ordinary browser selection copy and paste remain available. Legacy servers
retain their clipboard relay and input-owner filtering.
- Public JSON workspace/tab/pane focus remains session-wide; Studio does not
promise independent navigation alongside other Herdr clients. Enhanced Kitty
keyboard / modifyOtherKeys parity, pixel mouse and semantic endpoint rendering
are not supported. Keyboard-mode messages are decoded, not applied in-browser.
- Closing a workspace does not implicitly close its linked group. If Herdr
requires group closure, Studio leaves it intact and directs you to the CLI:
`herdr --session <name> workspace close <workspace_id> --group`. Review all
linked workspaces first; this explicitly closes the entire group.

Layout updates use the existing event-driven refresh path. Every subscription
acknowledgement, including reconnect, requests a fresh browser snapshot to
reconcile changes missed before subscription; events during a refresh queue a
follow-up refresh. This is reconciliation, not an atomic or replayable event log.

## Install a release

Prebuilt standalone binaries are available for Linux, macOS, and Windows on
Expand Down
16 changes: 9 additions & 7 deletions docs/HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ still receive their conversation-only `messages` response.
membership/order changes, the complete ordered ID list `order`. It does not
also include `messages`, `entries`, or a trajectory. A no-change delta has empty
changes and the same revision.
- The window holds the most recent 200 conversation/tool/error entries. Window
eviction is represented by removals; ATIF and raw exports remain complete.
- The window counts the most recent 200 conversation entries (user/assistant
messages and errors). Tool calls and results stay with the retained
conversation entries without counting toward that limit. Window eviction is
represented by removals; ATIF and raw exports remain complete.

IDs identify projected content occurrences (or tool call IDs), **not durable
source records or ATIF step numbers**. Diffs compare full projected windows, so
Expand All @@ -49,11 +51,11 @@ them away from their transcript position.

## Message type filters

The User, Agent, and Tool toggle buttons independently filter the current
200-entry window. All types start enabled. Agent includes assistant errors;
Tool includes calls, outputs, and tool errors. Button counts describe the
unfiltered window; the History badge shows visible/total when filtered.
The minimap and card numbering follow the visible list. Hidden entries still
The User, Agent, and Tool toggle buttons independently filter the loaded History
window. User and Agent start enabled; Tool starts disabled. Agent includes
assistant errors; Tool includes calls, outputs, and tool errors. Button counts
describe the unfiltered window; the History badge shows visible/total when
filtered. The minimap and card numbering follow the visible list. Hidden entries still
receive incremental updates, and exports are unaffected. Selections survive
pane switches and close/reopen while the drawer stays mounted; they are not
saved across page reloads. If no entries match, Show all types restores the view.
Expand Down
58 changes: 31 additions & 27 deletions server/src/bridge/protocol-compat.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,45 @@
export const MINIMUM_HERDR_PROTOCOL = 14;
const MAXIMUM_HERDR_PROTOCOL = 0xffffffff;
export const MAXIMUM_HERDR_PROTOCOL = 22;

// Herdr requires clients to echo the server's exact protocol in Hello. Versions
// 14-17 kept the terminal wire variants used by the GUI stable, so optimistically
// allow newer versions as well instead of rejecting them solely for a newer
// number. A future release that changes a used wire layout will require a GUI
// codec update.
export function isSupportedHerdrProtocol(protocol: number): boolean {
export class HerdrCompatibilityError extends Error {}

// Retain the verified legacy codecs (14-20) and add only tagged v0.9.0 (22).
// Protocol 21 and future versions must never be echoed through either codec.
export function isSupportedHerdrProtocol(
protocol: unknown,
): protocol is number {
return (
typeof protocol === "number" &&
Number.isSafeInteger(protocol) &&
protocol >= MINIMUM_HERDR_PROTOCOL &&
protocol <= MAXIMUM_HERDR_PROTOCOL
((protocol >= MINIMUM_HERDR_PROTOCOL && protocol <= 20) || protocol === 22)
);
}

// Herdr 0.8.2 (protocol 20) inserted ClientLaunchMode::AppDirectGraphics at
// wire index 1, moving ClientLaunchMode::TerminalAttach from 1 to 2. Map the
// semantic terminal-attach launch mode onto the negotiated protocol's wire
// value; App stays 0 on every protocol.
// Private terminal protocol 22 is distinct from stable endpoint generation 1.
export function isTerminalHelloProtocol(protocol: number): boolean {
return protocol === 22;
}

// Herdr 0.8.2 inserted AppDirectGraphics before TerminalAttach.
export const APP_DIRECT_GRAPHICS_LAUNCH_MODE_PROTOCOL = 20;

export function terminalAttachLaunchModeWireValue(protocol: number): number {
return protocol >= APP_DIRECT_GRAPHICS_LAUNCH_MODE_PROTOCOL ? 2 : 1;
}

export function assertSupportedHerdrProtocol(protocol: number): void {
if (
!Number.isSafeInteger(protocol) ||
protocol < 0 ||
protocol > MAXIMUM_HERDR_PROTOCOL
) {
throw new Error(`Herdr returned an invalid protocol version: ${protocol}`);
}
if (!isSupportedHerdrProtocol(protocol)) {
throw new Error(
`Herdr protocol ${protocol} is not supported by this Herdr Studio build ` +
`(requires protocol ${MINIMUM_HERDR_PROTOCOL} or newer)`,
);
}
export function assertSupportedHerdrProtocol(
protocol: unknown,
): asserts protocol is number {
if (isSupportedHerdrProtocol(protocol)) return;
const actual =
typeof protocol === "number" || typeof protocol === "string"
? String(protocol)
.replace(/[\u0000-\u001f\u007f-\u009f]/g, "?")
.slice(0, 20)
: "unknown";
throw new HerdrCompatibilityError(
`Herdr protocol ${actual} is not supported by this Studio build ` +
"(supports protocols 14-20 and 22). Use a Studio release explicitly " +
"supporting this server, or a separate compatible server. Do not downgrade a live server.",
);
}
73 changes: 66 additions & 7 deletions server/src/bridge/terminal-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ afterEach(async () => {
);
});

function terminalFrame(width = 100, height = 30) {
function terminalFrame(width = 100, height = 30, protocol = 17) {
const writer = new BinWriter();
writer.variant(2);
writer.variant(protocol === 22 ? 1 : 2);
writer.varint(1);
writer.varint(width);
writer.varint(height);
Expand All @@ -42,6 +42,7 @@ function clipboardFrame(data: string) {

async function startThinServer(
options: {
protocol?: number;
clipboardData?: string;
appWelcomeDelayMs?: number;
appWelcomeError?: string;
Expand Down Expand Up @@ -91,9 +92,15 @@ async function startThinServer(
const helloRows = reader.varint();
reader.varint(); // cell width
reader.varint(); // cell height
reader.varint(); // encoding
reader.varint(); // keybindings
const launchMode = reader.varint();
let launchMode = 2;
if (protocol === 22) {
expect(reader.bool()).toBe(false); // pixel_mouse
} else {
reader.varint(); // encoding
reader.varint(); // keybindings
launchMode = reader.varint();
}
expect(reader.remaining).toBe(0);
socketCols = helloCols;
socketRows = helloRows;
if (launchMode === 0 && options.tracker) {
Expand All @@ -116,7 +123,9 @@ async function startThinServer(
if (launchMode === 0) appSocket = socket;
socket.write(encodeFrame(writer.toBuffer()));
if (launchMode === 0) {
socket.write(terminalFrame(socketCols, socketRows));
socket.write(
terminalFrame(socketCols, socketRows, options.protocol),
);
}
};
if (launchMode === 0 && (options.appWelcomeDelayMs ?? 0) > 0) {
Expand Down Expand Up @@ -147,7 +156,9 @@ async function startThinServer(
}
const sendTerminalFrame = () => {
if (!socket.destroyed) {
socket.write(terminalFrame(socketCols, socketRows));
socket.write(
terminalFrame(socketCols, socketRows, options.protocol),
);
}
};
if (variant !== 5 || !options.skipDirectFrame) {
Expand Down Expand Up @@ -197,6 +208,54 @@ async function waitForCondition(
}

describe("terminal bridge sharing", () => {
for (const protocol of [20, 22]) {
test(`protocol ${protocol} ${protocol === 22 ? "skips OSC52 relay" : "retains legacy relay"} while sharing terminal rendering`, async () => {
const tracker = {
appConnects: 0,
appCloses: 0,
appSizes: [] as string[],
events: [] as string[],
};
const socketPath = await startThinServer({ protocol, tracker });
const browser = {} as ServerWebSocket<unknown>;
const messages: string[] = [];
const bridge = createTerminalBridge({
clientSocketPath: socketPath,
herdrProtocol: async () => protocol,
safeSend: (_ws, payload) => {
messages.push(payload);
return true;
},
clientLabel: () => "test",
markRpcError: () => undefined,
});
try {
await bridge.handleTerminalRpc(browser, "attach", "terminal.attach", {
terminal_id: "term_1",
cols: 100,
rows: 30,
});
await waitForTerminalFrame(messages);
expect(
tracker.events.filter((event) => event === "attach"),
).toHaveLength(1);
expect(tracker.appConnects).toBe(protocol === 22 ? 0 : 1);
const viewer = {} as ServerWebSocket<unknown>;
await bridge.handleTerminalRpc(viewer, "second", "terminal.attach", {
terminal_id: "term_1",
cols: 100,
rows: 30,
});
expect(
tracker.events.filter((event) => event === "attach"),
).toHaveLength(1);
expect(tracker.appConnects).toBe(protocol === 22 ? 0 : 1);
} finally {
bridge.dispose();
}
});
}

test("refreshes a reused terminal for a newly attached browser", async () => {
const socketPath = await startThinServer();
const firstBrowser = {} as ServerWebSocket<unknown>;
Expand Down
Loading