From 7b12788b9aa3dbb2d66fc0891ef94a2be9e900cd Mon Sep 17 00:00:00 2001 From: Wangshuyi Date: Tue, 8 Sep 2026 09:14:44 +0800 Subject: [PATCH 1/4] Add basic Herdr 0.9.0 terminal compatibility --- CHANGELOG.md | 5 + docs/DEPLOYMENT.md | 33 ++++ server/src/bridge/protocol-compat.ts | 58 +++--- server/src/bridge/terminal-bridge.test.ts | 73 +++++++- server/src/bridge/terminal-bridge.ts | 112 ++++++----- .../thin-client.tagged-contract.test.ts | 134 +++++++++++++ server/src/bridge/thin-client.test.ts | 177 +++++++++++++++--- server/src/bridge/thin-client.ts | 98 ++++++++-- .../src/connections/profile-service.test.ts | 77 +++++++- server/src/connections/profile-service.ts | 20 +- server/src/connections/runtime.test.ts | 63 +++++++ server/src/connections/runtime.ts | 12 +- web/src/store.test.ts | 84 +++++++++ web/src/store.ts | 59 +++--- 14 files changed, 853 insertions(+), 152 deletions(-) create mode 100644 server/src/bridge/thin-client.tagged-contract.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ce46976..0feb2ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index fde2323..a5e5843 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -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 workspace close --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 diff --git a/server/src/bridge/protocol-compat.ts b/server/src/bridge/protocol-compat.ts index dfb4490..466e395 100644 --- a/server/src/bridge/protocol-compat.ts +++ b/server/src/bridge/protocol-compat.ts @@ -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.", + ); } diff --git a/server/src/bridge/terminal-bridge.test.ts b/server/src/bridge/terminal-bridge.test.ts index ea839ef..8fe9368 100644 --- a/server/src/bridge/terminal-bridge.test.ts +++ b/server/src/bridge/terminal-bridge.test.ts @@ -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); @@ -42,6 +42,7 @@ function clipboardFrame(data: string) { async function startThinServer( options: { + protocol?: number; clipboardData?: string; appWelcomeDelayMs?: number; appWelcomeError?: string; @@ -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) { @@ -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) { @@ -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) { @@ -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; + 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; + 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; diff --git a/server/src/bridge/terminal-bridge.ts b/server/src/bridge/terminal-bridge.ts index feabc86..9f82378 100644 --- a/server/src/bridge/terminal-bridge.ts +++ b/server/src/bridge/terminal-bridge.ts @@ -6,6 +6,7 @@ import { import { type Logger, silentLogger } from "../utils/logger"; import { NO_TERMINAL_ATTACHED_MESSAGE } from "../utils/rpc-logging"; import { ThinClient } from "./thin-client"; +import { isTerminalHelloProtocol } from "./protocol-compat"; type TerminalSession = { terminalId: string | null; @@ -77,6 +78,9 @@ export function createTerminalBridge(args: { let clipboardTarget: ClipboardTarget | null = null; let clipboardRelaySize: { cols: number; rows: number } | null = null; let clipboardRelayRevision = 0; + // Set when the server speaks protocol 22+ and the relay is known to be + // undeliverable, so repeated checks neither reconnect nor re-log. + let clipboardRelaySkipped = false; let lifecycleRevision = 0; let disposed = false; @@ -227,57 +231,79 @@ export function createTerminalBridge(args: { function ensureClipboardRelay(cols: number, rows: number) { if (disposed) throw new Error("terminal bridge disposed"); + if (clipboardRelaySkipped) return Promise.resolve(); if (clipboardRelay && !clipboardRelay.isClosed) { return clipboardRelayConnecting ?? Promise.resolve(); } - const relay = new ThinClient(args.clientSocketPath, args.herdrProtocol); - clipboardRelay = relay; - clipboardRelaySize = { cols, rows }; - relay.on("clipboard", ({ data }) => forwardClipboard(data)); - relay.on("error", (error) => - logger.warn("clipboard relay error", { - connection: args.connectionId ?? "legacy-default", - error: formatError(error), - }), - ); - relay.on("close", () => { - if (clipboardRelay !== relay) return; - clipboardRelay = null; - clipboardRelayConnecting = null; - clipboardRelaySize = null; - }); + const connecting = (async () => { + // Tagged Herdr 0.9.0 (protocol 22) routes client-local side effects such as + // OSC 52 only to the foreground *shell* (endpoint-protocol) client; + // direct terminal connections like this relay are never foreground and + // can never receive ServerMessage::Clipboard there. Skip the relay and + // log the limitation instead of idling silently. Browser copy/paste + // remains available; terminal-program OSC 52 needs a shell endpoint. + const protocol = await args.herdrProtocol(); + if (disposed) return; + if (isTerminalHelloProtocol(protocol)) { + clipboardRelaySkipped = true; + logger.warn( + "terminal-program OSC 52 unavailable: Herdr protocol 22 routes clipboard only to endpoint shell clients; browser copy/paste is unaffected", + { connection: args.connectionId ?? "legacy-default" }, + ); + return; + } - // Herdr routes client-local side effects such as OSC 52 only to its - // foreground app client. Direct terminal attachments intentionally cannot - // receive them, so keep one lightweight app connection while terminals are - // being viewed and route its clipboard messages back to the input owner. - const connecting = relay - .connect(cols, rows, { launchMode: "app", encoding: 1 }) - .then(() => { - if (disposed) { - relay.close(); - return; - } - logger.debug("clipboard relay connected", { + const relay = new ThinClient(args.clientSocketPath, args.herdrProtocol); + clipboardRelay = relay; + clipboardRelaySize = { cols, rows }; + relay.on("clipboard", ({ data }) => forwardClipboard(data)); + relay.on("error", (error) => + logger.warn("clipboard relay error", { connection: args.connectionId ?? "legacy-default", - }); - }) - .catch((error) => { - if (clipboardRelay === relay) { - clipboardRelay = null; - clipboardRelaySize = null; - } - if (!disposed && sharedTerminals.size > 0) { - logger.warn("clipboard relay connection failed", { + error: formatError(error), + }), + ); + relay.on("close", () => { + if (clipboardRelay !== relay) return; + clipboardRelay = null; + clipboardRelayConnecting = null; + clipboardRelaySize = null; + }); + + // Herdr before protocol 22 routes client-local side effects such as + // OSC 52 only to its foreground app client. Direct terminal attachments + // intentionally cannot receive them, so keep one lightweight app + // connection while terminals are being viewed and route its clipboard + // messages back to the input owner. + await relay + .connect(cols, rows, { launchMode: "app", encoding: 1 }) + .then(() => { + if (disposed) { + relay.close(); + return; + } + logger.debug("clipboard relay connected", { connection: args.connectionId ?? "legacy-default", - error: formatError(error), }); - } - }) - .finally(() => { - if (clipboardRelay === relay) clipboardRelayConnecting = null; - }); + }) + .catch((error) => { + if (clipboardRelay === relay) { + clipboardRelay = null; + clipboardRelaySize = null; + } + if (!disposed && sharedTerminals.size > 0) { + logger.warn("clipboard relay connection failed", { + connection: args.connectionId ?? "legacy-default", + error: formatError(error), + }); + } + }); + })().finally(() => { + if (clipboardRelayConnecting === connecting) { + clipboardRelayConnecting = null; + } + }); clipboardRelayConnecting = connecting; return connecting; } diff --git a/server/src/bridge/thin-client.tagged-contract.test.ts b/server/src/bridge/thin-client.tagged-contract.test.ts new file mode 100644 index 0000000..e56e43d --- /dev/null +++ b/server/src/bridge/thin-client.tagged-contract.test.ts @@ -0,0 +1,134 @@ +import { expect, test } from "bun:test"; +import * as net from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { encodeFrame } from "./bincode"; +import { ThinClient } from "./thin-client"; + +// Literal bincode-standard payloads, independent of BinWriter. Contract source: +// herdr v0.9.0 b99002ac src/protocol/wire.rs TerminalHello/ClientMessage/ +// ServerMessage (470-629, 1329-1450). Legacy protocol 20 retains its Hello fields +// and Terminal=2/Shutdown=4. These are hand-transcribed, not Rust-generated. +for (const protocol of [20, 22]) { + test(`protocol ${protocol} tagged Hello/Welcome -> attach -> render/resize/input/paste/shutdown`, async () => { + const socketPath = join( + tmpdir(), + `herdr-contract-${crypto.randomUUID()}.sock`, + ); + const sockets = new Set(); + const seen: string[] = []; + const server = net.createServer((socket) => { + sockets.add(socket); + let input = Buffer.alloc(0); + socket.on("data", (chunk) => { + input = Buffer.concat([input, Buffer.from(chunk)]); + while (input.length >= 4) { + const length = input.readUInt32LE(0); + if (input.length < length + 4) return; + const payload = input.subarray(4, length + 4); + input = input.subarray(length + 4); + seen.push(payload.toString("hex")); + if (payload[0] === 0) { + const welcome = encodeFrame( + Buffer.from(protocol === 22 ? "00160100" : "00140100", "hex"), + ); + // Deliberately fragment the length header. + socket.write(welcome.subarray(0, 2)); + socket.write(welcome.subarray(2)); + } else if (payload[0] === 5) { + socket.write( + encodeFrame( + Buffer.from( + protocol === 22 + ? "0101641e010568656c6c6f" + : "0201641e010568656c6c6f", + "hex", + ), + ), + ); + } else if (payload[0] === 1) { + const payloads = + protocol === 22 + ? [ + "080100", + "080001", + "10fb000102", + "100000", + "03010874616b656f766572", + ] + : ["04010874616b656f766572"]; + // Coalesce both mouse booleans, keyboard mode/reset and shutdown. + socket.write( + Buffer.concat( + payloads.map((hex) => encodeFrame(Buffer.from(hex, "hex"))), + ), + ); + } + } + }); + }); + const client = new ThinClient(socketPath, async () => protocol); + const errors: string[] = []; + const mouse: unknown[] = []; + const keyboard: unknown[] = []; + client.on("error", (error) => errors.push(error.message)); + client.on("mouse_capture", (enabled, sgrPixels) => + mouse.push({ enabled, sgrPixels }), + ); + client.on("keyboard_protocol", (mode) => keyboard.push(mode)); + const terminal = new Promise((resolve) => + client.once("terminal", resolve), + ); + const closed = new Promise((resolve) => + client.once("close", resolve), + ); + try { + await new Promise((resolve) => server.listen(socketPath, resolve)); + await client.connect(100, 30, { + launchMode: "terminal-attach", + encoding: 1, + }); + expect(seen).toEqual([ + protocol === 22 ? "0016641e000000" : "0014641e0000010002", + ]); + client.attach("term_1", true); + expect(await terminal).toEqual({ + seq: 1, + width: 100, + height: 30, + full: true, + bytes: Buffer.from("hello"), + }); + client.resize(120, 40); + client.input(Buffer.from("abc\x1b[200~paste\x1b[201~")); + await closed; + expect(seen.slice(1)).toEqual([ + "05067465726d5f3101", + protocol === 22 ? "037828000000" : "0378280000", + "01146162631b5b3230307e70617374651b5b3230317e", + ]); + expect(errors).toEqual(["takeover"]); + expect(client.isClosed).toBe(true); + expect(mouse).toEqual( + protocol === 22 + ? [ + { enabled: true, sgrPixels: false }, + { enabled: false, sgrPixels: true }, + ] + : [], + ); + expect(keyboard).toEqual( + protocol === 22 + ? [ + { flags: 256, modifyOtherKeysLevel: 2 }, + { flags: 0, modifyOtherKeysLevel: 0 }, + ] + : [], + ); + } finally { + client.close(); + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + } + }); +} diff --git a/server/src/bridge/thin-client.test.ts b/server/src/bridge/thin-client.test.ts index c1dabf8..2f715bd 100644 --- a/server/src/bridge/thin-client.test.ts +++ b/server/src/bridge/thin-client.test.ts @@ -23,7 +23,11 @@ afterEach(async () => { }); async function startHandshakeServer( - welcome: (protocol: number) => { version: number; error?: string }, + welcome: (protocol: number) => { + version: number; + encoding?: number; + error?: string; + }, onConnection: () => void = () => undefined, onHello: (hello: { protocol: number; launchMode: number }) => void = () => undefined, @@ -50,15 +54,23 @@ async function startHandshakeServer( reader.varint(); // rows reader.varint(); // cell_width_px reader.varint(); // cell_height_px - reader.varint(); // requested_encoding - reader.varint(); // keybindings - const launchMode = reader.varint(); + let launchMode = 0; + if (protocol === 22) { + // TerminalHello: requested_encoding, keybindings, and launch_mode + // were removed; only pixel_mouse remains. + expect(reader.bool()).toBe(false); + } else { + reader.varint(); // requested_encoding + reader.varint(); // keybindings + launchMode = reader.varint(); + } + expect(reader.remaining).toBe(0); onHello({ protocol, launchMode }); const response = welcome(protocol); const writer = new BinWriter(); writer.variant(0); writer.varint(response.version); - writer.varint(1); + writer.varint(response.encoding ?? 1); writer.option(response.error, (value) => writer.string(value)); socket.write(encodeFrame(writer.toBuffer())); }); @@ -112,25 +124,20 @@ async function startMessageServer( } describe("Herdr thin-client protocol compatibility", () => { - test("supports the compatible floor and future protocol versions", () => { - expect([14, 15, 16, 17, 18, 999].map(isSupportedHerdrProtocol)).toEqual([ - true, - true, - true, - true, - true, - true, - ]); + test("supports verified legacy codecs and exact tagged protocol 22, not 21", () => { + expect( + [14, 15, 16, 17, 18, 19, 20, 21, 22].map(isSupportedHerdrProtocol), + ).toEqual([true, true, true, true, true, true, true, false, true]); expect(isSupportedHerdrProtocol(13)).toBe(false); - expect(() => assertSupportedHerdrProtocol(13)).toThrow( - "requires protocol 14 or newer", - ); - expect(() => assertSupportedHerdrProtocol(17.5)).toThrow( - "invalid protocol version", - ); - expect(() => assertSupportedHerdrProtocol(0x1_0000_0000)).toThrow( - "invalid protocol version", - ); + // Protocols newer than 22 have an unknown wire layout and must fail + // loudly instead of mis-decoding. + expect(isSupportedHerdrProtocol(23)).toBe(false); + expect(isSupportedHerdrProtocol(999)).toBe(false); + for (const protocol of [13, 21, 23, 999, 17.5, 0x1_0000_0000, "22", null]) { + expect(() => assertSupportedHerdrProtocol(protocol)).toThrow( + "supports protocols 14-20 and 22", + ); + } }); for (const protocol of [14, 15, 16, 17, 18]) { @@ -158,7 +165,6 @@ describe("Herdr thin-client protocol compatibility", () => { for (const [protocol, expectedLaunchMode] of [ [19, 1], [20, 2], - [21, 2], ] as const) { const seen: Array<{ protocol: number; launchMode: number }> = []; const socketPath = await startHandshakeServer( @@ -193,6 +199,79 @@ describe("Herdr thin-client protocol compatibility", () => { client.close(); }); + test("uses the TerminalHello layout on protocol 22", async () => { + const seen: Array<{ protocol: number; launchMode: number }> = []; + const socketPath = await startHandshakeServer( + (requestedProtocol) => ({ version: requestedProtocol }), + () => undefined, + (hello) => seen.push(hello), + ); + const client = new ThinClient(socketPath, async () => 22); + + await client.connect(100, 30, { + launchMode: "terminal-attach", + encoding: 1, + }); + + // The 6-field TerminalHello carries no launch mode at all. + expect(seen).toEqual([{ protocol: 22, launchMode: 0 }]); + client.close(); + }); + + test("decodes terminal frames at the protocol 22 variant index", async () => { + const socketPath = await startMessageServer((variant, socket) => { + if (variant !== 5) return; + const writer = new BinWriter(); + writer.variant(1); // ServerMessage::Terminal on protocol 22 + writer.varint(7); // seq + writer.varint(100); // width + writer.varint(30); // height + writer.bool(true); // full + writer.bytes(Buffer.from("hello")); + socket.write(encodeFrame(writer.toBuffer())); + }); + const client = new ThinClient(socketPath, async () => 22); + const terminal = new Promise<{ + seq: number; + width: number; + height: number; + full: boolean; + bytes: Buffer; + }>((resolve) => client.once("terminal", resolve)); + + await client.connect(100, 30, { launchMode: "terminal-attach" }); + client.attach("term_1", true); + + expect(await terminal).toEqual({ + seq: 7, + width: 100, + height: 30, + full: true, + bytes: Buffer.from("hello"), + }); + client.close(); + }); + + test("appends pixel_mouse to resize only on protocol 22", async () => { + const resizes: number[] = []; + const socketPath = await startMessageServer((variant, _socket, reader) => { + if (variant !== 3) return; + reader.varint(); // cols + reader.varint(); // rows + reader.varint(); // cell_width_px + reader.varint(); // cell_height_px + resizes.push(reader.bool() ? 1 : 0); + }); + const client = new ThinClient(socketPath, async () => 22); + + await client.connect(100, 30, { launchMode: "terminal-attach" }); + client.resize(120, 40); + await Bun.sleep(10); + + expect(resizes).toEqual([0]); + client.close(); + }); + test("rejects a welcome error instead of treating the socket as attached", async () => { const socketPath = await startHandshakeServer((protocol) => ({ version: 16, @@ -205,14 +284,58 @@ describe("Herdr thin-client protocol compatibility", () => { ); }); - test("rejects unsupported protocols before opening a thin socket", async () => { - const client = new ThinClient("/missing.sock", async () => 13); + for (const protocol of [13, 21, 23, 999]) { + test(`rejects protocol ${protocol} before opening a thin socket`, async () => { + const client = new ThinClient("/missing.sock", async () => protocol); + await expect(client.connect(100, 30)).rejects.toThrow( + `Herdr protocol ${protocol} is not supported`, + ); + }); + test(`rejects unsupported Welcome protocol ${protocol}`, async () => { + const socketPath = await startHandshakeServer(() => ({ + version: protocol, + })); + const client = new ThinClient(socketPath, async () => 22); + client.on("error", () => undefined); + await expect(client.connect(100, 30)).rejects.toThrow( + `Herdr protocol ${protocol} is not supported`, + ); + expect(client.isClosed).toBe(true); + }); + } + test("rejects a supported but mismatched Welcome protocol", async () => { + const socketPath = await startHandshakeServer(() => ({ version: 20 })); + const client = new ThinClient(socketPath, async () => 22); await expect(client.connect(100, 30)).rejects.toThrow( - "Herdr protocol 13 is not supported", + "welcomed protocol 20, expected 22", ); }); + test("requires TerminalAnsi encoding in protocol 22 Welcome", async () => { + const socketPath = await startHandshakeServer(() => ({ + version: 22, + encoding: 0, + })); + const client = new ThinClient(socketPath, async () => 22); + await expect(client.connect(100, 30)).rejects.toThrow( + "unsupported encoding 0", + ); + }); + + test("rejects an oversized frame before accepting a coalesced Welcome", async () => { + const socketPath = await startMessageServer((variant, socket) => { + if (variant !== 0) return; + const header = Buffer.alloc(4); + header.writeUInt32LE(32 * 1024 * 1024 + 1); + socket.write(header); + }); + const client = new ThinClient(socketPath, async () => 22); + client.on("error", () => undefined); + await expect(client.connect(100, 30)).rejects.toThrow("oversized frame"); + expect(client.isClosed).toBe(true); + }); + test("does not open a socket when closed during protocol resolution", async () => { let resolveProtocol!: (protocol: number) => void; const protocol = new Promise((resolve) => { diff --git a/server/src/bridge/thin-client.ts b/server/src/bridge/thin-client.ts index 1b4e639..b8fd437 100644 --- a/server/src/bridge/thin-client.ts +++ b/server/src/bridge/thin-client.ts @@ -3,6 +3,7 @@ import * as net from "node:net"; import { BinReader, BinWriter, encodeFrame } from "./bincode"; import { assertSupportedHerdrProtocol, + isTerminalHelloProtocol, terminalAttachLaunchModeWireValue, } from "./protocol-compat"; @@ -17,15 +18,42 @@ const CM = { AttachScroll: 6, } as const; -// ServerMessage variant indices. -const SM = { +// ServerMessage variant indices. Tagged v0.9.0 (protocol 22) removed Frame, +// KittyKeyboardReportAll, and PrefixInputSource, shifting every variant the +// GUI decodes down; Frame gets -1 so it can never match on newer protocols. +interface ServerMessageIndices { + Welcome: number; + Frame: number; + Terminal: number; + ServerShutdown: number; + Clipboard: number; + MouseCapture: number; + DirectTerminalKeyboardProtocol: number; +} + +const SM_LEGACY: ServerMessageIndices = { Welcome: 0, Frame: 1, Terminal: 2, ServerShutdown: 4, Clipboard: 6, MouseCapture: 9, -} as const; + DirectTerminalKeyboardProtocol: -1, +}; + +const SM_V22: ServerMessageIndices = { + Welcome: 0, + Frame: -1, + Terminal: 1, + ServerShutdown: 3, + Clipboard: 5, + MouseCapture: 8, + DirectTerminalKeyboardProtocol: 16, +}; + +function serverMessageIndices(protocol: number): ServerMessageIndices { + return isTerminalHelloProtocol(protocol) ? SM_V22 : SM_LEGACY; +} /** * Semantic launch mode requested in the Hello handshake. The wire value is @@ -69,6 +97,7 @@ export class ThinClient extends EventEmitter { private buf = Buffer.alloc(0); private closed = false; private protocolVersion: number | null = null; + private sm: ServerMessageIndices = SM_LEGACY; private attachedTerminalId: string | null = null; private pendingWelcome: | { @@ -102,6 +131,7 @@ export class ThinClient extends EventEmitter { if (this.closed) throw new Error("thin client is closed"); assertSupportedHerdrProtocol(protocolVersion); this.protocolVersion = protocolVersion; + this.sm = serverMessageIndices(protocolVersion); await new Promise((resolve, reject) => { const sock = net.createConnection({ path: this.socketPath }); this.sock = sock; @@ -161,14 +191,18 @@ export class ThinClient extends EventEmitter { while (this.buf.length >= 4) { const len = this.buf.readUInt32LE(0); if (len > 32 * 1024 * 1024) { - this.emit("error", new Error(`oversized frame: ${len}`)); + const error = new Error(`oversized frame: ${len}`); this.buf = Buffer.alloc(0); + this.rejectWelcome(error); + this.close(); + this.emit("error", error); return; } if (this.buf.length < 4 + len) return; const payload = this.buf.subarray(4, 4 + len); this.buf = this.buf.subarray(4 + len); this.handlePayload(payload); + if (this.closed) return; } } @@ -176,10 +210,12 @@ export class ThinClient extends EventEmitter { try { const r = new BinReader(payload); const variant = r.variant(); - if (variant === SM.Welcome) { + const sm = this.sm; + if (variant === sm.Welcome) { const version = r.varint(); const encoding = r.varint(); const error = r.option(() => r.string()); + assertSupportedHerdrProtocol(version); this.emit("welcome", { version, encoding, error }); if (error) { this.rejectWelcome( @@ -195,33 +231,56 @@ export class ThinClient extends EventEmitter { ), ); this.close(); + } else if ( + (isTerminalHelloProtocol(version) && encoding !== 1) || + (encoding !== 0 && encoding !== 1) + ) { + this.rejectWelcome( + new Error(`Herdr welcomed unsupported encoding ${encoding}`), + ); + this.close(); } else { this.resolveWelcome(); } - } else if (variant === SM.Frame) { + } else if (variant === sm.Frame) { this.emit("frame", readFrameData(r)); - } else if (variant === SM.Terminal) { + } else if (variant === sm.Terminal) { const seq = r.varint(); const width = r.varint(); const height = r.varint(); const full = r.bool(); const bytes = r.bytes(); this.emit("terminal", { seq, width, height, full, bytes }); - } else if (variant === SM.ServerShutdown) { + } else if (variant === sm.ServerShutdown) { const reason = r.option(() => r.string()); this.emit( "error", new Error(reason || "Herdr closed the thin-client connection"), ); this.close(); - } else if (variant === SM.Clipboard) { + } else if (variant === sm.Clipboard) { this.emit("clipboard", { data: r.string() }); - } else if (variant === SM.MouseCapture) { - this.emit("mouse_capture", r.bool()); + } else if (variant === sm.MouseCapture) { + const enabled = r.bool(); + const sgrPixels = isTerminalHelloProtocol(this.protocolVersion!) + ? r.bool() + : false; + // Keep the legacy enabled argument; protocol 22 carries both fields. + this.emit("mouse_capture", enabled, sgrPixels); + } else if (variant === sm.DirectTerminalKeyboardProtocol) { + // Decode the tagged contract, but do not enable browser keyboard modes: + // basic input remains legacy; enhanced keyboard parity is unsupported. + this.emit("keyboard_protocol", { + flags: r.varint(), + modifyOtherKeysLevel: r.u8(), + }); } // Graphics / Notify / WindowTitle are ignored for now. } catch (e) { - this.emit("error", new Error(`decode: ${(e as Error).message}`)); + const error = e instanceof Error ? e : new Error(String(e)); + this.rejectWelcome(error); + this.close(); + this.emit("error", error); } } @@ -243,6 +302,14 @@ export class ThinClient extends EventEmitter { w.varint(rows); w.varint(0); // cell_width_px (no client-side kitty graphics) w.varint(0); // cell_height_px + if (isTerminalHelloProtocol(protocolVersion)) { + // Protocol 22 (Herdr 0.9.0) replaced Hello with TerminalHello: + // requested_encoding, keybindings, and launch_mode are gone (direct + // terminal connections always get TerminalAnsi), leaving pixel_mouse. + w.bool(false); // pixel_mouse + this.write(w.toBuffer()); + return; + } w.varint(encoding); // requested_encoding: 0=SemanticFrame, 1=TerminalAnsi w.varint(0); // keybindings = Server // launch_mode: App is always 0; TerminalAttach moved from 1 to 2 in @@ -280,6 +347,13 @@ export class ThinClient extends EventEmitter { w.varint(rows); w.varint(0); w.varint(0); + // Protocol 22 (Herdr 0.9.0) added pixel_mouse to Resize. + if ( + this.protocolVersion !== null && + isTerminalHelloProtocol(this.protocolVersion) + ) { + w.bool(false); // pixel_mouse + } this.write(w.toBuffer()); } diff --git a/server/src/connections/profile-service.test.ts b/server/src/connections/profile-service.test.ts index 5c6a97a..a3b02fe 100644 --- a/server/src/connections/profile-service.test.ts +++ b/server/src/connections/profile-service.test.ts @@ -139,6 +139,8 @@ function runtimeFactory(created: Map) { async function startProbeServers( id: string, renderMode: "valid" | "malformed" = "valid", + pingProtocol: unknown = 14, + welcomeProtocol?: number, ) { const key = crypto.randomUUID(); const root = join(tmpdir(), `herdr-gui-profile-probe-${key}`); @@ -167,7 +169,7 @@ async function startProbeServers( id: request.id, result: { version: `fake-${id}`, - protocol: 14, + protocol: pingProtocol, workspace_id: "shared-workspace", pane_id: "shared-pane", terminal_id: "shared-terminal", @@ -191,9 +193,22 @@ async function startProbeServers( const reader = new BinReader(input.subarray(4, 4 + length)); expect(reader.variant()).toBe(0); const protocol = reader.varint(); + expect(pingProtocol).toBe(protocol); + expect(reader.varint()).toBe(80); + expect(reader.varint()).toBe(24); + expect(reader.varint()).toBe(0); + expect(reader.varint()).toBe(0); + if (protocol === 22) { + expect(reader.bool()).toBe(false); + } else { + reader.varint(); // encoding + expect(reader.varint()).toBe(0); // keybindings + expect(reader.varint()).toBe(0); // app + } + expect(reader.remaining).toBe(0); const writer = new BinWriter(); writer.variant(0); - writer.varint(protocol); + writer.varint(welcomeProtocol ?? protocol); writer.varint(1); writer.option(undefined, (value) => writer.string(value)); socket.write(encodeFrame(writer.toBuffer())); @@ -323,6 +338,55 @@ describe("connection profile bootstrap", () => { }); describe("connection profile service", () => { + for (const protocol of [20, 22]) { + test(`probes protocol ${protocol} with a complete Hello/Welcome`, async () => { + const server = await startProbeServers("tagged", "valid", protocol); + expect(await testLocalConnectionProfile(server.profile)).toMatchObject({ + ok: true, + protocol, + }); + expect(server.counts()).toEqual({ + controlConnections: 1, + renderConnections: 1, + }); + }); + } + + for (const protocol of [13, 21, 23, 999, "22", null]) { + test(`rejects unsupported control probe ${JSON.stringify(protocol)} without opening render socket`, async () => { + const server = await startProbeServers("unsupported", "valid", protocol); + await expect( + testLocalConnectionProfile(server.profile), + ).rejects.toMatchObject({ + retryable: false, + message: expect.stringContaining("supports protocols 14-20 and 22"), + }); + expect(server.counts()).toEqual({ + controlConnections: 1, + renderConnections: 0, + }); + }); + } + + for (const protocol of [21, 23, 999]) { + test(`classifies unsupported binary Welcome ${protocol} as permanent`, async () => { + const server = await startProbeServers( + "unsupported-welcome", + "valid", + 22, + protocol, + ); + await expect( + testLocalConnectionProfile(server.profile), + ).rejects.toMatchObject({ + retryable: false, + message: expect.stringContaining( + `Herdr protocol ${protocol} is not supported`, + ), + }); + }); + } + test("probes two colliding-ID local servers through their own control and render sockets", async () => { const alpha = await startProbeServers("alpha"); const beta = await startProbeServers("beta"); @@ -365,9 +429,12 @@ describe("connection profile service", () => { ).rejects.toThrow(); const malformed = await startProbeServers("malformed-render", "malformed"); - await expect(testLocalConnectionProfile(malformed.profile)).rejects.toThrow( - "closed during handshake", - ); + await expect( + testLocalConnectionProfile(malformed.profile), + ).rejects.toMatchObject({ + retryable: false, + message: expect.stringContaining("bincode: short read"), + }); }); test("first create persists a restart-consistent default and retires synthetic runtime", async () => { diff --git a/server/src/connections/profile-service.ts b/server/src/connections/profile-service.ts index 0f04ff8..09c83db 100644 --- a/server/src/connections/profile-service.ts +++ b/server/src/connections/profile-service.ts @@ -1,4 +1,8 @@ import { HerdrClient } from "../bridge/herdr-client"; +import { + assertSupportedHerdrProtocol, + HerdrCompatibilityError, +} from "../bridge/protocol-compat"; import { createSshTunnelManager, SshTunnelError } from "../bridge/ssh-tunnel"; import { ThinClient } from "../bridge/thin-client"; import { runProcess } from "../utils/process-utils"; @@ -139,12 +143,13 @@ export async function testConnectionSockets( let thinClient: ThinClient | null = null; try { const ping = await herdr.call("ping", {}, 8_000); - const protocol = Number(ping?.protocol); - if (!Number.isFinite(protocol)) { - throw new ConnectionProbeError( - "Herdr ping did not return a protocol version", - false, - ); + const protocol: unknown = ping?.protocol; + try { + assertSupportedHerdrProtocol(protocol); + } catch (error) { + throw new ConnectionProbeError((error as Error).message, false, { + cause: error, + }); } thinClient = new ThinClient(clientSocketPath, async () => protocol); // ThinClient mirrors runtime failures through EventEmitter in addition to @@ -157,7 +162,8 @@ export async function testConnectionSockets( } catch (error) { const message = error instanceof Error ? error.message : String(error); const permanent = - /(?:protocol .*not supported|rejected thin-client protocol|bincode:|invalid protocol version)/i.test( + error instanceof HerdrCompatibilityError || + /(?:protocol .*not supported|rejected thin-client protocol|welcomed protocol|unsupported encoding|bincode:|invalid protocol version)/i.test( message, ); throw new ConnectionProbeError(message, !permanent, { cause: error }); diff --git a/server/src/connections/runtime.test.ts b/server/src/connections/runtime.test.ts index 8aa31fb..4cc6715 100644 --- a/server/src/connections/runtime.test.ts +++ b/server/src/connections/runtime.test.ts @@ -131,3 +131,66 @@ test("runtime stop drains an in-flight completion and suppresses publication", a baselines.captureWorkspace("w1", async () => "/repo"), ).rejects.toMatchObject({ code: "LAST_STEP_STORE_DISPOSED" }); }); + +test("layout subscription ACK and reconnect request browser reconciliation", async () => { + const events: unknown[] = []; + const subscriptions: Array<{ ack: () => void; close: () => void }> = []; + const runtime = createLegacyConnectionRuntime({ + config: { + socketPath: "/tmp/unused-layout-contract-control.sock", + clientSocketPath: "/tmp/unused-layout-contract-client.sock", + hasExplicitSocketPath: true, + hasExplicitClientSocketPath: true, + }, + safeSend: () => true, + clientLabel: () => "test", + markRpcError: () => undefined, + onEvent: (event) => events.push(event), + }); + // No real sockets, settings-driven git operations, or pane processes. + runtime.workspaceAutoSync.start = () => undefined; + runtime.herdr.call = async () => ({ panes: [] }); + runtime.herdr.subscribe = (types) => { + expect(types).toContain("layout.updated"); + let ack!: () => void; + let close!: () => void; + const ready = new Promise((resolve) => { + ack = resolve; + }); + const closed = new Promise((resolve) => { + close = resolve; + }); + subscriptions.push({ ack, close }); + return { ready, closed, close }; + }; + try { + runtime.startBackground(); + expect(subscriptions).toHaveLength(1); + expect(events).toEqual([]); // No snapshot invalidation before the ACK. + subscriptions[0]!.ack(); + await Bun.sleep(10); + expect(events).toEqual([{ event: "session.resync_required", data: {} }]); + // Subscription name is dotted; tagged event envelopes use snake_case. + const layout = { + event: "layout_updated", + data: { layout: { tab_id: "tab_1" } }, + }; + runtime.herdr.emit("event", layout); + expect(events.at(-1)).toEqual(layout); + subscriptions[0]!.close(); + const deadline = Date.now() + 3000; + while (subscriptions.length < 2 && Date.now() < deadline) + await Bun.sleep(10); + expect(subscriptions).toHaveLength(2); + expect(events).toHaveLength(2); + subscriptions[1]!.ack(); + await Bun.sleep(10); + expect(events.at(-1)).toEqual({ + event: "session.resync_required", + data: {}, + }); + expect(events).toHaveLength(3); + } finally { + await runtime.stop(); + } +}); diff --git a/server/src/connections/runtime.ts b/server/src/connections/runtime.ts index 1b84e96..1fa1154 100644 --- a/server/src/connections/runtime.ts +++ b/server/src/connections/runtime.ts @@ -2,6 +2,7 @@ import type { ServerWebSocket } from "bun"; import { createAgentSessionHandlers } from "../agent/agent-sessions"; import { createAgentSessionFileAccess } from "../agent/session-file-access"; import { HerdrClient } from "../bridge/herdr-client"; +import { assertSupportedHerdrProtocol } from "../bridge/protocol-compat"; import { createSettingsRpcHandler } from "../bridge/settings-rpc"; import { createSshTunnelManager, @@ -60,6 +61,7 @@ const DEFAULT_EVENTS = [ "pane.moved", "pane.exited", "pane.agent_detected", + "layout.updated", "worktree.created", "worktree.opened", "worktree.removed", @@ -191,7 +193,11 @@ export function createLegacyConnectionRuntime(args: { connectionGeneration: args.connectionGeneration, formatError: sanitizeConnectionError, clientSocketPath, - herdrProtocol: async () => Number((await herdr.ping()).protocol), + herdrProtocol: async () => { + const protocol: unknown = (await herdr.ping()).protocol; + assertSupportedHerdrProtocol(protocol); + return protocol; + }, safeSend: args.safeSend, clientLabel: args.clientLabel, markRpcError: args.markRpcError, @@ -299,6 +305,10 @@ export function createLegacyConnectionRuntime(args: { const subscriptionLoop = createEventSubscriptionLoop({ subscribe: () => herdr.subscribe(DEFAULT_EVENTS), onReady: () => { + // Browser snapshots may start before the subscription ACK. Reconcile + // after every ACK (including reconnect) to close that missed-event gap. + // The browser's generic refresh path queues another snapshot if busy. + args.onEvent({ event: "session.resync_required", data: {} }, identity); if (!eventSubscriptionRecovery.recovered({ connection: identity.id })) { logger.info("subscribed to Herdr events", { connection: identity.id }); } diff --git a/web/src/store.test.ts b/web/src/store.test.ts index d8fc552..721698f 100644 --- a/web/src/store.test.ts +++ b/web/src/store.test.ts @@ -1456,3 +1456,87 @@ describe("pending workspace focus settlement", () => { } }); }); + +describe("basic Herdr 0.9 compatibility", () => { + test("safe workspace close reports grouped-close refusal without closing the group", async () => { + const previousState = store.get(); + const originalConnection = bridge.connection; + const calls: unknown[] = []; + bridge.connection = (() => ({ + connectionId: "alpha", + generation: 10, + isCurrent: () => true, + call: (async (method, params) => { + calls.push({ method, params }); + throw new Error( + "workspace_group_close_required: workspace has linked worktrees", + ); + }) as ConnectionClient["call"], + })) as typeof bridge.connection; + try { + __storeTesting.replaceState(partitionState()); + await store.closeWorkspace("workspace_1"); + expect(calls).toEqual([ + { method: "workspace.close", params: { workspace_id: "workspace_1" } }, + ]); + expect(store.get().notice).toMatchObject({ + kind: "error", + message: "Workspace belongs to a group", + detail: expect.stringContaining("Herdr CLI with --group"), + }); + expect(store.get().workspaces).toEqual(partitionState().workspaces); + } finally { + bridge.connection = originalConnection; + __storeTesting.replaceState(previousState); + } + }); + + for (const event of ["layout_updated", "session.resync_required"]) { + test(`${event} uses generic refresh and queues reconciliation during an in-flight snapshot`, async () => { + const previousState = store.get(); + const originalConnection = bridge.connection; + const snapshot = partitionState(); + let lists = 0; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + bridge.connection = (() => ({ + connectionId: "alpha", + generation: 10, + isCurrent: () => true, + call: (async (method) => { + if (method === "workspace.list") { + lists += 1; + if (lists === 1) await gate; + return { workspaces: snapshot.workspaces }; + } + if (method === "tab.list") return { tabs: snapshot.tabs }; + if (method === "pane.list") return { panes: snapshot.panes }; + if (method === "pane.layout") return { layout: null }; + return {}; + }) as ConnectionClient["call"], + })) as typeof bridge.connection; + try { + __storeTesting.replaceState(snapshot); + const refreshing = store.refresh(); + __storeTesting.handleHerdrEvent({ + event, + connection_id: "alpha", + connection_generation: 1, + data: {}, + }); + await Bun.sleep(100); // The production 80ms event debounce fires while busy. + expect(lists).toBe(1); + release(); + await refreshing; + await Bun.sleep(10); + expect(lists).toBe(2); // No five-second metadata poll needed. + } finally { + release(); + bridge.connection = originalConnection; + __storeTesting.replaceState(previousState); + } + }); + } +}); diff --git a/web/src/store.ts b/web/src/store.ts index c309333..fe0b9b3 100644 --- a/web/src/store.ts +++ b/web/src/store.ts @@ -4,6 +4,7 @@ import { type ConnectionClient, type ConnectionStatus, type ConnectionSummary, + type HerdrEventMsg, parseConnectionSummary, } from "./api"; import { @@ -1777,6 +1778,25 @@ export function worktreeRemovalCompletionNotice( }; } +function handleHerdrEvent(event: HerdrEventMsg) { + if ( + !state.connectionPaused && + connectionEventIsActive( + state, + event.connection_id, + event.connection_generation, + ) + ) { + if ( + event.event === "workspace.last_step_completed" && + typeof event.data.workspace_id === "string" + ) { + publishLastStepCompletion(event.connection_id, event.data.workspace_id); + } + scheduleRefresh(); + } +} + export const store = { get: () => state, subscribe(l: () => void) { @@ -1859,27 +1879,7 @@ export const store = { } } }); - bridge.onEvent((event) => { - if ( - !state.connectionPaused && - connectionEventIsActive( - state, - event.connection_id, - event.connection_generation, - ) - ) { - if ( - event.event === "workspace.last_step_completed" && - typeof event.data.workspace_id === "string" - ) { - publishLastStepCompletion( - event.connection_id, - event.data.workspace_id, - ); - } - scheduleRefresh(); - } - }); + bridge.onEvent(handleHerdrEvent); bridge.onControl((control) => { if (control.type === "pause_connection") { store.pauseConnection( @@ -2181,8 +2181,20 @@ export const store = { }, closeWorkspace(workspaceId: string) { - return action((lease) => - lease.client.call("workspace.close", { workspace_id: workspaceId }), + return action( + (lease) => + lease.client.call("workspace.close", { workspace_id: workspaceId }), + { + failureNotice: (error) => ({ + kind: "error", + message: error.message.startsWith("workspace_group_close_required:") + ? "Workspace belongs to a group" + : "Workspace close failed", + detail: error.message.startsWith("workspace_group_close_required:") + ? "Nothing was closed. To close this workspace and its linked workspaces, explicitly close the group in the Herdr CLI with --group." + : error.message, + }), + }, ); }, @@ -2881,6 +2893,7 @@ export const store = { /** Test-only singleton seam for deterministic deferred production-store tests. */ export const __storeTesting = { + handleHerdrEvent, startUpdatePolling, updatePollingActive: () => updateTimer !== null, refreshBridgeStatus, From 3c3fc3baa78c9ff556750b96d077ee0c55b34592 Mon Sep 17 00:00:00 2001 From: Wangshuyi Date: Tue, 8 Sep 2026 09:30:49 +0800 Subject: [PATCH 2/4] Validate default local connections before background startup --- .../src/connections/profile-process.test.ts | 81 ++++++++++++++++++- server/src/index.ts | 3 - 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/server/src/connections/profile-process.test.ts b/server/src/connections/profile-process.test.ts index a108028..f08483e 100644 --- a/server/src/connections/profile-process.test.ts +++ b/server/src/connections/profile-process.test.ts @@ -39,6 +39,8 @@ async function listen(server: net.Server, path: string): Promise { async function fakeHerdr( root: string, id: string, + protocol: unknown = 14, + welcomeProtocol?: number, ): Promise { const controlPath = join(root, `${id}-control.sock`); const renderPath = join(root, `${id}-render.sock`); @@ -57,7 +59,7 @@ async function fakeHerdr( controlCalls.set(id, calls); const result = request.method === "ping" - ? { version: `fake-${id}`, protocol: 14 } + ? { version: `fake-${id}`, protocol } : request.method === "workspace.list" ? { workspaces: [ @@ -89,7 +91,7 @@ async function fakeHerdr( const protocol = reader.varint(); const writer = new BinWriter(); writer.variant(0); - writer.varint(protocol); + writer.varint(welcomeProtocol ?? protocol); writer.varint(1); writer.option(undefined, (value) => writer.string(value)); socket.write(encodeFrame(writer.toBuffer())); @@ -416,3 +418,78 @@ test("production dispatcher isolates two local profiles and profile CRUD", async await child.exited; } }, 20_000); + +for (const { protocol, welcomeProtocol, accepted } of [ + { protocol: 20, accepted: true }, + { protocol: 22, accepted: true }, + { protocol: 21, accepted: false }, + { protocol: 23, accepted: false }, + { protocol: "22", accepted: false }, + { protocol: 22, welcomeProtocol: 23, accepted: false }, +]) { + test(`default local startup validates protocol ${JSON.stringify(protocol)} / Welcome ${welcomeProtocol ?? "same"} before background RPCs`, async () => { + if (process.platform === "win32") return; + const root = join(tmpdir(), `h090-default-${crypto.randomUUID()}`); + roots.push(root); + mkdirSync(root, { recursive: true, mode: 0o700 }); + const profile = await fakeHerdr(root, "test", protocol, welcomeProtocol); + const env: Record = { + ...process.env, + HOST: "127.0.0.1", + PORT: "0", + HERDR_GUI_CONNECTIONS_PATH: join(root, "connections.json"), + HERDR_SOCKET_PATH: profile.control_socket_path, + HERDR_CLIENT_SOCKET_PATH: profile.client_socket_path, + }; + delete env.HERDR_SSH_HOST; + delete env.HERDR_SESSION; + const child = Bun.spawn(["bun", "server/src/index.ts"], { + cwd: join(import.meta.dir, "../../.."), + env, + stdout: "pipe", + stderr: "ignore", + }); + let socket: WebSocket | undefined; + try { + const port = await bridgeListeningPort(child.stdout); + const browser = new WebSocket(`ws://127.0.0.1:${port}/ws`); + socket = browser; + await new Promise((resolve, reject) => { + browser.onopen = () => resolve(); + browser.onerror = () => reject(new Error("websocket open failed")); + }); + let timer!: ReturnType; + const reply = new Promise((resolve, reject) => { + timer = setTimeout( + () => reject(new Error("connect RPC timed out")), + 5_000, + ); + browser.onmessage = (event) => { + const message = JSON.parse(String(event.data)); + if (message.id === "connect-default") resolve(message); + }; + }); + socket.send( + JSON.stringify({ + id: "connect-default", + method: "connections.connect", + params: { id: "legacy-default" }, + }), + ); + const response = await reply.finally(() => clearTimeout(timer)); + const calls = controlCalls.get("test") ?? []; + expect(calls[0]).toBe("ping"); + if (accepted) { + expect(response.error).toBeUndefined(); + expect(response.result.state).toBe("ready"); + } else { + expect(response.error?.message).toContain("protocol"); + expect(calls.filter((method) => method !== "ping")).toEqual([]); + } + } finally { + socket?.close(); + child.kill("SIGTERM"); + await child.exited; + } + }, 10_000); +} diff --git a/server/src/index.ts b/server/src/index.ts index 87f0d08..69a8511 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -427,9 +427,6 @@ function runtimeFactoryForProfile( } throw error; } - if (profile.id === LEGACY_DEFAULT_CONNECTION_ID && !profileConfig.sshHost) { - return runtime; - } return { ...runtime, async startTransport() { From 34ed5884df0ec4bafb0cbb83afdd76ad073b94a4 Mon Sep 17 00:00:00 2001 From: Wangshuyi Date: Tue, 8 Sep 2026 09:48:22 +0800 Subject: [PATCH 3/4] Correct History filter defaults and window documentation --- docs/HISTORY.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/HISTORY.md b/docs/HISTORY.md index 391edb2..968e97a 100644 --- a/docs/HISTORY.md +++ b/docs/HISTORY.md @@ -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 @@ -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. From 22d2641b6ba6ee68764e01cf2886347443355bbf Mon Sep 17 00:00:00 2001 From: Wangshuyi Date: Tue, 8 Sep 2026 09:53:37 +0800 Subject: [PATCH 4/4] Wait for reconciliation publication in store tests --- web/src/store.test.ts | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/web/src/store.test.ts b/web/src/store.test.ts index 721698f..6d02ef6 100644 --- a/web/src/store.test.ts +++ b/web/src/store.test.ts @@ -1496,7 +1496,25 @@ describe("basic Herdr 0.9 compatibility", () => { const previousState = store.get(); const originalConnection = bridge.connection; const snapshot = partitionState(); + const refreshedWorkspaces = snapshot.workspaces.map((workspace) => ({ + ...workspace, + label: "after-layout-event", + })); let lists = 0; + let published!: () => void; + let publicationTimer!: ReturnType; + const publication = new Promise((resolve, reject) => { + published = resolve; + publicationTimer = setTimeout( + () => reject(new Error("follow-up snapshot was not published")), + 2_000, + ); + }); + const unsubscribe = store.subscribe(() => { + if (store.get().workspaces[0]?.label === "after-layout-event") { + published(); + } + }); let release!: () => void; const gate = new Promise((resolve) => { release = resolve; @@ -1509,7 +1527,10 @@ describe("basic Herdr 0.9 compatibility", () => { if (method === "workspace.list") { lists += 1; if (lists === 1) await gate; - return { workspaces: snapshot.workspaces }; + return { + workspaces: + lists === 1 ? snapshot.workspaces : refreshedWorkspaces, + }; } if (method === "tab.list") return { tabs: snapshot.tabs }; if (method === "pane.list") return { panes: snapshot.panes }; @@ -1530,9 +1551,11 @@ describe("basic Herdr 0.9 compatibility", () => { expect(lists).toBe(1); release(); await refreshing; - await Bun.sleep(10); + await publication; expect(lists).toBe(2); // No five-second metadata poll needed. } finally { + clearTimeout(publicationTimer); + unsubscribe(); release(); bridge.connection = originalConnection; __storeTesting.replaceState(previousState);