diff --git a/.vscode-test.js b/.vscode-test.js index e6a4904..86627eb 100644 --- a/.vscode-test.js +++ b/.vscode-test.js @@ -9,7 +9,7 @@ function resolveLocalVsCodeExecutable() { if (process.platform === "darwin") { const candidate = - "/Applications/Visual Studio Code.app/Contents/MacOS/Electron"; + "/Applications/Visual Studio Code.app/Contents/MacOS/Code"; return fs.existsSync(candidate) ? candidate : undefined; } @@ -39,10 +39,14 @@ function resolveLocalVsCodeExecutable() { const localVsCodeExecutable = resolveLocalVsCodeExecutable(); -module.exports = defineConfig({ - files: "out/test/e2e/**/*.e2e.js", +const packagedExtensionPath = process.env.ULW_E2E_EXTENSION_PATH; + +const shared = { version: "stable", workspaceFolder: "src/test/e2e/fixtures/workspace", + ...(packagedExtensionPath + ? { extensionDevelopmentPath: packagedExtensionPath } + : {}), ...(localVsCodeExecutable ? { useInstallation: { @@ -54,4 +58,23 @@ module.exports = defineConfig({ ui: "tdd", timeout: 20000, }, -}); +}; + +const herdrRequested = process.argv.some( + (argument, index, argv) => + argument === "--label=herdr" || + (argument === "--label" && argv[index + 1] === "herdr"), +); + +module.exports = defineConfig( + herdrRequested + ? { + ...shared, + label: "herdr", + files: "out/test/e2e/suite/herdr-attach.e2e.js", + } + : { + ...shared, + files: "out/test/e2e/suite/activation.e2e.js", + }, +); diff --git a/AGENTS.md b/AGENTS.md index 2f78b08..9ee4aff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,17 +2,28 @@ ## OVERVIEW -VS Code extension that runs one native shell terminal in the secondary sidebar or an editor-group tab. The extension host owns one `node-pty` process; each surface owns one xterm.js instance, with one active surface at a time. +VS Code extension that runs one native shell terminal in the secondary sidebar or an editor-group tab. With Herdr off, the host owns one persistent `node-pty` shell PTY on one active xterm surface. With Herdr on, the sidebar terminal is hidden and each attached agent gets its own editor-group webview plus one control-bridge child. ## SOURCE TOPOLOGY ```text src/ ├── extension.ts # activate/deactivate entry -├── types.ts # seven-message host/webview contract +├── types.ts # host/webview contract ├── core/ExtensionLifecycle.ts # creates and registers the terminal provider ├── providers/TerminalProvider.ts # sidebar webview and PTY message bridge -├── terminals/TerminalManager.ts # one native shell PTY lifecycle +├── terminals/ +│ ├── TerminalManager.ts # one native shell PTY lifecycle +│ ├── TerminalTransport.ts # transport seam for shell and Herdr bridge +│ └── LocalShellTransport.ts # local shell transport adapter +├── herdr/ +│ ├── HerdrCliClient.ts # CLI discovery, agent listing, workspace listing +│ ├── HerdrInvocationResolver.ts # shared Herdr command/env resolver +│ ├── HerdrControlTransport.ts # official Herdr control bridge child +│ ├── HerdrAttachController.ts # attach/detach lifecycle state machine +│ ├── HerdrExplorer.ts # Activity Bar Spaces/Agents trees +│ ├── types.ts # Herdr data types +│ └── errors.ts # Herdr typed errors ├── webview/ │ ├── main.ts # one xterm bootstrap │ ├── terminal/index.ts # xterm input/output/resize/config bridge @@ -25,30 +36,34 @@ src/ ## RUNTIME FLOW ```text -sidebar: contributed view `ulw` -> resolveWebviewView() -editor: ulw.defaultLocation=editor (default) | ulw.toggleEditorLocation -> createWebviewPanel - -> active surface posts `ready` - -> TerminalManager creates or resizes `sidebar-shell` - -> scrollback replay when switching to a fresh xterm - -> node-pty data/exit events post to surfaces - -> active surface input/resize events write/resize the PTY +Herdr off: + sidebar: contributed view `ulw` (when `ulw.sidebar.enabled`) -> resolveWebviewView() + editor: ulw.defaultLocation=editor (default) | ulw.toggleEditorLocation -> one shared webview panel + -> TerminalManager creates or resizes `sidebar-shell` +Herdr on: + sidebar terminal hidden (`when: config.ulw.sidebar.enabled && !config.ulw.herdr.enabled`) + Activity Bar Spaces/Agents -> agent click in this window opens/reveals an editor-group tab per agent + -> one control-bridge child per attached agent -> first-full-frame atomic cutover + -> detach/external closure closes that session without restoring a local shell ``` ## CONTRACT - Webview to host: `ready`, `input`, `resize`, `copy`, `imagePasted`. -- Host to webview: `output`, `exit`, `config`, `focus`, `clipboardImage`. -- No pane or session identifiers: exactly one terminal process exists. -- One active surface at a time: secondary-sidebar webview or one editor-group webview panel. -- `ulw.toggleEditorLocation` moves that single shell between surfaces. +- Host to webview: `output`, `exit`, `config`, `focus`, `clipboardImage`, `reset`, `sourceState`. +- Herdr off: one persistent shell PTY; one active surface (sidebar or one editor panel). +- Herdr on: no sidebar terminal; one editor-group tab and one Herdr bridge child per attached agent. +- Input and resize target the currently ACTIVE surface only. +- `ulw.toggleEditorLocation` moves the shared shell between surfaces only while Herdr is off. ## CONVENTIONS - Activate for the sidebar view, contributed commands, and startup (so `ulw.defaultLocation=editor` can open an editor tab). -- Keep contributed commands limited to location toggle and send-to-terminal helpers; no keybindings. +- Keep contributed commands limited to location toggle, send-to-terminal helpers, Herdr attach/detach, and the read-only Spaces/Agents explorer; no keybindings. - Keep `node-pty` as the only runtime dependency. xterm and the fit addon are build-time dependencies bundled into `webview.js`. -- Do not add multiplexer, session, AI, HTTP, dashboard, file-context, or multi-pane features. -- One editor panel max for the shared shell; never spawn a second PTY for editor mode. +- Herdr attach is allowed only through official CLI bridge children using builtin `child_process`; no raw socket client, no agent start/rename, no auto-start/reconnect/reattach. Herdr commands and the Activity Bar Spaces/Agents tree stay hidden until `ulw.herdr.enabled` is true. Then the tree lists live workspaces, opens each clicked agent in this window as its own editor-group tab, and opens another Space's folder in a new VS Code window. +- With Herdr off: one editor panel max for the shared shell; never spawn a second PTY for editor mode. +- With Herdr on: hide the ULW sidebar terminal; open each agent in its own editor-group tab; do not restore a local shell on detach. - Honor `ulw.defaultLocation` (`editor` default | `sidebar`); toggle always overrides the current surface. - Use project scripts for verification. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f3bc94..610187d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,60 @@ All notable changes to the "ULW" extension will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.12.16] - 2026-09-07 + +### Added + +- Herdr over SSH: the new `ulw.herdr.remoteTarget` setting reaches the Herdr server behind an SSH target while VS Code is connected to a remote window. ULW forwards the remote Herdr API and client sockets over SSH and routes Spaces/Agents listing and attach through them. Blank targets and local windows keep the previous invocation, Herdr setting changes apply to new invocations without reloading the window, and a configured `ulw.herdr.session` is ignored while forwarding. + +### Fixed + +- Switching between Herdr agent editor tabs now moves global input, resize, and detach to the focused agent's tab, and closing the focused tab falls back to the most recently focused remaining agent. + +## [1.12.15] - 2026-08-24 + +### Fixed + +- Herdr attach scroll: wheel/PageUp/PageDown now send Herdr `terminal.scroll` (host history) instead of CSI arrows as `terminal.input`. Arrows were reaching the agent prompt/input widget and scrolling that field, not the transcript. Follow each scroll with same-size `terminal.resize` so Herdr emits a scrolled `full:true` checkpoint frame. + +## [1.12.14] - 2026-08-24 + +### Fixed + +- Herdr attach scroll: capture wheel on `window` and post repeated CSI arrows as `terminal.input`. xterm was eating wheel as local scroll (viewport checkpoint has no history) or dropping it when render dimensions were missing (`consumeWheelEvent` returned 0). Also block xterm's local wheel handler via `customWheelEventHandler` while attached. + +## [1.12.13] - 2026-08-24 + +### Fixed + +- Stop capturing wheel/click in the webview. xterm already converts wheel to CSI arrows when scrollback is 0; intercepting the event blocked that path. Hide the xterm viewport overflow so an empty local buffer cannot swallow the gesture. + +## [1.12.12] - 2026-08-24 + +### Fixed + +- Wheel an attached agent TUI with CSI arrows when the app has no mouse tracking (typical pi/omo frames omit DECSET 1000/1006). Send SGR mouse only when xterm reports a mouse protocol, so clicks still work in mouse-aware apps. + +## [1.12.11] - 2026-08-24 + +### Fixed + +- Send Herdr-attached wheel and clicks as SGR mouse (`ESC[<64;col;rowM`, `ESC[<0;col;rowM/m`) instead of CSI arrows, so the agent TUI gets mouse input rather than keyboard scroll. + +## [1.12.10] - 2026-08-24 + +### Fixed + +- Mouse-wheel a Herdr-attached agent TUI by sending CSI arrows as PTY input. Host `terminal.scroll` only moves Herdr history and does not paint alt-screen apps; a follow-up same-size resize snapped the live viewport back. +- Capture wheel on the webview `window` and disable xterm scrollback while attached so the local empty buffer cannot swallow the gesture. + +## [1.12.9] - 2026-08-24 + +### Fixed + +- Scroll a Herdr-attached terminal by intercepting wheel/Page keys and forcing a checkpoint after `terminal.scroll`, which otherwise moves host history without painting a new frame. +- Keep the Spaces and Agents trees current by polling Herdr lists while Herdr mode is enabled. + ## [1.12.8] - 2026-08-06 ### Fixed diff --git a/README.md b/README.md index 5822c1b..c2490d1 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # ULW Sidebar Terminal -ULW is a small VS Code extension that runs one native shell terminal in the secondary sidebar. +ULW is a small VS Code extension that runs one native shell terminal in the secondary sidebar or an editor-group tab. -It intentionally has no terminal multiplexer, session manager, AI integration, HTTP service, dashboard, or multi-pane layout. Opening ULW creates one `node-pty` process and connects it to one xterm.js terminal in either the secondary sidebar or an editor-group tab. +With Herdr integration off, opening ULW creates one `node-pty` process connected to one xterm.js surface. With `ulw.herdr.enabled`, the ULW sidebar terminal is hidden; Spaces/Agents live in the Activity Bar, and each agent opens in its own editor-group tab. ## Use @@ -14,6 +14,16 @@ The shell starts in the first workspace folder. When no workspace is open, it st Run **ULW: Toggle Terminal Location** (`ulw.toggleEditorLocation`) to move the same shell between the secondary sidebar and an editor-group tab. Toggle again, or close the editor tab, to return to the sidebar. Switching surfaces reuses the same shell and replays recent scrollback into the newly focused xterm. +## Attach to a running Herdr agent + +Herdr integration is off until you set `ulw.herdr.enabled` (Settings: **ULW › Herdr: Enabled**). After that, use **ULW: Attach Herdr Session** (`ulw.attachHerdrSession`) to open a QuickPick of live Herdr agents, then choose the session to take over. The Activity Bar **Herdr** view lists the same live **Spaces** (`ulw.herdr.spaces`) and **Agents** (`ulw.herdr.agents`). Clicking an agent (`ulw.herdr.openAgent`) attaches the existing terminal when that agent's folder is this VS Code window, otherwise it opens the folder in a new window. Clicking a space (`ulw.herdr.openSpace`) uses the same folder check and never starts an agent. Refresh with `ulw.herdr.refreshExplorer`. + +- The picker and trees are populated from the Herdr CLI `agent list` / `workspace list` output, and ULW warns when takeover will replace other direct Herdr clients. +- Taking control is not auto-restored to those other clients; ULW owns the session only while attached. +- Any attach failure or external closure restores the local shell automatically. + +Use **ULW: Detach Herdr Session** (`ulw.detachHerdrSession`) to release ULW's controller and restore the local shell. A previously displaced direct Herdr client is not automatically restored. + The terminal automatically inherits the active VS Code terminal palette, including ANSI colors, cursor colors, selections, and live theme changes. Drag-selecting terminal text copies the finished selection to the system clipboard. ## Commands @@ -23,12 +33,18 @@ The terminal automatically inherits the active VS Code terminal palette, includi | `ulw.toggleEditorLocation` | Toggle the terminal between secondary sidebar and editor group | | `ulw.sendSelectionToTerminal` | Send the active editor selection to the terminal | | `ulw.sendFileToTerminal` | Send an explorer file path to the terminal | +| `ulw.attachHerdrSession` | Attach to a running Herdr agent | +| `ulw.detachHerdrSession` | Detach from a running Herdr agent | +| `ulw.herdr.openAgent` | Attach the selected Activity Bar agent | +| `ulw.herdr.openSpace` | Open that Space's folder in this window or a new window | +| `ulw.herdr.refreshExplorer` | Refresh Spaces and Agents lists | ## Settings | Setting | Default | Purpose | | --- | --- | --- | | `ulw.defaultLocation` | `editor` | Open in an editor-group tab or the secondary sidebar | +| `ulw.sidebar.enabled` | `true` | Show the ULW label in the secondary sidebar. Off hides ULW from the sidebar completely | | `ulw.fontSize` | `14` | Terminal font size | | `ulw.fontFamily` | Nerd Font and monospace fallbacks | Terminal font family | | `ulw.cursorBlink` | `true` | Blink the cursor | @@ -36,6 +52,10 @@ The terminal automatically inherits the active VS Code terminal palette, includi | `ulw.scrollback` | `10000` | Scrollback line count | | `ulw.shellPath` | empty | Shell executable; empty uses the VS Code or system default | | `ulw.shellArgs` | `[]` | Arguments passed to the shell | +| `ulw.herdr.enabled` | `false` | Turn on Herdr Spaces/Agents and attach. Off until you enable it | +| `ulw.herdr.executablePath` | `herdr` | Herdr executable path; GUI-launched VS Code may need an explicit absolute path if PATH does not include herdr | +| `ulw.herdr.socketPath` | empty | Optional Herdr socket path; ignored when a named session is configured | +| `ulw.herdr.session` | empty | Optional named Herdr session; takes precedence over the socket path | ## Development diff --git a/docs/herdr-bridge-protocol.md b/docs/herdr-bridge-protocol.md new file mode 100644 index 0000000..99ed045 --- /dev/null +++ b/docs/herdr-bridge-protocol.md @@ -0,0 +1,79 @@ +# Herdr 0.8.2 control-bridge protocol + +This document pins the live behavior of `/Users/ilseoblee/.local/bin/herdr` version `0.8.2` on macOS. It is generated from an isolated workspace created with: + +```text +herdr workspace create --cwd --label ulw-probe --no-focus +``` + +No pre-existing pane was controlled. The reproducible probe is: + +```text +node script/qa/probe-herdr-control.mjs --herdr /Users/ilseoblee/.local/bin/herdr --evidence .omo/evidence/task-1-herdr-agent-attach +``` + +Every child process has a 12,000 ms hard kill timeout; expected records have a 5,000 ms timeout. Evidence paths are `.omo/evidence/task-1-herdr-agent-attach/{raw.ndjson,probe.log,summary.json,cleanup.json,protocol.md}`. `raw.ndjson` is the authoritative raw transcript; line numbers below refer to that file. The captured scratch identifiers are disposable evidence values, not API constants. + +## 1-12 answers + +| # | Question | Locked 0.8.2 answer | Exact invocation/command | Evidence | +|---:|---|---|---|---| +| 1 | First controller record | Yes: the first record is a complete checkpoint, `type:"terminal.frame"`, `full:true`, `seq:1`, `width:52`, `height:12`, `encoding:"ansi"`. Exact JSON is RAW line 1. | `herdr terminal session control --takeover --cols 52 --rows 12` | `raw.ndjson:1` | +| 2 | Every frame field and bytes encoding | Exactly seven fields were observed and asserted: `type`, `bytes`, `encoding`, `full`, `width`, `height`, `seq`. `bytes` is standard base64; decoding yields ANSI/VT terminal bytes. `encoding` is the literal string `ansi`. `full:true` replaces the terminal checkpoint; `full:false` is a following delta. Sequence numbers are per bridge connection and start at 1. | Decode with `Buffer.from(record.bytes, "base64")`; reject missing/extra fields. | `raw.ndjson:1-6`; `summary.json.probes.frame_fields` | +| 3 | UTF-8 split across records | The CJK command was sent through the base64 input form. The captured output placed `가나다` in one delta record; no frame boundary split an individual UTF-8 code point in this run. Consumers must still stream-decode decoded frame bytes because base64 frame boundaries are not a UTF-8 framing guarantee. | `{"type":"terminal.input","bytes":"cHJpbnRmICfqsIDrgpjri6RcbicK"}` | `probe.log` input entry; `raw.ndjson:3`; `summary.json.probes.utf8_split` | +| 4 | Stdin input and marker round-trip | Text form is `{"type":"terminal.input","text":"printf 'ULW_PROBE_OK\\n'\n"}`. Base64 byte form also works: `{"type":"terminal.input","bytes":""}`. Sending both `text` and `bytes` is rejected by the bridge with `terminal.input accepts text or bytes, not both`. Sending neither field is silently ignored: no error and no frame. ULW's transport **MUST validate exactly one field client-side before writing**. The marker round-tripped in a delta frame. | Write one NDJSON object plus `\n` to control stdin; negative forms are sent before CJK output. | `probe.log` entries `primary stdin` for valid/both/neither forms; output `raw.ndjson:2-3`; `summary.json.probes.input.negative_validation` | +| 5 | Changed and unchanged resize | Shape: `{"type":"terminal.resize","cols":61,"rows":14}`. Changed size emitted a `full:true` 61x14 checkpoint. Repeating the same size also emitted a second `full:true` 61x14 checkpoint. Therefore every accepted resize should be treated as capable of forcing replacement, even when dimensions are unchanged. | Send the exact resize object twice. | `raw.ndjson:4-5`; `summary.json.probes.resize` | +| 6 | Wheel and PageUp/PageDown scroll | Shape has `type`, `direction`, `lines`, `source`, `column`, `row`, plus numeric bitmask `modifiers`. Wheel: `{"type":"terminal.scroll","direction":"up","lines":3,"source":"wheel","column":4,"row":4,"modifiers":0}`. PageUp/PageDown use `source:"page_key"`, directions `up`/`down`, and page-sized `lines` (14 here). **Informational-only limitation:** 0.8.2 provides no explicit scroll ACK. Wheel happened to be followed by frames, while PageUp/PageDown emitted no command-correlated record; all three produced no rejection stderr and the bridge remained writable. The probe therefore validates accepted command shape, not semantic scrolling for the page-key cases. | Send each object separately, observe one quiet window, then prove continued writability with `ULW_SCROLL_OK`. | `probe.log` entries `primary stdin` for each scroll; wheel-following frames `raw.ndjson:6-8`; marker `raw.ndjson:9`; `summary.json.probes.scroll.observations` | +| 7 | Release closure and ownership | `{"type":"terminal.release"}` produces `{"reason":"detached","type":"terminal.closed"}` and the bridge exits 0. `herdr pane read` still exits 0 afterward, and a successor controller can attach. | Send release, await closure, then run `herdr pane read --lines 20 --format text`. | `raw.ndjson:10`; `summary.json.probes.release` | +| 8 | EOF, SIGTERM, SIGKILL | stdin EOF emits `terminal.closed` reason `detached` and exits 0 (`raw:12`). SIGTERM and SIGKILL terminate locally without any closure record. Immediate successor takeover obtained a first full frame after both signals (`raw:16`, `raw:19`), proving server ownership was released on this host. Do not rely on a closure record after signals. | Close stdin; `kill(SIGTERM)`; `kill(SIGKILL)`; after each, spawn a successor controller. | `raw.ndjson:11-21`; `summary.json.probes.{eof,sigterm,sigkill}` | +| 9 | Displaced controller | A second `control --takeover` closes the first with exact record `{"reason":"terminal attach taken over","type":"terminal.closed"}`. The probe asserts exact string equality. The displaced process exits 0 and the second receives a full frame. | Spawn two control bridges against only the scratch terminal. | closure `raw.ndjson:22`; successor `raw.ndjson:23`; `summary.json.probes.displacement` | +| 10 | Visible grid while observe reads | `terminal session observe --cols 30 --rows 8` receives a 30x8 full checkpoint. A controller resize to 47x11 emits a `full:true` controller frame at exactly 47x11. Every observer frame captured afterward remained exactly 30x8; the probe asserts both dimension pairs and requires at least one post-resize observer frame. Thus each client renders at its requested grid. | Controller 52x12, observer 30x8, then controller `terminal.resize` to 47x11. | observer `raw.ndjson:25,27`; controller resize `raw.ndjson:26`; `summary.json.probes.observe_grid` | +| 11 | Named session and unsupported errors | Global placement is `/path/herdr --session terminal session control ...`; `--session` must precede the subcommands. `--session ulw-probe-nonexistent --version` still prints `herdr 0.8.2`. Control against that absent named session exits 1 with `failed to connect to server`, advice to start `herdr server`, and the resolved `.../sessions//herdr-client.sock`. A bogus target on the live session exits 0 with `{"reason":"terminal session control failed: terminal target ulw-probe-bogus not found","type":"terminal.closed"}`. ULW's minimum remains 0.8.0; the probe hard-pins installed 0.8.2. | See `summary.json.probes.named_session.control_argv`. | named-version, absent-session stderr, and bogus-target JSON in `probe.log` entries `named-session version invocation`, `named-session missing-server invocation`, and `live-session bogus-target invocation`; `summary.json.probes.named_session` | +| 12 | Long-run retention | After emitting 240 numbered lines plus `ULW_RET_DONE`, `pane read --lines 300` contained all 240 numbered output lines, including `ULW_RET_001` and `ULW_RET_240`, plus command/done matches. These facts are asserted. A fresh 47x11 observer received only the visible tail as its initial full frame, not line 1. Replay therefore needs latest-full-plus-following-deltas. The independent 8 MiB checkpoint overflow was **not exercised**; the bound remains a product-side requirement. | Emit 240 lines, assert pane-read count/first/last/done, then start a fresh observer. | emission `raw.ndjson:30`; fresh full `raw.ndjson:32`; `summary.json.probes.retention` | + +## Exact record and command schemas + +Controller stdout is NDJSON: + +```json +{"bytes":"","encoding":"ansi","full":true,"height":12,"seq":1,"type":"terminal.frame","width":52} +{"reason":"detached","type":"terminal.closed"} +``` + +Controller stdin is NDJSON, one object per line: + +```json +{"type":"terminal.input","text":"printf 'ULW_PROBE_OK\\n'\n"} +{"type":"terminal.input","bytes":"cHJpbnRmICfqsIDrgpjri6RcbicK"} +{"type":"terminal.input","text":"printf 'ULW_INVALID_BOTH_TEXT\\n'\n","bytes":"cHJpbnRmICdVTFdfSU5WQUxJRF9CT1RIX0JZVEVTXG4nCg=="} +{"type":"terminal.input"} +{"type":"terminal.resize","cols":61,"rows":14} +{"type":"terminal.scroll","direction":"up","lines":3,"source":"wheel","column":4,"row":4,"modifiers":0} +{"type":"terminal.scroll","direction":"up","lines":14,"source":"page_key","column":0,"row":0,"modifiers":0} +{"type":"terminal.scroll","direction":"down","lines":14,"source":"page_key","column":0,"row":0,"modifiers":0} +{"type":"terminal.release"} +``` + +For `terminal.input`, sending both `text` and `bytes` is rejected by the bridge with stderr `terminal.input accepts text or bytes, not both`. Sending neither field is silently ignored: no error and no frame. ULW's transport **MUST validate exactly one field client-side before writing**. The scroll command has no explicit protocol acknowledgment; accepted shape is inferred only from no rejection stderr and continued bridge writability, and semantic PageUp/PageDown behavior remains informational rather than locked by this probe. Production code must validate exact command shapes rather than trust process exit status. + +## Replay and lifecycle rules for ULW + +1. Do not cut over until the first valid `terminal.frame` with `full:true`. +2. Base64-decode `bytes`; feed decoded ANSI bytes through a streaming decoder/terminal parser. +3. Replace replay state on every `full:true`, including unchanged-dimension resize checkpoints. +4. Append `full:false` deltas after the latest full frame, capped at 8 MiB total. +5. Treat `terminal.closed` `detached` as release; map `terminal attach taken over` to takeover; preserve other reason strings as protocol diagnostics. +6. On EOF/SIGTERM/SIGKILL, process exit may be the only closure signal. +7. A fresh observer/controller full frame is viewport-sized, not complete scrollback. `pane read` is richer but is not part of the streaming bridge. + +## Deviations from plan assumptions D2/D5/D6 + +- **D2 amended:** 0.8.2 emits typed `terminal.frame` and `terminal.closed`, not the 0.7.5-era bare `{"bytes":"..."}` shape. Writable input is supported directly through `terminal.input`; no `pane.send_text` fallback is required. Both `text` and base64 `bytes` forms exist. Sending both is rejected with `terminal.input accepts text or bytes, not both`; sending neither is silently ignored with no error and no frame. ULW must validate exactly one field client-side. +- **D5 confirmed/amended:** the first frame is `full:true`; live `terminal.resize` exists. Both changed-size and unchanged-size resize produced a new `full:true` checkpoint, so resize does not require bridge respawn. +- **D6 amended:** scrolling is typed `terminal.scroll`, with `source:"wheel"` or `source:"page_key"`, positive `lines`, direction, coordinates, and numeric modifier bitmask. Decoded frames contain terminal mode escapes (for example cursor and synchronized-update modes), so code must not assume an escape-free or mouse-sequence-free stream; selection behavior requires real-surface QA. + +## Raw transcript and cleanup + +The full unedited NDJSON transcript is committed as evidence at `.omo/evidence/task-1-herdr-agent-attach/raw.ndjson`. Cleanup closed the exact returned workspace ID, verified it absent from `herdr workspace list`, verified no `ulw-probe` label remained, removed the temporary directory, and left no tracked probe child alive. See `cleanup.json` for the receipt. + +This shell probe does not establish GUI-launch PATH or socket inheritance; that belongs to the extension-host integration test. diff --git a/package.json b/package.json index d610b42..017f45d 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "opencode-sidebar-tui", "displayName": "ULW Sidebar Terminal", "description": "A fast native shell terminal in the VS Code secondary sidebar.", - "version": "1.12.8", + "version": "1.12.16", "publisher": "islee23520", "icon": "icon.png", "engines": { @@ -25,11 +25,19 @@ "main": "./dist/extension.js", "contributes": { "viewsContainers": { + "activitybar": [ + { + "id": "ulwHerdr", + "title": "Herdr", + "icon": "resources/ulwcode-sidebar.svg" + } + ], "secondarySidebar": [ { "id": "ulwContainer", "title": "ULW", - "icon": "resources/ulwcode-sidebar.svg" + "icon": "resources/ulwcode-sidebar.svg", + "when": "config.ulw.sidebar.enabled && !config.ulw.herdr.enabled" } ] }, @@ -39,7 +47,20 @@ "id": "ulw", "name": "Terminal", "type": "webview", - "icon": "resources/ulwcode-sidebar.svg" + "icon": "resources/ulwcode-sidebar.svg", + "when": "config.ulw.sidebar.enabled && !config.ulw.herdr.enabled" + } + ], + "ulwHerdr": [ + { + "id": "ulw.herdr.spaces", + "name": "Spaces", + "when": "config.ulw.herdr.enabled" + }, + { + "id": "ulw.herdr.agents", + "name": "Agents", + "when": "config.ulw.herdr.enabled" } ] }, @@ -60,6 +81,32 @@ "command": "ulw.sendFileToTerminal", "title": "ULW: Send File to Terminal", "category": "ULW" + }, + { + "command": "ulw.attachHerdrSession", + "title": "ULW: Attach Herdr Session", + "category": "ULW" + }, + { + "command": "ulw.detachHerdrSession", + "title": "ULW: Detach Herdr Session", + "category": "ULW" + }, + { + "command": "ulw.herdr.openAgent", + "title": "ULW: Attach Herdr Agent", + "category": "ULW" + }, + { + "command": "ulw.herdr.openSpace", + "title": "ULW: Reveal Herdr Space", + "category": "ULW" + }, + { + "command": "ulw.herdr.refreshExplorer", + "title": "ULW: Refresh Herdr Explorer", + "category": "ULW", + "icon": "$(refresh)" } ], "menus": { @@ -80,6 +127,11 @@ "command": "ulw.toggleEditorLocation", "when": "view == ulw", "group": "navigation" + }, + { + "command": "ulw.herdr.refreshExplorer", + "when": "view == ulw.herdr.spaces || view == ulw.herdr.agents", + "group": "navigation" } ], "editor/title": [ @@ -93,6 +145,11 @@ "configuration": { "title": "ULW Terminal", "properties": { + "ulw.sidebar.enabled": { + "type": "boolean", + "default": true, + "description": "Show the ULW label in the secondary sidebar. Turn this off to hide ULW from the sidebar completely; the terminal still opens as an editor tab." + }, "ulw.defaultLocation": { "type": "string", "enum": [ @@ -167,6 +224,35 @@ "default": [], "scope": "machine-overridable", "description": "Arguments passed to the shell executable." + }, + "ulw.herdr.enabled": { + "type": "boolean", + "default": false, + "description": "Enable Herdr Spaces/Agents and attach/detach. Off by default; ULW is only a terminal until this is turned on." + }, + "ulw.herdr.executablePath": { + "type": "string", + "default": "herdr", + "scope": "machine-overridable", + "description": "Herdr executable path. GUI-launched VS Code may not inherit your shell PATH, so configure an absolute path when herdr cannot be found." + }, + "ulw.herdr.socketPath": { + "type": "string", + "default": "", + "scope": "machine-overridable", + "description": "Optional Herdr socket path. Ignored when a named Herdr session is configured." + }, + "ulw.herdr.session": { + "type": "string", + "default": "", + "scope": "machine-overridable", + "description": "Optional named Herdr session. When set, it takes precedence over the socket path." + }, + "ulw.herdr.remoteTarget": { + "type": "string", + "default": "", + "scope": "machine-overridable", + "description": "SSH target (e.g. user@host) whose Herdr server ULW should reach while VS Code is connected to a remote window. ULW forwards the remote API and client sockets over SSH and routes Spaces/Agents listing and attach through them. Targets the default Herdr session; a configured ulw.herdr.session is ignored while forwarding. Ignored in local windows." } } } @@ -182,9 +268,11 @@ "test": "vitest run", "pretest:e2e": "node -e \"require('fs').rmSync('out', { recursive: true, force: true })\" && npm run compile && npm run compile:e2e", "test:e2e": "vscode-test", + "test:e2e:herdr": "npm run pretest:e2e && vscode-test --label herdr", "test:all": "npm run test && npm run test:e2e", "test:watch": "vitest", "test:coverage": "vitest run --coverage", + "package:vsix": "npm run package && npx @vscode/vsce package --allow-star-activation && node script/qa/check-vsix-node-pty.mjs", "build-and-install": "npx @vscode/vsce package -o build/extension.vsix && code --install-extension build/extension.vsix --force" }, "devDependencies": { @@ -216,9 +304,19 @@ }, "activationEvents": [ "onView:ulw", + "onView:ulw.herdr.spaces", + "onView:ulw.herdr.agents", "onCommand:ulw.toggleEditorLocation", "onCommand:ulw.sendSelectionToTerminal", "onCommand:ulw.sendFileToTerminal", + "onCommand:ulw.attachHerdrSession", + "onCommand:ulw.detachHerdrSession", + "onCommand:ulw.herdr.openAgent", + "onCommand:ulw.herdr.openSpace", + "onCommand:ulw.herdr.refreshExplorer", "onStartupFinished" - ] + ], + "allowScripts": { + "node-pty@1.2.0-beta.11": true + } } diff --git a/script/qa/check-herdr-doc-contract.mjs b/script/qa/check-herdr-doc-contract.mjs new file mode 100644 index 0000000..d6a8265 --- /dev/null +++ b/script/qa/check-herdr-doc-contract.mjs @@ -0,0 +1,244 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +const ROOT = process.cwd(); +const PACKAGE_PATH = path.join(ROOT, 'package.json'); +const README_PATH = path.join(ROOT, 'README.md'); +const AGENTS_PATH = path.join(ROOT, 'AGENTS.md'); + +function readText(filePath) { + return fs.readFileSync(filePath, 'utf8'); +} + +function readJson(filePath) { + return JSON.parse(readText(filePath)); +} + +function getArgs(argv) { + const args = { selfTest: false, evidencePath: null }; + for (let i = 2; i < argv.length; i += 1) { + const token = argv[i]; + if (token === '--self-test') { + args.selfTest = true; + continue; + } + if (token === '--evidence') { + args.evidencePath = argv[i + 1] ?? null; + i += 1; + continue; + } + if (token.startsWith('--evidence=')) { + args.evidencePath = token.slice('--evidence='.length) || null; + continue; + } + } + return args; +} + +function isHerdrCommand(key) { + return ( + key === 'ulw.attachHerdrSession' || + key === 'ulw.detachHerdrSession' || + key === 'ulw.herdr.openAgent' || + key === 'ulw.herdr.openSpace' || + key === 'ulw.herdr.refreshExplorer' + ); +} + +function isHerdrSetting(key) { + return ( + key === 'ulw.herdr.enabled' || + key === 'ulw.herdr.executablePath' || + key === 'ulw.herdr.socketPath' || + key === 'ulw.herdr.session' + ); +} + +function collectManifestContracts(pkg) { + const commands = (pkg?.contributes?.commands ?? []) + .map((entry) => entry?.command) + .filter((value) => typeof value === 'string' && isHerdrCommand(value)); + const settings = Object.keys(pkg?.contributes?.configuration?.properties ?? {}) + .filter((key) => isHerdrSetting(key)); + const keys = uniqueSorted([...commands, ...settings]); + return { commands: uniqueSorted(commands), settings: uniqueSorted(settings), keys }; +} + +function stripHtmlComments(markdown) { + return markdown.replace(//g, ''); +} + +function extractTableRows(markdown) { + return markdown + .split(/\r?\n/) + .filter((line) => line.includes('|')) + .map((line) => line.trim()) + .filter((line) => line.startsWith('|') && line.endsWith('|')); +} + +function collectDocumentedIds(markdown) { + const cleaned = stripHtmlComments(markdown); + const ids = new Set(); + const commandRe = /`(ulw\.[a-zA-Z0-9.]+)`/g; + for (const row of extractTableRows(cleaned)) { + const cells = row.split('|').map((cell) => cell.trim()); + const first = cells[1] ?? ''; + if (/^`ulw\.[^`]+`$/.test(first)) { + ids.add(first.slice(1, -1)); + } + } + let match; + while ((match = commandRe.exec(cleaned))) { + ids.add(match[1]); + } + return [...ids].filter((id) => isHerdrCommand(id) || isHerdrSetting(id)); +} + +function uniqueSorted(values) { + return [...new Set(values)].sort(); +} + +function diffLists(expected, actual) { + const missing = expected.filter((value) => !actual.includes(value)); + const extra = actual.filter((value) => !expected.includes(value)); + return { missing, extra }; +} + +function buildRows(manifestKeys, docKeys) { + const keys = uniqueSorted([...manifestKeys, ...docKeys]); + return keys.map((key) => ({ + key, + manifest_present: manifestKeys.includes(key), + docs_present: docKeys.includes(key), + manifest_to_docs: docKeys.includes(key), + docs_to_manifest: manifestKeys.includes(key), + matched: manifestKeys.includes(key) && docKeys.includes(key), + })); +} + +function buildReport(pkg, readme, agents) { + const manifest = collectManifestContracts(pkg); + const docs = uniqueSorted([...collectDocumentedIds(readme), ...collectDocumentedIds(agents)]); + const rows = buildRows(manifest.keys, docs); + const missing = rows.filter((row) => row.manifest_present && !row.docs_present).map((row) => row.key); + const extra = rows.filter((row) => row.docs_present && !row.manifest_present).map((row) => row.key); + const ok = missing.length === 0 && extra.length === 0; + return { + ok, + manifest: { commands: manifest.commands, settings: manifest.settings, keys: manifest.keys }, + docs: { keys: docs }, + rows, + diffs: { missing, extra }, + }; +} + +function formatReport(report) { + const lines = []; + lines.push('HERDR DOC CONTRACT'); + lines.push('| key | manifest->docs | docs->manifest | matched |'); + lines.push('| --- | --- | --- | --- |'); + for (const row of report.rows) { + lines.push( + `| ${row.key} | ${row.manifest_to_docs ? 'true' : 'false'} | ${row.docs_to_manifest ? 'true' : 'false'} | ${row.matched ? 'true' : 'false'} |`, + ); + } + if (!report.ok) { + if (report.diffs.missing.length) { + lines.push(`missing manifest docs: ${report.diffs.missing.join(', ')}`); + } + if (report.diffs.extra.length) { + lines.push(`extra documented ids: ${report.diffs.extra.join(', ')}`); + } + } + return lines.join('\n'); +} + +function writeEvidence(evidencePath, payload) { + if (!evidencePath) return; + fs.mkdirSync(path.dirname(evidencePath), { recursive: true }); + fs.writeFileSync(evidencePath, `${JSON.stringify(payload, null, 2)}\n`); +} + +function runCheck({ pkg, readme, agents }) { + const report = buildReport(pkg, readme, agents); + const output = formatReport(report); + console.log(output); + return report; +} + +function selfTest() { + const pkg = readJson(PACKAGE_PATH); + const readme = readText(README_PATH); + const agents = readText(AGENTS_PATH); + const original = buildReport(pkg, readme, agents); + + const mutatedExtra = buildReport( + pkg, + `${readme}\n| \`ulw.herdFake\` | Adversarial fake command |`, + agents, + ); + const mutatedCommentedOut = buildReport( + pkg, + readme.replace( + '| `ulw.herdr.socketPath` | empty | Optional Herdr socket path; ignored when a named session is configured |', + '', + ), + agents, + ); + const mutatedMissingManifest = buildReport( + { + ...pkg, + contributes: { + ...pkg.contributes, + commands: pkg.contributes.commands.filter((entry) => entry.command !== 'ulw.attachHerdrSession'), + }, + }, + readme, + agents, + ); + + const rejected = !mutatedExtra.ok && !mutatedCommentedOut.ok && !mutatedMissingManifest.ok; + const passed = original.ok && rejected; + const payload = { + selfTest: true, + originalOk: original.ok, + mutatedRejected: rejected, + passed, + original, + mutations: { + fakeDocumentedExtraRejected: !mutatedExtra.ok, + commentedOutRowRejected: !mutatedCommentedOut.ok, + missingManifestKeyRejected: !mutatedMissingManifest.ok, + }, + }; + console.log(formatReport(original)); + console.log(`self-test fake extra rejected: ${mutatedExtra.ok ? 'no' : 'yes'}`); + console.log(`self-test commented-out row rejected: ${mutatedCommentedOut.ok ? 'no' : 'yes'}`); + console.log(`self-test missing manifest key rejected: ${mutatedMissingManifest.ok ? 'no' : 'yes'}`); + return payload; +} + +const args = getArgs(process.argv); +const pkg = readJson(PACKAGE_PATH); +const readme = readText(README_PATH); +const agents = readText(AGENTS_PATH); + +if (args.selfTest) { + const payload = selfTest(); + if (args.evidencePath) writeEvidence(args.evidencePath, payload); + process.exit(payload.passed ? 0 : 1); +} + +const report = runCheck({ pkg, readme, agents }); +const payload = { + selfTest: false, + manifest_docs_match: report.ok, + manifest: report.manifest, + docs: report.docs, + rows: report.rows, + diffs: report.diffs, +}; +if (args.evidencePath) writeEvidence(args.evidencePath, payload); +process.exit(report.ok ? 0 : 1); diff --git a/script/qa/check-vsix-node-pty.mjs b/script/qa/check-vsix-node-pty.mjs new file mode 100644 index 0000000..ea69395 --- /dev/null +++ b/script/qa/check-vsix-node-pty.mjs @@ -0,0 +1,23 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +const root = process.cwd(); +const version = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).version; +const vsixPath = path.join(root, `opencode-sidebar-tui-${version}.vsix`); +if (!existsSync(vsixPath)) { + process.stderr.write(`missing ${vsixPath}\n`); + process.exit(1); +} +const listing = execFileSync("unzip", ["-Z1", vsixPath], { encoding: "utf8" }); +if (!/extension\/node_modules\/node-pty\//.test(listing)) { + process.stderr.write("VSIX is missing node-pty; never package with --no-dependencies\n"); + process.exit(1); +} +if (!/node-pty\/(?:prebuilds|build|lib)\//.test(listing)) { + process.stderr.write("VSIX node-pty is missing native/prebuild files\n"); + process.exit(1); +} +process.stdout.write(`ok: ${vsixPath} contains node-pty\n`); diff --git a/script/qa/probe-herdr-control.mjs b/script/qa/probe-herdr-control.mjs new file mode 100644 index 0000000..1f210de --- /dev/null +++ b/script/qa/probe-herdr-control.mjs @@ -0,0 +1,619 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, resolve } from "node:path"; +import process from "node:process"; +import { StringDecoder } from "node:string_decoder"; + +const CHILD_TIMEOUT_MS = 12_000; +const RECORD_TIMEOUT_MS = 5_000; +const QUIET_WINDOW_MS = 350; +const DEFAULT_COLS = 52; +const DEFAULT_ROWS = 12; + +function parseArgs(argv) { + const options = { herdr: "herdr", evidence: ".omo/evidence/task-1-herdr-agent-attach" }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--expect-connect-failure") options.expectConnectFailure = true; + else if (argument === "--herdr") options.herdr = argv[++index]; + else if (argument === "--socket") options.socket = argv[++index]; + else if (argument === "--evidence") options.evidence = argv[++index]; + else throw new Error(`unknown argument: ${argument}`); + } + options.herdr = resolve(options.herdr.replace(/^~(?=\/)/, process.env.HOME ?? "")); + options.evidence = resolve(options.evidence); + return options; +} + +function assert(condition, message) { + if (!condition) throw new Error(`assertion failed: ${message}`); +} + +function jsonLine(value) { + return `${JSON.stringify(value)}\n`; +} + +class Log { + constructor() { + this.lines = []; + } + add(message, detail) { + const suffix = detail === undefined ? "" : ` ${typeof detail === "string" ? detail : JSON.stringify(detail)}`; + this.lines.push(`[${new Date().toISOString()}] ${message}${suffix}`); + } + text() { + return `${this.lines.join("\n")}\n`; + } +} + +async function runBounded(command, args, { env, input, timeoutMs = CHILD_TIMEOUT_MS, allowNonzero = false } = {}) { + return await new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { env, stdio: ["pipe", "pipe", "pipe"] }); + const stdout = []; + const stderr = []; + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, timeoutMs); + child.stdout.on("data", (chunk) => stdout.push(chunk)); + child.stderr.on("data", (chunk) => stderr.push(chunk)); + child.on("error", (error) => { + clearTimeout(timer); + reject(error); + }); + child.on("close", (code, signal) => { + clearTimeout(timer); + const result = { + command: [command, ...args], code, signal, timedOut, + stdout: Buffer.concat(stdout).toString("utf8"), + stderr: Buffer.concat(stderr).toString("utf8"), + }; + if (timedOut) reject(new Error(`command timed out after ${timeoutMs}ms: ${result.command.join(" ")}`)); + else if (!allowNonzero && code !== 0) reject(new Error(`command failed (${code}): ${result.command.join(" ")}\n${result.stderr}`)); + else resolvePromise(result); + }); + if (input !== undefined) child.stdin.end(input); + else child.stdin.end(); + }); +} + +class NdjsonChild { + constructor(command, args, rawLines, log, { env, name }) { + this.name = name; + this.rawLines = rawLines; + this.log = log; + this.records = []; + this.stderr = ""; + this.waiters = new Set(); + this.decoder = new StringDecoder("utf8"); + this.buffer = ""; + this.closed = false; + this.child = spawn(command, args, { env, stdio: ["pipe", "pipe", "pipe"] }); + this.pid = this.child.pid; + this.hardTimer = setTimeout(() => this.child.kill("SIGKILL"), CHILD_TIMEOUT_MS); + this.child.stdout.on("data", (chunk) => this.consume(this.decoder.write(chunk))); + this.child.stdout.on("end", () => this.consume(this.decoder.end())); + this.child.stderr.on("data", (chunk) => { + this.stderr += chunk.toString("utf8"); + this.notify(); + }); + this.exitPromise = new Promise((resolveExit, reject) => { + this.child.on("error", reject); + this.child.on("close", (code, signal) => { + clearTimeout(this.hardTimer); + this.closed = true; + this.consume("\n"); + this.notify(); + resolveExit({ code, signal, stderr: this.stderr }); + }); + }); + log.add(`spawn ${name}; hard_timeout_ms=${CHILD_TIMEOUT_MS}`, { pid: this.pid, argv: [command, ...args] }); + } + consume(text) { + this.buffer += text; + for (;;) { + const newline = this.buffer.indexOf("\n"); + if (newline < 0) break; + const raw = this.buffer.slice(0, newline).replace(/\r$/, ""); + this.buffer = this.buffer.slice(newline + 1); + if (!raw) continue; + let record; + try { record = JSON.parse(raw); } + catch (error) { throw new Error(`${this.name} emitted invalid NDJSON: ${raw}\n${error}`); } + const rawLine = this.rawLines.push(raw); + this.records.push({ record, raw, rawLine }); + this.log.add(`${this.name} raw.ndjson:${rawLine}`, record); + this.notify(); + } + } + notify() { + for (const waiter of [...this.waiters]) waiter(); + } + async waitFor(predicate, description, timeoutMs = RECORD_TIMEOUT_MS, startIndex = 0) { + const existing = this.records.slice(startIndex).find(({ record }) => predicate(record)); + if (existing) return existing; + return await new Promise((resolveWait, reject) => { + const timer = setTimeout(() => { + this.waiters.delete(check); + reject(new Error(`${this.name}: timed out after ${timeoutMs}ms waiting for ${description}; stderr=${this.stderr}`)); + }, timeoutMs); + const check = () => { + const found = this.records.slice(startIndex).find(({ record }) => predicate(record)); + if (found) { + clearTimeout(timer); + this.waiters.delete(check); + resolveWait(found); + } else if (this.closed) { + clearTimeout(timer); + this.waiters.delete(check); + reject(new Error(`${this.name}: exited before ${description}; stderr=${this.stderr}`)); + } + }; + this.waiters.add(check); + check(); + }); + } + async waitForStderr(predicate, description, timeoutMs = RECORD_TIMEOUT_MS) { + if (predicate(this.stderr)) return this.stderr; + return await new Promise((resolveWait, reject) => { + const timer = setTimeout(() => { + this.waiters.delete(check); + reject(new Error(`${this.name}: timed out after ${timeoutMs}ms waiting for stderr ${description}; stderr=${this.stderr}`)); + }, timeoutMs); + const check = () => { + if (predicate(this.stderr)) { + clearTimeout(timer); + this.waiters.delete(check); + resolveWait(this.stderr); + } else if (this.closed) { + clearTimeout(timer); + this.waiters.delete(check); + reject(new Error(`${this.name}: exited before stderr ${description}; stderr=${this.stderr}`)); + } + }; + this.waiters.add(check); + check(); + }); + } + send(record) { + assert(!this.closed, `${this.name} must be alive before sending ${record.type}`); + this.log.add(`${this.name} stdin`, record); + this.child.stdin.write(jsonLine(record)); + } + endStdin() { + this.log.add(`${this.name} stdin EOF`); + this.child.stdin.end(); + } + kill(signal) { + this.log.add(`${this.name} kill`, signal); + this.child.kill(signal); + } + async quietRecordCount(windowMs = QUIET_WINDOW_MS) { + const before = this.records.length; + await new Promise((resolveQuiet) => setTimeout(resolveQuiet, windowMs)); + return this.records.length - before; + } + async exit() { + return await this.exitPromise; + } +} + +function frameText(frame) { + assert(frame.type === "terminal.frame", "record must be terminal.frame"); + assert(frame.encoding === "ansi", "terminal.frame encoding must be ansi"); + return Buffer.from(frame.bytes, "base64").toString("utf8"); +} + +function validateFrame(frame) { + const keys = Object.keys(frame).sort(); + assert(JSON.stringify(keys) === JSON.stringify(["bytes", "encoding", "full", "height", "seq", "type", "width"]), `terminal.frame fields changed: ${keys.join(",")}`); + assert(typeof frame.bytes === "string" && Buffer.from(frame.bytes, "base64").length > 0, "terminal.frame.bytes must be nonempty base64"); + assert(frame.encoding === "ansi", "terminal.frame.encoding must be ansi"); + assert(typeof frame.full === "boolean", "terminal.frame.full must be boolean"); + assert(Number.isInteger(frame.width) && Number.isInteger(frame.height), "terminal.frame dimensions must be integers"); + assert(Number.isInteger(frame.seq) && frame.seq > 0, "terminal.frame.seq must be a positive integer"); +} + +async function waitForText(client, marker, startIndex = 0) { + return await client.waitFor( + (record) => record.type === "terminal.frame" && frameText(record).includes(marker), + `frame containing ${marker}`, + RECORD_TIMEOUT_MS, + startIndex, + ); +} + +function controlArgs(target, cols = DEFAULT_COLS, rows = DEFAULT_ROWS, takeover = true) { + return ["terminal", "session", "control", target, ...(takeover ? ["--takeover"] : []), "--cols", String(cols), "--rows", String(rows)]; +} + +function observeArgs(target, cols = DEFAULT_COLS, rows = DEFAULT_ROWS) { + return ["terminal", "session", "observe", target, "--cols", String(cols), "--rows", String(rows)]; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + await mkdir(options.evidence, { recursive: true }); + const log = new Log(); + const rawLines = []; + log.add("invocation", [process.execPath, ...process.argv.slice(1)]); + const initialStatus = await runBounded("git", ["status", "--short"], { timeoutMs: 5_000 }); + log.add("git status before", initialStatus.stdout.trim() || "clean"); + log.add("timeouts", { child_process_ms: CHILD_TIMEOUT_MS, expected_record_ms: RECORD_TIMEOUT_MS, quiet_window_ms: QUIET_WINDOW_MS }); + + const env = { ...process.env }; + if (options.socket) env.HERDR_SOCKET_PATH = options.socket; + + if (options.expectConnectFailure) { + const result = await runBounded(options.herdr, controlArgs("ulw-probe-bogus"), { env, allowNonzero: true }); + assert(result.code !== 0, "connect-failure probe must exit nonzero internally"); + assert(result.stderr.includes("failed to connect to server"), "connect-failure stderr must identify server connection failure"); + assert(result.stderr.includes("Socket path:"), "connect-failure stderr must report the resolved socket path"); + log.add("expected connect failure validated", result); + log.add("result", "PASS expected connection failure (script exit 0)"); + await writeFile(resolve(options.evidence, "raw.ndjson"), ""); + await writeFile(resolve(options.evidence, "cleanup.json"), `${JSON.stringify({ workspace_created: false, cleanup_required: false, expected_connect_failure: true }, null, 2)}\n`); + const after = await runBounded("git", ["status", "--short"], { timeoutMs: 5_000 }); + log.add("git status after", after.stdout.trim() || "clean"); + await writeFile(resolve(options.evidence, "probe.log"), log.text()); + console.log(`PASS expected connection failure; evidence=${options.evidence}`); + return; + } + + const scratch = await mkdtemp(resolve(tmpdir(), "ulw-probe-")); + let workspaceId; + let paneId; + let terminalId; + const clients = new Set(); + const summary = { version: null, workspace: null, probes: {}, deviations: [] }; + let cleanupReceipt; + + const startClient = (name, args, clientEnv = env) => { + const client = new NdjsonChild(options.herdr, args, rawLines, log, { env: clientEnv, name }); + clients.add(client); + client.exitPromise.finally(() => clients.delete(client)); + return client; + }; + + try { + const version = await runBounded(options.herdr, ["--version"], { env }); + assert(version.stdout.trim() === "herdr 0.8.2", `probe is pinned to herdr 0.8.2, got ${version.stdout.trim()}`); + summary.version = version.stdout.trim(); + + const created = await runBounded(options.herdr, ["workspace", "create", "--cwd", scratch, "--label", "ulw-probe", "--no-focus"], { env }); + const createJson = JSON.parse(created.stdout); + workspaceId = createJson?.result?.workspace?.workspace_id; + paneId = createJson?.result?.root_pane?.pane_id; + terminalId = createJson?.result?.root_pane?.terminal_id; + assert(workspaceId && paneId && terminalId, "workspace create JSON must return workspace, pane, and terminal ids"); + summary.workspace = { workspace_id: workspaceId, pane_id: paneId, terminal_id: terminalId, scratch_cwd: scratch, create_json: createJson }; + log.add("isolated workspace created from exact JSON", summary.workspace); + + // 1-6: frame contract, UTF-8 behavior, input, resize, and scroll command acceptance. + const primary = startClient("primary", controlArgs(terminalId)); + const first = await primary.waitFor((record) => record.type === "terminal.frame", "first terminal.frame"); + validateFrame(first.record); + assert(first.record.full === true, "first controller frame must be full"); + summary.probes.first_frame = { raw_line: first.rawLine, record: first.record }; + summary.probes.frame_fields = { raw_line: first.rawLine, fields: Object.keys(first.record).sort(), decoded_bytes: Buffer.from(first.record.bytes, "base64").length }; + + const inputStart = primary.records.length; + const input = { type: "terminal.input", text: "printf 'ULW_PROBE_OK\\n'\n" }; + primary.send(input); + const markerFrame = await waitForText(primary, "ULW_PROBE_OK", inputStart); + const bothFieldsStderrStart = primary.stderr.length; + const bothFieldsInput = { type: "terminal.input", text: "printf 'ULW_INVALID_BOTH_TEXT\\n'\n", bytes: Buffer.from("printf 'ULW_INVALID_BOTH_BYTES\\n'\n").toString("base64") }; + primary.send(bothFieldsInput); + await primary.waitForStderr( + (stderr) => stderr.slice(bothFieldsStderrStart).includes("terminal.input accepts text or bytes, not both"), + "rejecting terminal.input with both text and bytes", + ); + const neitherFieldsStderrStart = primary.stderr.length; + const neitherFieldsRecordStart = primary.records.length; + const neitherFieldsInput = { type: "terminal.input" }; + primary.send(neitherFieldsInput); + const neitherFieldsRecords = await primary.quietRecordCount(); + const neitherFieldsStderr = primary.stderr.slice(neitherFieldsStderrStart).trim(); + assert(neitherFieldsStderr === "", `neither-field input behavior changed; unexpected stderr: ${neitherFieldsStderr}`); + assert(neitherFieldsRecords === 0 && primary.records.length === neitherFieldsRecordStart, "neither-field input behavior changed; expected silent no-op with no frame"); + assert(!primary.records.slice(inputStart).some(({ record }) => record.type === "terminal.frame" && /ULW_INVALID_BOTH_(TEXT|BYTES)/.test(frameText(record))), "rejected both-fields input must not reach the terminal"); + summary.probes.input = { + command: input, + raw_line: markerFrame.rawLine, + round_trip: true, + negative_validation: { + both_fields: { command: bothFieldsInput, rejected: true, stderr: primary.stderr.slice(bothFieldsStderrStart, neitherFieldsStderrStart).trim() }, + neither_field: { command: neitherFieldsInput, rejected: false, silent_noop: true, records_emitted: neitherFieldsRecords, stderr: neitherFieldsStderr }, + }, + }; + + // Ask the shell for multibyte CJK output and inspect every resulting record boundary. The bridge + // carries base64 bytes, so an individual frame may end inside UTF-8 even though NDJSON remains valid. + const utfStart = primary.records.length; + const utfInputBytes = Buffer.from("printf '가나다\\n'\n", "utf8").toString("base64"); + primary.send({ type: "terminal.input", bytes: utfInputBytes }); + const utfFrame = await primary.waitFor( + () => { + const decoded = primary.records.slice(utfStart).filter(({ record }) => record.type === "terminal.frame").map(({ record }) => frameText(record)).join(""); + return [..."가나다"].every((character) => decoded.includes(character)); + }, + "frames containing each of 가, 나, 다", + RECORD_TIMEOUT_MS, + utfStart, + ); + const utfRecords = primary.records.slice(utfStart, primary.records.indexOf(utfFrame) + 1).filter(({ record }) => record.type === "terminal.frame"); + const decodedUtfBuffers = utfRecords.map(({ record }) => Buffer.from(record.bytes, "base64")); + const concatenatedUtf = Buffer.concat(decodedUtfBuffers); + const cjkBytes = Buffer.from("가나다", "utf8"); + const cjkOffset = concatenatedUtf.indexOf(cjkBytes); + const boundaries = []; + let cumulative = 0; + for (const bytes of decodedUtfBuffers.slice(0, -1)) { + cumulative += bytes.length; + boundaries.push(cumulative); + } + const splitInsideCjk = cjkOffset >= 0 && boundaries.some((boundary) => boundary > cjkOffset && boundary < cjkOffset + cjkBytes.length && ![cjkOffset + 3, cjkOffset + 6].includes(boundary)); + summary.probes.utf8_split = { + raw_lines: utfRecords.map(({ rawLine }) => rawLine), + decoded_frame_byte_lengths: decodedUtfBuffers.map((bytes) => bytes.length), + cjk_byte_offset: cjkOffset, + frame_boundaries: boundaries, + observed_text: "가나다", + split_inside_multibyte_character: splitInsideCjk, + result: splitInsideCjk ? "observed a frame boundary inside one UTF-8 character" : "no frame boundary split an individual UTF-8 character in this captured emission", + }; + + const resizeChangedStart = primary.records.length; + const changedResize = { type: "terminal.resize", cols: 61, rows: 14 }; + primary.send(changedResize); + const changedFrame = await primary.waitFor((record) => record.type === "terminal.frame" && record.width === 61 && record.height === 14, "changed-size frame", RECORD_TIMEOUT_MS, resizeChangedStart); + validateFrame(changedFrame.record); + assert(changedFrame.record.full === true, "changed-size resize must emit a full frame"); + const resizeSameStart = primary.records.length; + const sameResize = { type: "terminal.resize", cols: 61, rows: 14 }; + primary.send(sameResize); + const unchangedFrame = await primary.waitFor( + (record) => record.type === "terminal.frame" && record.width === 61 && record.height === 14, + "unchanged-size frame", + RECORD_TIMEOUT_MS, + resizeSameStart, + ); + validateFrame(unchangedFrame.record); + assert(unchangedFrame.record.full === true, "unchanged-size resize must emit a full frame"); + summary.probes.resize = { + changed: { command: changedResize, raw_line: changedFrame.rawLine, full: changedFrame.record.full }, + unchanged: { command: sameResize, raw_line: unchangedFrame.rawLine, emitted: true, full: unchangedFrame.record.full }, + }; + + const scrollCommands = [ + { type: "terminal.scroll", direction: "up", lines: 3, source: "wheel", column: 4, row: 4, modifiers: 0 }, + { type: "terminal.scroll", direction: "up", lines: 14, source: "page_key", column: 0, row: 0, modifiers: 0 }, + { type: "terminal.scroll", direction: "down", lines: 14, source: "page_key", column: 0, row: 0, modifiers: 0 }, + ]; + const scrollObservations = []; + for (const command of scrollCommands) { + const commandStart = primary.records.length; + const stderrStart = primary.stderr.length; + primary.send(command); + const recordsEmitted = await primary.quietRecordCount(); + const frames = primary.records.slice(commandStart).filter(({ record }) => record.type === "terminal.frame"); + const stderr = primary.stderr.slice(stderrStart).trim(); + scrollObservations.push({ command, records_emitted: recordsEmitted, frame_raw_lines: frames.map(({ rawLine }) => rawLine), stderr }); + } + const scrollStart = primary.records.length; + const scrollMarker = { type: "terminal.input", text: "printf 'ULW_SCROLL_OK\\n'\n" }; + primary.send(scrollMarker); + const scrollFrame = await waitForText(primary, "ULW_SCROLL_OK", scrollStart); + assert(scrollObservations.every(({ stderr }) => stderr === ""), "valid scroll commands must not produce rejection stderr"); + summary.probes.scroll = { + commands: scrollCommands, + observations: scrollObservations, + acknowledgment_observable: scrollObservations.some(({ records_emitted }) => records_emitted > 0), + informational_only: true, + acceptance_raw_line: scrollFrame.rawLine, + result: "no command-correlated acknowledgment is guaranteed; shapes were accepted without rejection and the bridge remained writable", + }; + + primary.send({ type: "terminal.release" }); + const released = await primary.waitFor((record) => record.type === "terminal.closed", "release closure"); + const primaryExit = await primary.exit(); + assert(released.record.reason === "detached", `release reason must be detached, got ${released.record.reason}`); + assert(primaryExit.code === 0, "released controller must exit cleanly"); + const readAfterRelease = await runBounded(options.herdr, ["pane", "read", paneId, "--lines", "20", "--format", "text"], { env }); + assert(readAfterRelease.code === 0, "pane read must remain available after release"); + summary.probes.release = { command: { type: "terminal.release" }, raw_line: released.rawLine, record: released.record, exit: primaryExit, pane_read_worked: true, pane_read_stdout_bytes: Buffer.byteLength(readAfterRelease.stdout) }; + + // 8: EOF, SIGTERM, and SIGKILL. A successor takeover proves authority is available after each disconnect. + for (const mode of ["eof", "sigterm", "sigkill"]) { + const client = startClient(`lifecycle-${mode}`, controlArgs(terminalId)); + const initial = await client.waitFor((record) => record.type === "terminal.frame" && record.full === true, `${mode} initial full frame`); + if (mode === "eof") client.endStdin(); + else client.kill(mode === "sigterm" ? "SIGTERM" : "SIGKILL"); + const exit = await client.exit(); + const closure = client.records.find(({ record }) => record.type === "terminal.closed"); + const successor = startClient(`successor-${mode}`, controlArgs(terminalId)); + const successorFrame = await successor.waitFor((record) => record.type === "terminal.frame" && record.full === true, `${mode} successor full frame`); + successor.send({ type: "terminal.release" }); + await successor.waitFor((record) => record.type === "terminal.closed", `${mode} successor closure`); + await successor.exit(); + summary.probes[mode] = { + initial_raw_line: initial.rawLine, + closure: closure ? { raw_line: closure.rawLine, record: closure.record } : null, + exit, + ownership_released: true, + successor_raw_line: successorFrame.rawLine, + }; + } + + // 9: a takeover controller displaces the first controller. + const displaced = startClient("displaced-first", controlArgs(terminalId)); + await displaced.waitFor((record) => record.type === "terminal.frame" && record.full === true, "displaced controller initial frame"); + const takeover = startClient("displacing-second", controlArgs(terminalId)); + const takeoverFrame = await takeover.waitFor((record) => record.type === "terminal.frame" && record.full === true, "takeover controller initial frame"); + const displacedClosed = await displaced.waitFor((record) => record.type === "terminal.closed", "displaced controller closure"); + assert(displacedClosed.record.reason === "terminal attach taken over", `displaced controller reason changed: ${displacedClosed.record.reason}`); + const displacedExit = await displaced.exit(); + summary.probes.displacement = { raw_line: displacedClosed.rawLine, record: displacedClosed.record, first_exit: displacedExit, second_initial_raw_line: takeoverFrame.rawLine }; + + // 10: observe is read-only, but receives the controller-sized grid and subsequent resize. + const observer = startClient("observer", observeArgs(terminalId, 30, 8)); + const observedInitial = await observer.waitFor((record) => record.type === "terminal.frame" && record.full === true, "observer initial full frame"); + const observerResizeStart = observer.records.length; + takeover.send({ type: "terminal.resize", cols: 47, rows: 11 }); + const controlledResize = await takeover.waitFor((record) => record.type === "terminal.frame" && record.width === 47 && record.height === 11, "controller resized frame"); + assert(observedInitial.record.width === 30 && observedInitial.record.height === 8, "observer initial viewport must match requested 30x8 grid"); + assert(controlledResize.record.width === 47 && controlledResize.record.height === 11, "controller resize frame must match requested 47x11 grid"); + assert(controlledResize.record.full === true, "controller resize while observer is active must emit a full frame"); + const observerRecordsAfterResize = await observer.quietRecordCount(); + const observerFramesAfterResize = observer.records.slice(observerResizeStart).filter(({ record }) => record.type === "terminal.frame"); + assert(observerFramesAfterResize.length > 0, "observer must emit at least one frame after controller resize"); + assert(observerFramesAfterResize.every(({ record }) => record.width === 30 && record.height === 8), "observer frames must remain at requested 30x8 grid after controller resize"); + const observedResize = observerFramesAfterResize.find(({ record }) => record.width === 47 && record.height === 11); + const takeoverClosedByObserver = takeover.records.find(({ record }) => record.type === "terminal.closed"); + summary.probes.observe_grid = { + observer_initial: { raw_line: observedInitial.rawLine, width: observedInitial.record.width, height: observedInitial.record.height }, + controller_resize_raw_line: controlledResize.rawLine, + observer_records_after_controller_resize: observerRecordsAfterResize, + observer_resize: observedResize ? { raw_line: observedResize.rawLine, width: observedResize.record.width, height: observedResize.record.height, full: observedResize.record.full } : null, + controller_closure_after_observer: takeoverClosedByObserver ? { raw_line: takeoverClosedByObserver.rawLine, record: takeoverClosedByObserver.record } : null, + result: takeoverClosedByObserver ? "starting observe displaced the controller; observer then retained its own requested grid" : observedResize ? "observer followed controller dimensions" : "observer retained its own requested grid and emitted no controller-size frame", + }; + observer.kill("SIGTERM"); + await observer.exit(); + if (!takeover.closed) { + takeover.send({ type: "terminal.release" }); + await takeover.waitFor((record) => record.type === "terminal.closed", "takeover release after observer"); + await takeover.exit(); + } + + // 12: create >200 terminal lines, then compare pane-read retention with a fresh observer checkpoint. + const retentionController = startClient("retention-controller", controlArgs(terminalId)); + await retentionController.waitFor((record) => record.type === "terminal.frame" && record.full === true, "retention controller initial frame"); + const retentionStart = retentionController.records.length; + retentionController.send({ type: "terminal.input", text: "i=1; while [ $i -le 240 ]; do printf 'ULW_RET_%03d\\n' $i; i=$((i+1)); done; printf 'ULW_RET_DONE\\n'\n" }); + const retainedMarker = await waitForText(retentionController, "ULW_RET_DONE", retentionStart); + const paneRead = await runBounded(options.herdr, ["pane", "read", paneId, "--lines", "300", "--format", "text"], { env }); + const retainedLines = paneRead.stdout.split(/\r?\n/).filter((line) => line.includes("ULW_RET_")); + const retainedNumberedLines = retainedLines.filter((line) => /ULW_RET_\d{3}/.test(line)); + assert(retainedNumberedLines.length === 240, `pane read must retain all 240 numbered output lines; got ${retainedNumberedLines.length}`); + assert(retainedLines.some((line) => line.includes("ULW_RET_DONE")), "pane read must retain ULW_RET_DONE"); + assert(retainedLines.some((line) => line.includes("ULW_RET_001")), "pane read must retain first emitted line"); + assert(retainedLines.some((line) => line.includes("ULW_RET_240")), "pane read must retain last numbered line"); + const freshObserver = startClient("fresh-retention-observer", observeArgs(terminalId, 47, 11)); + const freshFrame = await freshObserver.waitFor((record) => record.type === "terminal.frame" && record.full === true, "fresh observer checkpoint"); + const freshText = frameText(freshFrame.record); + freshObserver.kill("SIGTERM"); + await freshObserver.exit(); + summary.probes.retention = { + emit_marker_raw_line: retainedMarker.rawLine, + emitted_lines: 241, + pane_read_requested_lines: 300, + pane_read_matching_lines: retainedLines.length, + pane_read_numbered_matches: retainedNumberedLines.length, + pane_read_first_match: retainedLines[0] ?? null, + pane_read_last_match: retainedLines.at(-1) ?? null, + fresh_observer_raw_line: freshFrame.rawLine, + fresh_observer_full_bytes: Buffer.from(freshFrame.record.bytes, "base64").length, + fresh_observer_contains_first: freshText.includes("ULW_RET_001"), + fresh_observer_contains_last: freshText.includes("ULW_RET_240"), + plan_checkpoint_bound_bytes: 8 * 1024 * 1024, + }; + + if (!retentionController.closed) { + retentionController.send({ type: "terminal.release" }); + await retentionController.waitFor((record) => record.type === "terminal.closed", "retention controller release"); + await retentionController.exit(); + } + + // 11: named-session argv behavior and bogus-target error record on the live default session. + const namedVersion = await runBounded(options.herdr, ["--session", "ulw-probe-nonexistent", "--version"], { env }); + log.add("named-session version invocation", namedVersion); + const namedControl = await runBounded(options.herdr, ["--session", "ulw-probe-nonexistent", ...controlArgs("bogus")], { env, allowNonzero: true }); + log.add("named-session missing-server invocation", namedControl); + const bogusTarget = await runBounded(options.herdr, controlArgs("ulw-probe-bogus"), { env }); + const bogusRecord = JSON.parse(bogusTarget.stdout.trim()); + log.add("live-session bogus-target invocation", { ...bogusTarget, parsed_record: bogusRecord }); + assert(bogusRecord.type === "terminal.closed" && bogusRecord.reason.includes("not found"), "bogus live target must return terminal.closed not-found reason"); + assert(namedControl.code !== 0 && namedControl.stderr.includes("failed to connect to server"), "missing named session must report connection failure"); + summary.probes.named_session = { + version_argv: [options.herdr, "--session", "ulw-probe-nonexistent", "--version"], + version_stdout: namedVersion.stdout.trim(), + control_argv: [options.herdr, "--session", "ulw-probe-nonexistent", ...controlArgs("bogus")], + control_exit: namedControl.code, + control_stderr: namedControl.stderr.trim(), + live_bogus_target_record: bogusRecord, + minimum_supported_version_for_plan: "0.8.0", + installed_version: summary.version, + }; + + summary.deviations = [ + "D2 amended: 0.8.2 emits typed terminal.frame/terminal.closed records, not bare {bytes}; input is accepted directly by terminal.input text rather than pane.send_text.", + "D5 confirmed/amended: the first frame is full:true; both changed and unchanged live terminal.resize emitted full:true checkpoints.", + "D6 amended: scroll is a typed terminal.scroll command (wheel/page_key); decoded controller frames include terminal-mode ANSI, so a blanket claim that no mouse-related escapes exist is unsafe.", + ]; + } finally { + for (const client of [...clients]) { + if (!client.closed) client.kill("SIGKILL"); + try { await client.exit(); } catch {} + } + let closeResult = null; + if (workspaceId) closeResult = await runBounded(options.herdr, ["workspace", "close", workspaceId], { env, allowNonzero: true }); + const workspaceList = await runBounded(options.herdr, ["workspace", "list"], { env, allowNonzero: true }); + let parsedList = null; + try { parsedList = JSON.parse(workspaceList.stdout); } catch {} + const serializedList = JSON.stringify(parsedList ?? workspaceList.stdout); + const workspaceAbsentById = workspaceId ? !serializedList.includes(`"${workspaceId}"`) : true; + const labelAbsent = !serializedList.includes("ulw-probe"); + await rm(scratch, { recursive: true, force: true }); + const processScan = await runBounded("pgrep", ["-af", "probe-herdr-control|ulw-probe"], { allowNonzero: true, timeoutMs: 5_000 }); + const processScanLines = processScan.stdout.split(/\r?\n/).filter(Boolean); + cleanupReceipt = { + workspace_id: workspaceId ?? null, + close: closeResult, + workspace_list: parsedList ?? workspaceList.stdout, + workspace_absent_by_returned_id: workspaceAbsentById, + ulw_probe_label_absent: labelAbsent, + tracked_probe_children_remaining: [...clients].filter((client) => !client.closed).map((client) => client.pid), + process_scan: { + command: processScan.command, + exit_code: processScan.code, + raw_matches: processScanLines, + note: "The running probe process may match its own argv; tracked spawned children are checked separately above." + }, + scratch_directory_removed: true, + }; + assert(workspaceAbsentById, `cleanup failed: returned workspace id ${workspaceId} remains`); + assert(labelAbsent, "cleanup failed: ulw-probe label remains"); + assert(cleanupReceipt.tracked_probe_children_remaining.length === 0, "cleanup failed: tracked probe child remains"); + } + + const rawPath = resolve(options.evidence, "raw.ndjson"); + const summaryPath = resolve(options.evidence, "summary.json"); + const cleanupPath = resolve(options.evidence, "cleanup.json"); + await writeFile(rawPath, rawLines.length ? `${rawLines.join("\n")}\n` : ""); + await writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`); + await writeFile(cleanupPath, `${JSON.stringify(cleanupReceipt, null, 2)}\n`); + + try { + const protocolDoc = await readFile(resolve(dirname(new URL(import.meta.url).pathname), "../../docs/herdr-bridge-protocol.md"), "utf8"); + await writeFile(resolve(options.evidence, "protocol.md"), protocolDoc); + } catch { + await writeFile(resolve(options.evidence, "protocol.md"), "Protocol document is generated from summary.json after the first successful probe run.\n"); + } + + const afterStatus = await runBounded("git", ["status", "--short"], { timeoutMs: 5_000 }); + log.add("git status after", afterStatus.stdout.trim() || "clean"); + log.add("cleanup receipt", cleanupReceipt); + log.add("result", `PASS herdr 0.8.2 control probe; raw_records=${rawLines.length} (script exit 0)`); + await writeFile(resolve(options.evidence, "probe.log"), log.text()); + console.log(`PASS herdr 0.8.2 control probe; raw_records=${rawLines.length}; evidence=${options.evidence}`); +} + +main().catch(async (error) => { + console.error(error.stack ?? String(error)); + process.exitCode = 1; +}); diff --git a/script/qa/web-terminal-reset-visual-qa.mjs b/script/qa/web-terminal-reset-visual-qa.mjs new file mode 100644 index 0000000..9e4b806 --- /dev/null +++ b/script/qa/web-terminal-reset-visual-qa.mjs @@ -0,0 +1,408 @@ +import fs from "node:fs"; +import http from "node:http"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { parseArgs } from "node:util"; + +const { values } = parseArgs({ + options: { + "evidence-dir": { type: "string" }, + }, + strict: false, +}); + +if (!values["evidence-dir"]) { + console.error("Missing --evidence-dir"); + process.exit(1); +} + +const evidenceDir = path.resolve(values["evidence-dir"]); +fs.mkdirSync(evidenceDir, { recursive: true }); + +const chromePaths = [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Google Chrome.app/Contents/MacOS/Chrome", +]; + +const chromeExecutable = chromePaths.find((p) => fs.existsSync(p)); +if (!chromeExecutable) { + console.error(`BLOCKED: Chrome not found at ${chromePaths.join(" or ")}`); + process.exit(1); +} + +const webviewJsPath = path.resolve("dist/webview.js"); +if (!fs.existsSync(webviewJsPath)) { + console.error("Missing dist/webview.js. Did you run npm run compile?"); + process.exit(1); +} + +// --------------------------------------------------------------------------- +// Harness HTML: the real dist/webview.js bundle in a plain page. +// The sequence is driven by real timers inside Chrome; the script keeps +// re-posting messages until they are observed in the LIVE rendered DOM, then +// writes a single #qa-results node with the assertions. +// --------------------------------------------------------------------------- +const htmlPath = path.resolve(evidenceDir, "harness.html"); + +const htmlContent = ` + + + + ULW Terminal Reset Visual QA + + + + +
+ + + +`; + +fs.writeFileSync(htmlPath, htmlContent); + +// --------------------------------------------------------------------------- +// Drive Chrome over the DevTools protocol with real time, so xterm's +// rAF-driven render loop runs normally. No new npm dependencies: Node >= 22 +// ships a global WebSocket client. +// --------------------------------------------------------------------------- +const freePort = () => + new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.listen(0, "127.0.0.1", () => { + const { port } = srv.address(); + srv.close(() => resolve(port)); + }); + srv.on("error", reject); + }); + +const httpJson = (port, method, urlPath) => + new Promise((resolve, reject) => { + const req = http.request( + { host: "127.0.0.1", port, path: urlPath, method }, + (res) => { + let data = ""; + res.on("data", (c) => (data += c)); + res.on("end", () => { + try { + resolve(JSON.parse(data)); + } catch { + reject(new Error(`Non-JSON response from ${urlPath}: ${data.slice(0, 200)}`)); + } + }); + }, + ); + req.on("error", reject); + req.end(); + }); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +class Cdp { + constructor(ws) { + this.ws = ws; + this.nextId = 1; + this.pending = new Map(); + ws.addEventListener("message", (event) => { + const msg = JSON.parse(event.data); + if (msg.id && this.pending.has(msg.id)) { + const { resolve, reject } = this.pending.get(msg.id); + this.pending.delete(msg.id); + if (msg.error) reject(new Error(msg.error.message)); + else resolve(msg.result); + } + }); + } + send(method, params = {}) { + const id = this.nextId++; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.ws.send(JSON.stringify({ id, method, params })); + }); + } +} + +async function main() { + const port = await freePort(); + const profileDir = fs.mkdtempSync(path.join(os.tmpdir(), "ulw-vqa-profile-")); + const chrome = spawn(chromeExecutable, [ + "--headless=new", + `--remote-debugging-port=${port}`, + `--user-data-dir=${profileDir}`, + "--no-first-run", + "--no-default-browser-check", + "--disable-background-timer-throttling", + "--disable-renderer-backgrounding", + "--window-size=800,600", + "about:blank", + ]); + + let chromeDead = false; + chrome.on("exit", () => (chromeDead = true)); + + const killChrome = () => { + try { + chrome.kill("SIGKILL"); + } catch {} + try { + fs.rmSync(profileDir, { recursive: true, force: true }); + } catch {} + }; + + try { + // Wait for the DevTools endpoint. + let version = null; + for (let i = 0; i < 100; i++) { + if (chromeDead) throw new Error("Chrome exited before DevTools was ready"); + try { + version = await httpJson(port, "GET", "/json/version"); + break; + } catch { + await sleep(100); + } + } + if (!version) throw new Error("DevTools endpoint never became ready"); + + // Open a fresh tab and grab its WebSocket URL. + await httpJson(port, "PUT", "/json/new?about:blank"); + let target = null; + for (let i = 0; i < 50; i++) { + const list = await httpJson(port, "GET", "/json/list"); + target = list.find((t) => t.type === "page"); + if (target && target.webSocketDebuggerUrl) break; + await sleep(100); + } + if (!target || !target.webSocketDebuggerUrl) { + throw new Error("No page target with a debugger URL"); + } + + const ws = new WebSocket(target.webSocketDebuggerUrl); + await new Promise((resolve, reject) => { + ws.addEventListener("open", resolve, { once: true }); + ws.addEventListener("error", reject, { once: true }); + }); + const cdp = new Cdp(ws); + + await cdp.send("Page.enable"); + await cdp.send("Runtime.enable"); + await cdp.send("Emulation.setDeviceMetricsOverride", { + width: 800, + height: 600, + deviceScaleFactor: 1, + mobile: false, + }); + await cdp.send("Page.navigate", { url: `file://${htmlPath}` }); + + // Poll the live DOM until the harness writes its assertions (bounded). + let assertionsText = null; + const deadline = Date.now() + 45000; + while (Date.now() < deadline) { + if (chromeDead) throw new Error("Chrome exited mid-run"); + const result = await cdp.send("Runtime.evaluate", { + expression: + '(() => { const n = document.getElementById("qa-results"); return n ? n.textContent : null; })()', + returnByValue: true, + }); + if (result && result.result && typeof result.result.value === "string") { + assertionsText = result.result.value; + break; + } + await sleep(150); + } + if (assertionsText === null) { + throw new Error("Harness never wrote #qa-results within the deadline"); + } + + const assertions = JSON.parse(assertionsText.replace(/"/g, '"')); + + // Screenshot AFTER the sequence completed, so the PNG shows final state. + const shot = await cdp.send("Page.captureScreenshot", { format: "png" }); + fs.writeFileSync(path.join(evidenceDir, "screenshot.png"), Buffer.from(shot.data, "base64")); + + // DOM snapshot transcript (equivalent of --dump-dom, taken at the end). + const dom = await cdp.send("Runtime.evaluate", { + expression: "document.documentElement.outerHTML", + returnByValue: true, + }); + fs.writeFileSync(path.join(evidenceDir, "transcript.txt"), dom.result.value); + + fs.writeFileSync(path.join(evidenceDir, "assertions.json"), JSON.stringify(assertions, null, 2)); + console.log("Assertions:", JSON.stringify(assertions)); + + try { + ws.close(); + } catch {} + + const pass = + assertions.sentinelAbsent === true && + assertions.replacementPresent === true && + assertions.badgePresent === true && + assertions.badgeTextPresent === true && + assertions.badgeText === "Attached: probe"; + + killChrome(); + if (!pass) { + console.error("Assertions failed."); + process.exit(1); + } + console.log("Visual QA script passed."); + process.exit(0); + } catch (err) { + console.error(`Visual QA failed: ${err && err.message ? err.message : err}`); + killChrome(); + process.exit(1); + } +} + +main(); diff --git a/script/qa/web-terminal-visual-qa.mjs b/script/qa/web-terminal-visual-qa.mjs new file mode 100644 index 0000000..d7b9d1c --- /dev/null +++ b/script/qa/web-terminal-visual-qa.mjs @@ -0,0 +1,568 @@ +import fs from "node:fs"; +import http from "node:http"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { parseArgs } from "node:util"; + +const { values } = parseArgs({ + options: { + title: { type: "string", default: "ULW herdr attached" }, + command: { type: "string" }, + input: { type: "string", default: "{Enter}" }, + "evidence-dir": { type: "string" }, + herdr: { type: "string", default: "/Users/ilseoblee/.local/bin/herdr" }, + }, + strict: true, +}); + +if (!values.command || !values["evidence-dir"]) { + console.error("Usage: node script/qa/web-terminal-visual-qa.mjs --title --command <pane-command> --input <keys> --evidence-dir <dir>"); + process.exit(1); +} + +const title = values.title; +const markerCommand = values.command; +const visualFixtureRequested = markerCommand.includes("--visual-fixture"); +const fixturePaneCommand = + "printf 'ULW_VISUAL_READY 가나다 \\033[38;2;255;95;31mULW_TRUECOLOR\\033[0m\\n'; exec /bin/sh"; +const paneCommand = visualFixtureRequested ? fixturePaneCommand : markerCommand; +const inputKeys = values.input; +const evidenceDir = path.resolve(values["evidence-dir"]); +const herdr = path.resolve(values.herdr); +const webviewJsPath = path.resolve("dist/webview.js"); +const commandTimeoutMs = 10_000; +fs.mkdirSync(evidenceDir, { recursive: true }); + +const chromeExecutable = [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Google Chrome.app/Contents/MacOS/Chrome", +].find((candidate) => fs.existsSync(candidate)); + +if (!fs.existsSync(herdr)) { + console.error(`Herdr binary not found: ${herdr}`); + process.exit(1); +} +if (!fs.existsSync(webviewJsPath)) { + console.error("Missing dist/webview.js. Run npm run compile first."); + process.exit(1); +} +if (!chromeExecutable) { + console.error("Chrome not found"); + process.exit(1); +} + +const transcript = []; +const childProcesses = new Set(); +let scratchDir; +let workspaceId; +let paneId; +let terminalId; +let scratchProcessIds = []; +let processInspection = { + processIds: [], + inspectionFailed: false, +}; +let chromeProfileDir; +let chrome; +let bridge; +let bridgeReleased = false; +let visualAssertions; + +const log = (event, detail = {}) => { + const entry = { at: new Date().toISOString(), event, ...detail }; + transcript.push(entry); + console.log(JSON.stringify(entry)); +}; + +const runBounded = (command, args, options = {}) => + new Promise((resolve, reject) => { + const child = spawn(command, args, { + env: options.env ?? process.env, + cwd: options.cwd, + stdio: ["ignore", "pipe", "pipe"], + }); + childProcesses.add(child); + let stdout = ""; + let stderr = ""; + const timeout = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error(`${command} ${args.join(" ")} timed out`)); + }, options.timeoutMs ?? commandTimeoutMs); + child.stdout.on("data", (chunk) => (stdout += chunk)); + child.stderr.on("data", (chunk) => (stderr += chunk)); + child.on("error", (error) => { + clearTimeout(timeout); + childProcesses.delete(child); + reject(error); + }); + child.on("exit", (code, signal) => { + clearTimeout(timeout); + childProcesses.delete(child); + if (code === 0) { + resolve({ stdout, stderr, code, signal }); + } else { + reject(new Error(`${command} ${args.join(" ")} failed (${code ?? signal}): ${stderr || stdout}`)); + } + }); + }); + +const parseResult = (stdout) => { + const parsed = JSON.parse(stdout); + if (!parsed?.result) throw new Error(`Herdr response had no result: ${stdout}`); + return parsed.result; +}; + +const inspectProcesses = async (targetPaneId) => { + try { + const result = parseResult((await runBounded(herdr, ["pane", "process-info", "--pane", targetPaneId])).stdout); + const info = result.process_info ?? {}; + return { + processIds: [...new Set([info.shell_pid, ...(info.foreground_processes ?? []).map((entry) => entry.pid)].filter(Number.isInteger))], + inspectionFailed: false, + }; + } catch (error) { + return { + processIds: [], + inspectionFailed: true, + error: String(error?.message ?? error), + }; + } +}; + +const processAlive = (pid) => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === "EPERM"; + } +}; + +const waitFor = (subscribe, description, timeoutMs = commandTimeoutMs) => + new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + dispose(); + reject(new Error(`Timed out waiting for ${description}`)); + }, timeoutMs); + const dispose = subscribe((value) => { + clearTimeout(timeout); + dispose(); + resolve(value); + }); + }); + +const freePort = () => + new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + server.close(() => resolve(address.port)); + }); + server.on("error", reject); + }); + +const httpJson = (port, method, requestPath) => + new Promise((resolve, reject) => { + const request = http.request( + { host: "127.0.0.1", port, path: requestPath, method }, + (response) => { + let body = ""; + response.on("data", (chunk) => (body += chunk)); + response.on("end", () => { + try { + resolve(JSON.parse(body)); + } catch { + reject(new Error(`Non-JSON CDP response: ${body.slice(0, 200)}`)); + } + }); + }, + ); + request.on("error", reject); + request.end(); + }); + +class Cdp { + constructor(socket) { + this.socket = socket; + this.nextId = 1; + this.pending = new Map(); + socket.addEventListener("message", (event) => { + const message = JSON.parse(event.data); + if (!message.id || !this.pending.has(message.id)) return; + const pending = this.pending.get(message.id); + this.pending.delete(message.id); + if (message.error) pending.reject(new Error(message.error.message)); + else pending.resolve(message.result); + }); + } + + send(method, params = {}) { + const id = this.nextId++; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.socket.send(JSON.stringify({ id, method, params })); + }); + } +} + +const decodeInputKeys = (value) => { + const replacements = { + "{Enter}": "\r", + "{Tab}": "\t", + "{Escape}": "\u001b", + "{Space}": " ", + }; + return Object.entries(replacements).reduce( + (decoded, [token, replacement]) => decoded.split(token).join(replacement), + value, + ); +}; + +const htmlPath = path.join(evidenceDir, "harness.html"); +fs.writeFileSync( + htmlPath, + `<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<title>${title.replaceAll("<", "<")} + + + +
+`, +); + +const startBridge = (cols, rows) => { + bridge = spawn( + herdr, + ["terminal", "session", "control", terminalId, "--takeover", "--cols", String(cols), "--rows", String(rows)], + { stdio: ["pipe", "pipe", "pipe"], env: process.env }, + ); + childProcesses.add(bridge); + let stdoutBuffer = ""; + let stderr = ""; + const records = []; + const listeners = new Set(); + bridge.stdout.on("data", (chunk) => { + stdoutBuffer += chunk.toString("utf8"); + for (;;) { + const newline = stdoutBuffer.indexOf("\n"); + if (newline < 0) break; + const line = stdoutBuffer.slice(0, newline).replace(/\r$/, ""); + stdoutBuffer = stdoutBuffer.slice(newline + 1); + if (!line) continue; + const record = JSON.parse(line); + records.push(record); + log("bridge.record", { record: record.type === "terminal.frame" ? { ...record, bytes: `<${record.bytes.length} base64 chars>` } : record }); + for (const listener of [...listeners]) listener(record); + } + }); + bridge.stderr.on("data", (chunk) => { + stderr += chunk.toString("utf8"); + log("bridge.stderr", { data: chunk.toString("utf8") }); + }); + bridge.on("exit", (code, signal) => { + childProcesses.delete(bridge); + log("bridge.exit", { code, signal, stderr }); + }); + return { + records, + send(command) { + log("bridge.command", { command }); + bridge.stdin.write(`${JSON.stringify(command)}\n`); + }, + onRecord(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +}; + +async function cleanup() { + for (const child of childProcesses) { + try { child.kill("SIGKILL"); } catch {} + } + if (chrome) { + try { chrome.kill("SIGKILL"); } catch {} + } + if (paneId) { + processInspection = await inspectProcesses(paneId); + scratchProcessIds = [...new Set([...scratchProcessIds, ...processInspection.processIds])]; + } + let closeResponse; + let workspaceAbsent = workspaceId === undefined; + if (workspaceId) { + try { + closeResponse = JSON.parse((await runBounded(herdr, ["workspace", "close", workspaceId])).stdout); + } catch (error) { + closeResponse = { error: String(error?.message ?? error) }; + } + try { + const listed = parseResult((await runBounded(herdr, ["workspace", "list"])).stdout); + workspaceAbsent = !(listed.workspaces ?? []).some((entry) => entry.workspace_id === workspaceId); + } catch { + workspaceAbsent = false; + } + } + if (scratchDir) fs.rmSync(scratchDir, { recursive: true, force: true }); + if (chromeProfileDir) fs.rmSync(chromeProfileDir, { recursive: true, force: true }); + const liveProcessIds = scratchProcessIds.filter(processAlive); + const cleanupReceipt = { + workspaceId, + paneId, + closeResponse, + workspaceAbsent, + processInspection, + checkedProcessIds: scratchProcessIds, + liveProcessIds, + noLeftoverChildren: !processInspection.inspectionFailed && liveProcessIds.length === 0, + scratchDir, + scratchDirRemoved: scratchDir ? !fs.existsSync(scratchDir) : true, + chromeProfileDir, + chromeProfileRemoved: chromeProfileDir ? !fs.existsSync(chromeProfileDir) : true, + bridgeReleased, + }; + fs.writeFileSync(path.join(evidenceDir, "visual-cleanup.json"), `${JSON.stringify(cleanupReceipt, null, 2)}\n`); + return cleanupReceipt; +} + +async function main() { + const deviation = { + literalPlanInvocationUsed: visualFixtureRequested, + requestedPlanCommand: 'npm run test:e2e:herdr -- --visual-fixture', + suppliedCommand: markerCommand, + actualCommandMeaning: paneCommand, + fixtureMode: visualFixtureRequested ? "internal-live-herdr-cycle" : "generic-marker-command", + justification: visualFixtureRequested + ? "The literal plan command selects the script's internal live-Herdr fixture cycle. The npm command is not executed inside the pane; the script creates an isolated workspace and emits the visual markers before driving the real bridge and production webview bundle." + : "Generic mode executes the supplied marker command in the isolated pane and expects it to emit ULW_VISUAL_READY, CJK, and truecolor fixture text.", + }; + + const version = await runBounded(herdr, ["--version"]); + if (!/^herdr 0\.8\./.test(version.stdout)) { + throw new Error(`Herdr 0.8.x required, got ${version.stdout.trim()}`); + } + scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), "ulw-visual-herdr-")); + const createdJson = JSON.parse((await runBounded(herdr, ["workspace", "create", "--cwd", scratchDir, "--label", "ulw-e2e", "--no-focus"])).stdout); + workspaceId = createdJson?.result?.workspace?.workspace_id; + paneId = createdJson?.result?.root_pane?.pane_id; + terminalId = createdJson?.result?.root_pane?.terminal_id; + if (!workspaceId || !paneId || !terminalId) throw new Error("workspace create did not return required IDs"); + log("scratch.created", { workspaceId, paneId, terminalId, scratchDir }); + + await runBounded(herdr, ["pane", "wait-output", paneId, "--regex", ".+", "--source", "visible", "--lines", "20", "--timeout", "5000", "--raw"]); + await runBounded(herdr, ["pane", "run", paneId, paneCommand]); + await runBounded(herdr, ["pane", "wait-output", paneId, "--match", "ULW_VISUAL_READY", "--source", "recent-unwrapped", "--lines", "100", "--timeout", "5000", "--raw"]); + processInspection = await inspectProcesses(paneId); + if (processInspection.inspectionFailed) { + throw new Error(`Scratch process inspection failed: ${processInspection.error}`); + } + scratchProcessIds = [...processInspection.processIds]; + + const port = await freePort(); + chromeProfileDir = fs.mkdtempSync(path.join(os.tmpdir(), "ulw-vqa-profile-")); + chrome = spawn(chromeExecutable, [ + "--headless=new", + `--remote-debugging-port=${port}`, + `--user-data-dir=${chromeProfileDir}`, + "--no-first-run", + "--no-default-browser-check", + "--disable-background-timer-throttling", + "--disable-renderer-backgrounding", + "--window-size=900,700", + "about:blank", + ]); + childProcesses.add(chrome); + chrome.on("exit", () => childProcesses.delete(chrome)); + + let versionEndpoint; + for (let attempt = 0; attempt < 100; attempt += 1) { + try { + versionEndpoint = await httpJson(port, "GET", "/json/version"); + break; + } catch {} + await new Promise((resolve) => setTimeout(resolve, 100)); + } + if (!versionEndpoint) throw new Error("Chrome DevTools endpoint did not become ready"); + await httpJson(port, "PUT", "/json/new?about:blank"); + const targets = await httpJson(port, "GET", "/json/list"); + const target = targets.find((entry) => entry.type === "page" && entry.webSocketDebuggerUrl); + if (!target) throw new Error("Chrome page target unavailable"); + const socket = new WebSocket(target.webSocketDebuggerUrl); + await new Promise((resolve, reject) => { + socket.addEventListener("open", resolve, { once: true }); + socket.addEventListener("error", reject, { once: true }); + }); + const cdp = new Cdp(socket); + await cdp.send("Page.enable"); + await cdp.send("Runtime.enable"); + await cdp.send("Emulation.setDeviceMetricsOverride", { width: 900, height: 700, deviceScaleFactor: 1, mobile: false }); + await cdp.send("Page.navigate", { url: `file://${htmlPath}` }); + + const evaluate = async (expression) => { + const result = await cdp.send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true }); + if (result.exceptionDetails) throw new Error(result.exceptionDetails.text); + return result.result?.value; + }; + + const hostReady = await waitFor( + (resolve) => { + const interval = setInterval(async () => { + const messages = await evaluate("window.__hostMessages || []"); + const ready = messages.find((message) => message?.type === "ready"); + if (ready) resolve(ready); + }, 50); + return () => clearInterval(interval); + }, + "webview ready", + ); + const bridgeDriver = startBridge(hostReady.cols, hostReady.rows); + const firstFrame = await waitFor( + (resolve) => bridgeDriver.onRecord((record) => { + if (record.type === "terminal.frame" && record.full === true) resolve(record); + }), + "first full Herdr frame", + ); + await evaluate(`window.postMessage(${JSON.stringify({ type: "reset" })}, "*")`); + await evaluate(`window.postMessage(${JSON.stringify({ type: "sourceState", source: "herdr", phase: "attached", label: "ulw-e2e" })}, "*")`); + await evaluate(`window.postMessage(${JSON.stringify({ type: "output", data: Buffer.from(firstFrame.bytes, "base64").toString("utf8") })}, "*")`); + + const forwardedFrames = new Set([firstFrame.seq]); + const disposeFrameForwarder = bridgeDriver.onRecord(async (record) => { + if (record.type !== "terminal.frame" || forwardedFrames.has(record.seq)) return; + forwardedFrames.add(record.seq); + if (record.full) await evaluate(`window.postMessage(${JSON.stringify({ type: "reset" })}, "*")`); + await evaluate(`window.postMessage(${JSON.stringify({ type: "output", data: Buffer.from(record.bytes, "base64").toString("utf8") })}, "*")`); + }); + + let pumpingHostMessages = false; + const hostInputInterval = setInterval(async () => { + if (pumpingHostMessages) return; + pumpingHostMessages = true; + try { + const messages = await evaluate("window.__hostMessages.splice(0)"); + for (const message of messages) { + if (message.type === "input") bridgeDriver.send({ type: "terminal.input", bytes: Buffer.from(message.data, "utf8").toString("base64") }); + if (message.type === "resize") bridgeDriver.send({ type: "terminal.resize", cols: message.cols, rows: message.rows }); + } + } finally { + pumpingHostMessages = false; + } + }, 25); + const disposeHostInput = () => clearInterval(hostInputInterval); + + await waitFor( + (resolve) => { + const interval = setInterval(async () => { + const rows = await evaluate("window.__rows()"); + if (rows.includes("ULW_VISUAL_READY") && rows.includes("가나다") && rows.includes("ULW_TRUECOLOR")) resolve(rows); + }, 50); + return () => clearInterval(interval); + }, + "rendered live marker, CJK, and truecolor text", + ); + + const inputPayload = `printf 'ULW_VISUAL_INPUT\\n'${decodeInputKeys(inputKeys)}`; + await evaluate("document.querySelector('.xterm-helper-textarea').focus()"); + await cdp.send("Input.insertText", { text: inputPayload }); + await waitFor( + (resolve) => { + const interval = setInterval(async () => { + if ((await evaluate("window.__rows()")).includes("ULW_VISUAL_INPUT")) resolve(true); + }, 50); + return () => clearInterval(interval); + }, + "rendered input round-trip", + ); + + await evaluate(`(() => { const row = [...document.querySelectorAll('.xterm-rows > div')].find((entry) => (entry.textContent || '').includes('ULW_VISUAL_READY')); const range = document.createRange(); range.selectNodeContents(row); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); })()`); + const selectedText = await evaluate("document.querySelector('.xterm-rows > div:nth-child(3)')?.textContent || ''"); + const selectionRangeCount = await evaluate("window.getSelection().rangeCount"); + + const attachedBadgeText = await evaluate("document.querySelector('.ulw-status-badge')?.textContent || ''"); + const attachedShot = await cdp.send("Page.captureScreenshot", { format: "png" }); + fs.writeFileSync(path.join(evidenceDir, "attached-with-badge.png"), Buffer.from(attachedShot.data, "base64")); + + bridgeDriver.send({ type: "terminal.release" }); + await waitFor( + (resolve) => bridgeDriver.onRecord((record) => { + if (record.type === "terminal.closed") resolve(record); + }), + "terminal.closed after release", + ); + bridgeReleased = true; + disposeFrameForwarder(); + disposeHostInput(); + await evaluate(`window.postMessage(${JSON.stringify({ type: "sourceState", source: "shell", phase: "error", message: "visual detach receipt" })}, "*")`); + const errorShot = await cdp.send("Page.captureScreenshot", { format: "png" }); + fs.writeFileSync(path.join(evidenceDir, "post-detach-error.png"), Buffer.from(errorShot.data, "base64")); + + const rowsOutput = await evaluate("window.__rows()"); + const badgeText = await evaluate("document.querySelector('.ulw-status-badge')?.textContent || ''"); + const truecolorSpan = await evaluate(`(() => { const spans = [...document.querySelectorAll('.xterm-rows span')]; const span = spans.find((entry) => (entry.textContent || '').trim() === 'ULW_TRUECOLOR'); return span ? getComputedStyle(span).color : ''; })()`); + visualAssertions = { + passed: true, + liveMarkerVisible: rowsOutput.includes("ULW_VISUAL_READY"), + inputRoundTripVisible: rowsOutput.includes("ULW_VISUAL_INPUT"), + cjkVisible: rowsOutput.includes("가나다"), + truecolorMarkerVisible: rowsOutput.includes("ULW_TRUECOLOR"), + truecolorComputedColor: truecolorSpan, + truecolorApplied: /255\s*,\s*95\s*,\s*31/.test(truecolorSpan), + selectionContainsMarker: selectedText.includes("ULW_VISUAL_READY") && selectionRangeCount > 0, + selectionRangeCount, + attachedBadgeCaptured: fs.existsSync(path.join(evidenceDir, "attached-with-badge.png")), + attachedBadgeText, + attachedBadgeVisible: attachedBadgeText === "Attached: ulw-e2e", + postDetachOrErrorCaptured: fs.existsSync(path.join(evidenceDir, "post-detach-error.png")), + finalBadgeText: badgeText, + finalErrorBadgeVisible: badgeText === "Error: visual detach receipt", + bridgeReleased, + title, + inputKeys, + rowsOutput, + deviation, + }; + visualAssertions.passed = Object.entries(visualAssertions) + .filter(([key]) => ["liveMarkerVisible", "inputRoundTripVisible", "cjkVisible", "truecolorMarkerVisible", "truecolorApplied", "selectionContainsMarker", "attachedBadgeCaptured", "attachedBadgeVisible", "postDetachOrErrorCaptured", "finalErrorBadgeVisible", "bridgeReleased"].includes(key)) + .every(([, value]) => value === true); + fs.writeFileSync(path.join(evidenceDir, "assertions.json"), `${JSON.stringify(visualAssertions, null, 2)}\n`); + const dom = await evaluate("document.documentElement.outerHTML"); + fs.writeFileSync(path.join(evidenceDir, "rendered-dom.html"), dom); + socket.close(); + if (!visualAssertions.passed) throw new Error("Visual assertions failed"); +} + +let exitCode = 0; +try { + await main(); +} catch (error) { + exitCode = 1; + log("failure", { message: String(error?.stack ?? error) }); + if (!visualAssertions) { + visualAssertions = { passed: false, error: String(error?.message ?? error) }; + fs.writeFileSync(path.join(evidenceDir, "assertions.json"), `${JSON.stringify(visualAssertions, null, 2)}\n`); + } +} finally { + const cleanupReceipt = await cleanup(); + fs.writeFileSync(path.join(evidenceDir, "transcript.json"), `${JSON.stringify(transcript, null, 2)}\n`); + if (cleanupReceipt.processInspection.inspectionFailed || !cleanupReceipt.workspaceAbsent || !cleanupReceipt.noLeftoverChildren || !cleanupReceipt.scratchDirRemoved || !cleanupReceipt.chromeProfileRemoved) exitCode = 1; +} + +if (exitCode === 0) console.log("Visual QA script passed."); +process.exit(exitCode); diff --git a/src/__tests__/minimal-topology.test.ts b/src/__tests__/minimal-topology.test.ts index a087261..4a6777b 100644 --- a/src/__tests__/minimal-topology.test.ts +++ b/src/__tests__/minimal-topology.test.ts @@ -1,8 +1,10 @@ -import { readFileSync } from "fs"; +import { existsSync, readFileSync } from "fs"; import { join } from "path"; import { describe, expect, it } from "vitest"; +import { execFileSync } from "child_process"; type Manifest = { + readonly version: string; readonly activationEvents?: readonly string[]; readonly contributes: { readonly commands?: readonly unknown[]; @@ -20,6 +22,7 @@ type Manifest = { }; readonly dependencies: Readonly>; readonly devDependencies: Readonly>; + readonly scripts: Readonly>; }; function readManifest(): Manifest { @@ -34,19 +37,47 @@ describe("minimal sidebar terminal topology", () => { expect(manifest.activationEvents).toEqual([ "onView:ulw", + "onView:ulw.herdr.spaces", + "onView:ulw.herdr.agents", "onCommand:ulw.toggleEditorLocation", "onCommand:ulw.sendSelectionToTerminal", "onCommand:ulw.sendFileToTerminal", + "onCommand:ulw.attachHerdrSession", + "onCommand:ulw.detachHerdrSession", + "onCommand:ulw.herdr.openAgent", + "onCommand:ulw.herdr.openSpace", + "onCommand:ulw.herdr.refreshExplorer", "onStartupFinished", ]); - expect(Object.keys(manifest.contributes.viewsContainers)).toEqual([ + expect(Object.keys(manifest.contributes.viewsContainers).sort()).toEqual([ + "activitybar", "secondarySidebar", ]); expect(manifest.contributes.viewsContainers.secondarySidebar).toEqual([ - expect.objectContaining({ id: "ulwContainer" }), + expect.objectContaining({ + id: "ulwContainer", + when: "config.ulw.sidebar.enabled && !config.ulw.herdr.enabled", + }), + ]); + expect(manifest.contributes.viewsContainers.activitybar).toEqual([ + expect.objectContaining({ id: "ulwHerdr" }), ]); expect(manifest.contributes.views.ulwContainer).toEqual([ - expect.objectContaining({ id: "ulw", type: "webview" }), + expect.objectContaining({ + id: "ulw", + type: "webview", + when: "config.ulw.sidebar.enabled && !config.ulw.herdr.enabled", + }), + ]); + expect(manifest.contributes.views["ulwHerdr"]).toEqual([ + expect.objectContaining({ + id: "ulw.herdr.spaces", + when: "config.ulw.herdr.enabled", + }), + expect.objectContaining({ + id: "ulw.herdr.agents", + when: "config.ulw.herdr.enabled", + }), ]); }); @@ -60,6 +91,11 @@ describe("minimal sidebar terminal topology", () => { const commandIds = commands.map((c) => c.command).sort(); expect(commandIds).toEqual([ + "ulw.attachHerdrSession", + "ulw.detachHerdrSession", + "ulw.herdr.openAgent", + "ulw.herdr.openSpace", + "ulw.herdr.refreshExplorer", "ulw.sendFileToTerminal", "ulw.sendSelectionToTerminal", "ulw.toggleEditorLocation", @@ -74,13 +110,20 @@ describe("minimal sidebar terminal topology", () => { it("surfaces the location toggle on sidebar and editor title bars", () => { const menus = readManifest().contributes.menus ?? {}; - expect(menus["view/title"]).toEqual([ - expect.objectContaining({ - command: "ulw.toggleEditorLocation", - when: "view == ulw", - group: "navigation", - }), - ]); + expect(menus["view/title"]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + command: "ulw.toggleEditorLocation", + when: "view == ulw", + group: "navigation", + }), + expect.objectContaining({ + command: "ulw.herdr.refreshExplorer", + when: "view == ulw.herdr.spaces || view == ulw.herdr.agents", + group: "navigation", + }), + ]), + ); expect(menus["editor/title"]).toEqual([ expect.objectContaining({ command: "ulw.toggleEditorLocation", @@ -101,10 +144,16 @@ describe("minimal sidebar terminal topology", () => { "ulw.defaultLocation", "ulw.fontFamily", "ulw.fontSize", + "ulw.herdr.enabled", + "ulw.herdr.executablePath", + "ulw.herdr.remoteTarget", + "ulw.herdr.session", + "ulw.herdr.socketPath", "ulw.renderer", "ulw.scrollback", "ulw.shellArgs", "ulw.shellPath", + "ulw.sidebar.enabled", ]); }); @@ -118,4 +167,28 @@ describe("minimal sidebar terminal topology", () => { ]), ); }); + + it("never packages a VSIX without runtime dependencies", () => { + const scripts = JSON.stringify(readManifest().scripts); + expect(scripts).not.toMatch(/--no-dependencies/); + const installer = readFileSync(join(process.cwd(), "dev-install.sh"), "utf8"); + expect(installer).not.toMatch(/--no-dependencies/); + }); + + it("packages node-pty inside the VSIX because webpack leaves it external", () => { + const webpack = readFileSync(join(process.cwd(), "webpack.config.js"), "utf8"); + expect(webpack).toMatch(/"node-pty":\s*"commonjs node-pty"/); + const vsixPath = join( + process.cwd(), + `opencode-sidebar-tui-${readManifest().version}.vsix`, + ); + if (!existsSync(vsixPath)) { + return; + } + const listing = execFileSync("unzip", ["-Z1", vsixPath], { + encoding: "utf8", + }); + expect(listing).toMatch(/extension\/node_modules\/node-pty\//); + expect(listing).toMatch(/node-pty\/(?:prebuilds|build|lib)\//); + }); }); diff --git a/src/core/ExtensionLifecycle.test.ts b/src/core/ExtensionLifecycle.test.ts index 49e81e4..59ff619 100644 --- a/src/core/ExtensionLifecycle.test.ts +++ b/src/core/ExtensionLifecycle.test.ts @@ -1,10 +1,25 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import * as vscode from "../test/mocks/vscode"; +import { + HerdrNotInstalledError, + HerdrServerDownError, + HerdrUnsupportedVersionError, +} from "../herdr/errors"; +import { HerdrAttachBusyError } from "../herdr/HerdrAttachController"; +import { HerdrInvocationResolver } from "../herdr/HerdrInvocationResolver"; +import type { HerdrAgent, HerdrInvocation } from "../herdr/types"; +import type { TerminalTransport } from "../terminals/TerminalTransport"; import { TerminalManager } from "../terminals/TerminalManager"; import { ExtensionLifecycle } from "./ExtensionLifecycle"; vi.mock("node-pty", async () => vi.importActual("../test/mocks/node-pty")); +// Herdr listeners fire explorer refreshes without awaiting them; drain the +// resulting microtasks (and their logging) before the next test or teardown. +afterEach(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); +}); + function createContext() { return { extensionUri: vscode.Uri.file("/extension"), @@ -12,6 +27,116 @@ function createContext() { }; } +function commandHandler unknown>(id: string): T { + const handlers = vscode.commands.registerCommand.mock.calls as readonly [ + string, + (...args: never[]) => unknown, + ][]; + const handler = handlers.find(([commandId]) => commandId === id)?.[1]; + expect(handler).toBeDefined(); + return handler as T; +} + +function agent(overrides: Partial = {}): HerdrAgent { + return { + paneId: "pane-1", + terminalId: "terminal-1", + agent: "claude", + status: "running", + title: "Agent one", + cwd: "/workspace/one", + workspaceId: "workspace-1", + ...overrides, + }; +} + +function createHerdrHarness(options: { + agents?: readonly HerdrAgent[]; + workspaces?: readonly { + readonly workspaceId: string; + readonly label: string; + readonly status: string; + readonly paneCount: number; + }[]; + versionError?: Error; + listError?: Error; + attachError?: Error; + herdrEnabled?: boolean; + phase?: "shell" | "attaching" | "attached" | "detaching" | "error"; + explorerPollMs?: number; +} = {}) { + vscode.workspace.workspaceFolders = [{ uri: vscode.Uri.file("/workspace/one") }]; + vscode.setConfiguration({ + "ulw.herdr.enabled": options.herdrEnabled ?? true, + }); + const sourceStateEmitter = new vscode.EventEmitter(); + const createdControllers: Array<{ + sourceState: { source: string; phase: string }; + onSourceState: typeof sourceStateEmitter.event; + attach: ReturnType; + detach: ReturnType; + dispose: ReturnType; + }> = []; + const controller = { + get sourceState() { + const latest = createdControllers[createdControllers.length - 1]; + return latest?.sourceState ?? { + source: options.phase === "shell" || options.phase === undefined ? "shell" : "herdr", + phase: options.phase ?? "shell", + }; + }, + onSourceState: sourceStateEmitter.event, + attach: vi.fn(async (_target?: unknown, _dimensions?: unknown) => { + if (options.attachError) { + throw options.attachError; + } + }), + detach: vi.fn(async () => undefined), + dispose: vi.fn(), + }; + const client = { + versionCheck: vi.fn(async () => { + if (options.versionError) { + throw options.versionError; + } + return { version: "0.8.2" }; + }), + listAgents: vi.fn(async () => { + if (options.listError) { + throw options.listError; + } + return options.agents ?? []; + }), + listWorkspaces: vi.fn(async () => options.workspaces ?? []), + }; + const lifecycle = new ExtensionLifecycle({ + explorerPollMs: options.explorerPollMs ?? 0, + createCliClient: () => client, + createAttachController: () => { + const next = { + sourceState: { + source: options.phase === "shell" || options.phase === undefined ? "shell" : "herdr", + phase: options.phase ?? "shell", + }, + onSourceState: sourceStateEmitter.event, + attach: vi.fn(async (target: unknown, dimensions: unknown) => { + await controller.attach(target, dimensions); + }), + detach: vi.fn(async () => { + await controller.detach(); + }), + dispose: vi.fn(() => { + controller.dispose(); + }), + }; + createdControllers.push(next); + return next as never; + }, + createControlTransport: () => ({}) as TerminalTransport, + }); + return { lifecycle, client, controller }; +} + describe("ExtensionLifecycle", () => { it("registers exactly one secondary-sidebar provider", () => { vscode.resetMocks(); @@ -43,8 +168,8 @@ describe("ExtensionLifecycle", () => { const write = vi.spyOn(manager, "write"); manager["startEmitter"].fire({ id: "sidebar-shell", pid: 42 }); - manager["dataEmitter"].fire({ id: "sidebar-shell", data: "hello" }); - manager["exitEmitter"].fire({ id: "sidebar-shell", code: 3 }); + manager["dataEmitter"].fire({ id: "sidebar-shell", data: "hello", replay: "append" }); + manager["exitEmitter"].fire({ id: "sidebar-shell", code: 3, reason: "process-exit" }); api.writeToTerminal("pwd\r"); expect(start).toHaveBeenCalledWith(42); @@ -136,24 +261,954 @@ describe("ExtensionLifecycle", () => { vscode.resetMocks(); const context = createContext(); const lifecycle = new ExtensionLifecycle(); - const api = lifecycle.activate(context as never); - const provider = lifecycle["provider"] as TerminalProvider; - const writeSpy = vi.spyOn(provider, "write"); - - api.writeToTerminal; - const handlers = vscode.commands.registerCommand.mock.calls as readonly [ - string, - (uri?: { fsPath?: string }) => void, - ][]; - const sendFile = handlers.find( - ([id]) => id === "ulw.sendFileToTerminal", - )?.[1]; - expect(sendFile).toBeDefined(); - - sendFile?.({ fsPath: "/safe/path" }); + lifecycle.activate(context as never); + const provider = lifecycle["provider"]; + const writeSpy = vi.spyOn(provider!, "write"); + const sendFile = commandHandler<(uri?: { fsPath?: string }) => void>( + "ulw.sendFileToTerminal", + ); + + sendFile({ fsPath: "/safe/path" }); expect(writeSpy).toHaveBeenLastCalledWith("'/safe/path'"); - sendFile?.({ fsPath: "name'$(whoami)'" }); + sendFile({ fsPath: "name'$(whoami)'" }); expect(writeSpy).toHaveBeenLastCalledWith("'name'\\''$(whoami)'\\'''"); }); + + it("lists agents and attaches the selected QuickPick target", async () => { + vscode.resetMocks(); + const fallback = agent({ paneId: "pane-2", terminalId: "terminal-2", title: "" }); + const malformedTitle = { ...agent({ paneId: "pane-3", terminalId: "terminal-3" }), title: undefined } as unknown as HerdrAgent; + const { lifecycle, controller } = createHerdrHarness({ + agents: [agent(), fallback, malformedTitle], + }); + lifecycle.activate(createContext() as never); + vscode.window.showQuickPick.mockImplementation(async (items: readonly unknown[]) => items[1]); + + await commandHandler<() => Promise>("ulw.attachHerdrSession")(); + + expect(vscode.window.showQuickPick).toHaveBeenCalledWith( + [ + expect.objectContaining({ + label: "Agent one", + description: "running · workspace-1", + detail: "/workspace/one", + }), + expect.objectContaining({ label: "claude · pane-2" }), + expect.objectContaining({ label: "claude · pane-3" }), + ], + expect.objectContaining({ + title: "Taking control replaces other direct Herdr clients and is not auto-restored", + }), + ); + expect(controller.attach).toHaveBeenCalledWith( + { terminalId: "terminal-2", label: "claude · pane-2" }, + { cols: 80, rows: 24 }, + ); + }); + + it("opens the executable setting when Herdr is not installed", async () => { + vscode.resetMocks(); + vscode.setConfiguration({ "ulw.herdr.executablePath": "/opt/herdr" }); + const { lifecycle } = createHerdrHarness({ + versionError: new HerdrNotInstalledError("herdr default", "/opt/herdr"), + }); + lifecycle.activate(createContext() as never); + vscode.window.showWarningMessage.mockResolvedValueOnce("Open Setting"); + + await commandHandler<() => Promise>("ulw.attachHerdrSession")(); + + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith( + "Herdr executable not found: /opt/herdr", + "Open Setting", + ); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith( + "workbench.action.openSettings", + "ulw.herdr.executablePath", + ); + }); + + it("shows the required version when Herdr is unsupported", async () => { + vscode.resetMocks(); + const { lifecycle } = createHerdrHarness({ + versionError: new HerdrUnsupportedVersionError("herdr default", "0.7.9"), + }); + lifecycle.activate(createContext() as never); + + await commandHandler<() => Promise>("ulw.attachHerdrSession")(); + + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith( + "Herdr 0.8.0 or newer is required (found 0.7.9)", + ); + }); + + it("retries discovery when the configured Herdr server is down", async () => { + vscode.resetMocks(); + vscode.setConfiguration({ "ulw.herdr.socketPath": "/tmp/herdr.sock" }); + const serverDown = new HerdrServerDownError( + "socket /tmp/herdr.sock", + "offline", + ); + const freshAgent = agent({ + paneId: "pane-retry", + terminalId: "terminal-retry", + title: "Retry target", + }); + const { lifecycle, client, controller } = createHerdrHarness(); + lifecycle.activate(createContext() as never); + client.versionCheck.mockResolvedValue({ version: "0.8.2" }); + client.listAgents.mockReset(); + client.listAgents + .mockRejectedValueOnce(serverDown) + .mockResolvedValue([freshAgent]); + vscode.window.showWarningMessage.mockResolvedValueOnce("Retry"); + vscode.window.showQuickPick.mockImplementation( + async (items: readonly unknown[]) => items[0], + ); + + await commandHandler<() => Promise>("ulw.attachHerdrSession")(); + + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith( + "Herdr session default is not running (socket /tmp/herdr.sock)", + "Retry", + ); + expect(client.versionCheck).toHaveBeenCalled(); + expect(client.listAgents).toHaveBeenCalled(); + expect(vscode.window.showQuickPick).toHaveBeenCalledWith( + [ + expect.objectContaining({ + label: "Retry target", + description: "running · workspace-1", + detail: "/workspace/one", + }), + ], + expect.objectContaining({ placeHolder: "Select a running Herdr agent" }), + ); + expect(controller.attach).toHaveBeenCalledWith( + { terminalId: "terminal-retry", label: "Retry target" }, + { cols: 80, rows: 24 }, + ); + }); + + it("shows an empty picker when no agents are running", async () => { + vscode.resetMocks(); + vscode.setConfiguration({ "ulw.herdr.session": "team" }); + const { lifecycle } = createHerdrHarness(); + lifecycle.activate(createContext() as never); + + await commandHandler<() => Promise>("ulw.attachHerdrSession")(); + + expect(vscode.window.showQuickPick).toHaveBeenCalledWith( + [], + expect.objectContaining({ + placeHolder: "No running Herdr agents in session team", + }), + ); + }); + + it("reopens the picker for a stale selected target without detaching the shell", async () => { + vscode.resetMocks(); + const stale = new Error("terminal target pane-1 not found"); + const { lifecycle, client, controller } = createHerdrHarness({ + agents: [agent()], + attachError: stale, + }); + lifecycle.activate(createContext() as never); + vscode.window.showQuickPick.mockImplementation(async (items: readonly unknown[]) => items[0]); + vscode.window.showWarningMessage.mockResolvedValueOnce("Choose Again"); + + await commandHandler<() => Promise>("ulw.attachHerdrSession")(); + + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith( + "The selected Herdr agent is no longer running", + "Choose Again", + ); + expect(client.listAgents.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(controller.detach).not.toHaveBeenCalled(); + }); + + it("reports busy attach attempts for the selected agent", async () => { + vscode.resetMocks(); + const busy = createHerdrHarness({ + agents: [agent()], + attachError: new HerdrAttachBusyError(), + }); + busy.lifecycle.activate(createContext() as never); + vscode.window.showQuickPick.mockImplementation(async (items: readonly unknown[]) => items[0]); + await commandHandler<() => Promise>("ulw.attachHerdrSession")(); + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + "Already attached to a Herdr session", + ); + }); + + it("maps every herdr attach failure to its exact UI response", async () => { + const rows: readonly { + readonly name: string; + readonly run: () => Promise; + }[] = [ + { + name: "HerdrNotInstalledError", + run: async () => { + vscode.setConfiguration({ "ulw.herdr.executablePath": "/opt/herdr" }); + const { lifecycle, controller } = createHerdrHarness({ + versionError: new HerdrNotInstalledError( + "herdr default", + "/opt/herdr", + ), + }); + lifecycle.activate(createContext() as never); + vscode.window.showWarningMessage.mockResolvedValueOnce("Open Setting"); + + await commandHandler<() => Promise>( + "ulw.attachHerdrSession", + )(); + + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith( + "Herdr executable not found: /opt/herdr", + "Open Setting", + ); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith( + "workbench.action.openSettings", + "ulw.herdr.executablePath", + ); + expect(controller.detach).not.toHaveBeenCalled(); + }, + }, + { + name: "HerdrUnsupportedVersionError", + run: async () => { + const { lifecycle, controller } = createHerdrHarness({ + versionError: new HerdrUnsupportedVersionError( + "herdr default", + "0.7.9", + ), + }); + lifecycle.activate(createContext() as never); + + await commandHandler<() => Promise>( + "ulw.attachHerdrSession", + )(); + + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith( + "Herdr 0.8.0 or newer is required (found 0.7.9)", + ); + expect(controller.detach).not.toHaveBeenCalled(); + }, + }, + { + name: "HerdrServerDownError", + run: async () => { + vscode.setConfiguration({ + "ulw.herdr.session": "team", + "ulw.herdr.socketPath": "", + }); + const { lifecycle, controller } = createHerdrHarness({ + versionError: new HerdrServerDownError("session team", "offline"), + }); + lifecycle.activate(createContext() as never); + + await commandHandler<() => Promise>( + "ulw.attachHerdrSession", + )(); + + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith( + "Herdr session team is not running (session team)", + "Retry", + ); + expect(controller.detach).not.toHaveBeenCalled(); + }, + }, + { + name: "no agents", + run: async () => { + vscode.setConfiguration({ "ulw.herdr.session": "team" }); + const { lifecycle, controller } = createHerdrHarness(); + lifecycle.activate(createContext() as never); + + await commandHandler<() => Promise>( + "ulw.attachHerdrSession", + )(); + + expect(vscode.window.showQuickPick).toHaveBeenCalledWith( + [], + expect.objectContaining({ + placeHolder: "No running Herdr agents in session team", + }), + ); + expect(controller.attach).not.toHaveBeenCalled(); + expect(controller.detach).not.toHaveBeenCalled(); + }, + }, + { + name: "stale target", + run: async () => { + const { lifecycle, controller } = createHerdrHarness({ + agents: [agent()], + attachError: new Error("terminal target pane-1 not found"), + }); + lifecycle.activate(createContext() as never); + vscode.window.showQuickPick.mockImplementation( + async (items: readonly unknown[]) => items[0], + ); + + await commandHandler<() => Promise>( + "ulw.attachHerdrSession", + )(); + + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith( + "The selected Herdr agent is no longer running", + "Choose Again", + ); + expect(controller.detach).not.toHaveBeenCalled(); + }, + }, + { + name: "busy", + run: async () => { + const { lifecycle, controller } = createHerdrHarness({ + agents: [agent()], + attachError: new HerdrAttachBusyError(), + }); + lifecycle.activate(createContext() as never); + vscode.window.showQuickPick.mockImplementation( + async (items: readonly unknown[]) => items[0], + ); + + await commandHandler<() => Promise>( + "ulw.attachHerdrSession", + )(); + + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + "Already attached to a Herdr session", + ); + expect(controller.attach).toHaveBeenCalledOnce(); + }, + }, + ]; + + for (const row of rows) { + vscode.resetMocks(); + await row.run(); + } + }); + + it("warns once when a named session overrides a configured socket", async () => { + vscode.resetMocks(); + vscode.setConfiguration({ + "ulw.herdr.session": "team", + "ulw.herdr.socketPath": "/tmp/ignored.sock", + }); + const { lifecycle } = createHerdrHarness(); + lifecycle.activate(createContext() as never); + + await commandHandler<() => Promise>("ulw.attachHerdrSession")(); + + expect(vscode.window.showWarningMessage).toHaveBeenCalledOnce(); + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith( + 'Herdr session "team" is configured; socketPath "/tmp/ignored.sock" is ignored.', + ); + }); + + it("manages the ssh forward only on remote windows with a configured target", async () => { + const makeHarness = () => { + const resolveInvocation = vi.fn( + (input: Parameters[0]) => + HerdrInvocationResolver.resolve(input), + ); + const createCliClient = vi.fn(() => ({ + versionCheck: async () => ({ version: "0.8.2" }), + listAgents: async () => [], + listWorkspaces: async () => [], + })); + const forwards: Array<{ + start: ReturnType; + dispose: ReturnType; + }> = []; + const createSocketForward = vi.fn((options: { target: string }) => { + const index = forwards.length; + const forward = { + options, + start: vi.fn(async () => ({ + apiSocketPath: `/tmp/f-${index}.sock`, + clientSocketPath: `/tmp/f-${index}-client.sock`, + })), + dispose: vi.fn(), + }; + forwards.push(forward); + return forward; + }); + const lifecycle = new ExtensionLifecycle({ + env: { PATH: undefined }, + platform: "darwin", + resolveInvocation, + createCliClient, + createSocketForward, + }); + return { resolveInvocation, createCliClient, createSocketForward, forwards, lifecycle }; + }; + const setConfig = (target: string) => { + vscode.setConfiguration({ + "ulw.herdr.enabled": true, + "ulw.herdr.executablePath": "herdr", + "ulw.herdr.remoteTarget": target, + }); + }; + + vscode.resetMocks(); + setConfig("u@h"); + vscode.env.remoteName = "ssh-remote+203.0.113.7"; + const remote = makeHarness(); + try { + remote.lifecycle.activate(createContext() as never); + await vi.waitFor(() => { + expect(remote.resolveInvocation).toHaveBeenCalled(); + }); + expect(remote.createSocketForward).toHaveBeenCalledWith( + expect.objectContaining({ target: "u@h" }), + ); + await vi.waitFor(() => { + expect( + remote.resolveInvocation.mock.lastCall?.[0].forwardSockets, + ).toEqual({ + apiSocketPath: "/tmp/f-0.sock", + clientSocketPath: "/tmp/f-0-client.sock", + }); + }); + expect(remote.resolveInvocation.mock.lastCall?.[0].remoteTarget).toBe("u@h"); + + setConfig("other@h"); + vscode.fireConfigurationChange("ulw.herdr"); + await vi.waitFor(() => { + expect(remote.forwards.length).toBe(2); + expect(remote.forwards[0]?.dispose).toHaveBeenCalled(); + }); + await vi.waitFor(() => { + expect( + remote.resolveInvocation.mock.lastCall?.[0].forwardSockets, + ).toEqual({ + apiSocketPath: "/tmp/f-1.sock", + clientSocketPath: "/tmp/f-1-client.sock", + }); + }); + + setConfig(""); + vscode.fireConfigurationChange("ulw.herdr"); + await vi.waitFor(() => { + expect(remote.forwards[1]?.dispose).toHaveBeenCalled(); + expect( + remote.resolveInvocation.mock.lastCall?.[0].forwardSockets, + ).toBeUndefined(); + }); + } finally { + remote.lifecycle.dispose(); + } + expect(remote.forwards[1]?.dispose).toHaveBeenCalledTimes(1); + + vscode.resetMocks(); + setConfig("u@h"); + const local = makeHarness(); + try { + local.lifecycle.activate(createContext() as never); + await vi.waitFor(() => { + expect(local.resolveInvocation).toHaveBeenCalled(); + }); + expect(local.createSocketForward).not.toHaveBeenCalled(); + for (const [input] of local.resolveInvocation.mock.calls) { + expect(input.forwardSockets).toBeUndefined(); + expect(input.remoteTarget).toBeUndefined(); + } + } finally { + local.lifecycle.dispose(); + } + }); + + it("re-resolves the Herdr invocation and client when Herdr settings change at runtime", async () => { + vscode.resetMocks(); + vscode.setConfiguration({ + "ulw.herdr.enabled": true, + "ulw.herdr.executablePath": "herdr", + "ulw.herdr.remoteTarget": "", + }); + const clients: Array<{ + versionCheck: ReturnType; + listAgents: ReturnType; + listWorkspaces: ReturnType; + }> = []; + const resolveInvocation = vi.fn( + (input: Parameters[0]) => + HerdrInvocationResolver.resolve(input), + ); + const createCliClient = vi.fn(() => { + const client = { + versionCheck: vi.fn(async () => ({ version: "0.8.2" })), + listAgents: vi.fn(async () => [] as HerdrAgent[]), + listWorkspaces: vi.fn(async () => []), + }; + clients.push(client); + return client; + }); + const lifecycle = new ExtensionLifecycle({ + env: { PATH: undefined }, + platform: "darwin", + explorerPollMs: 0, + resolveInvocation, + createCliClient, + }); + try { + lifecycle.activate(createContext() as never); + await vi.waitFor(() => { + expect(clients[1]?.listAgents).toHaveBeenCalled(); + }); + + vscode.setConfiguration({ + "ulw.herdr.enabled": true, + "ulw.herdr.executablePath": "herdr", + "ulw.herdr.remoteTarget": "ops@box", + }); + vscode.fireConfigurationChange("ulw.herdr"); + await vi.waitFor(() => { + expect(createCliClient).toHaveBeenCalledTimes(3); + }); + await vi.waitFor(() => { + expect(clients[2]?.listAgents).toHaveBeenCalled(); + }); + + vscode.setConfiguration({ + "ulw.herdr.enabled": true, + "ulw.herdr.executablePath": "herdr", + "ulw.herdr.remoteTarget": "", + }); + vscode.fireConfigurationChange("ulw.herdr"); + await vi.waitFor(() => { + expect(createCliClient).toHaveBeenCalledTimes(4); + }); + expect(resolveInvocation.mock.lastCall?.[0].remoteTarget).toBeUndefined(); + // Drain the fire-and-forget refresh the listener started so its logging + // cannot race worker teardown after dispose. + const agentResults = clients[3]?.listAgents.mock.results ?? []; + const workspaceResults = clients[3]?.listWorkspaces.mock.results ?? []; + await agentResults[agentResults.length - 1]?.value; + await workspaceResults[workspaceResults.length - 1]?.value; + await new Promise((resolve) => setTimeout(resolve, 0)); + } finally { + lifecycle.dispose(); + } + }); + + it("does not continue the herdr bootstrap after lifecycle disposal", async () => { + vscode.resetMocks(); + vscode.setConfiguration({ + "ulw.herdr.enabled": true, + "ulw.herdr.executablePath": "herdr", + "ulw.herdr.remoteTarget": "u@h", + }); + vscode.env.remoteName = "ssh-remote+203.0.113.7"; + const resolveInvocation = vi.fn( + (input: Parameters[0]) => + HerdrInvocationResolver.resolve(input), + ); + const createCliClient = vi.fn(() => ({ + versionCheck: async () => ({ version: "0.8.2" }), + listAgents: async () => [], + listWorkspaces: async () => [], + })); + let releaseStart: (sockets: { + apiSocketPath: string; + clientSocketPath: string; + }) => void = () => undefined; + const start = vi.fn( + () => + new Promise<{ apiSocketPath: string; clientSocketPath: string }>( + (resolve) => { + releaseStart = resolve; + }, + ), + ); + const dispose = vi.fn(); + const lifecycle = new ExtensionLifecycle({ + env: { PATH: undefined }, + platform: "darwin", + explorerPollMs: 0, + resolveInvocation, + createCliClient, + createSocketForward: vi.fn(() => ({ start, dispose })), + }); + + lifecycle.activate(createContext() as never); + await vi.waitFor(() => { + expect(start).toHaveBeenCalled(); + }); + const clientsBeforeDispose = createCliClient.mock.calls.length; + const resolvesBeforeDispose = resolveInvocation.mock.calls.length; + lifecycle.dispose(); + releaseStart({ + apiSocketPath: "/tmp/f-late.sock", + clientSocketPath: "/tmp/f-late-client.sock", + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(createCliClient).toHaveBeenCalledTimes(clientsBeforeDispose); + expect(resolveInvocation).toHaveBeenCalledTimes(resolvesBeforeDispose); + expect(dispose).toHaveBeenCalled(); + }); + + it("passes explicit settings through the resolver with a stripped environment and shares invocation with the bridge", async () => { + vscode.resetMocks(); + vscode.setConfiguration({ + "ulw.herdr.enabled": true, + "ulw.herdr.executablePath": "/Applications/Herdr/bin/herdr", + "ulw.herdr.socketPath": "/private/tmp/herdr.sock", + "ulw.herdr.session": "", + }); + const resolveInvocation = vi.fn( + (input: Parameters[0]) => + HerdrInvocationResolver.resolve(input), + ); + let discoveryInvocation: HerdrInvocation | undefined; + let bridgeInvocation: HerdrInvocation | undefined; + const createControlTransport = vi.fn((options: { invocation: HerdrInvocation }) => { + bridgeInvocation = options.invocation; + return {} as TerminalTransport; + }); + const sourceStateEmitter = new vscode.EventEmitter(); + const lifecycle = new ExtensionLifecycle({ + env: { PATH: undefined, HERDR_SOCKET_PATH: undefined }, + platform: "darwin", + resolveInvocation, + createCliClient: (invocation) => { + discoveryInvocation = invocation; + return { + versionCheck: async () => ({ version: "0.8.2" }), + listAgents: async () => [agent({ terminalId: "terminal-explicit" })], + listWorkspaces: async () => [], + }; + }, + createControlTransport, + createAttachController: (options) => { + return { + sourceState: { source: "shell", phase: "shell" }, + onSourceState: sourceStateEmitter.event, + attach: vi.fn(async (target: { terminalId: string }) => { + options.transportFactory(target, { cols: 80, rows: 24 }); + }), + detach: vi.fn(), + dispose: vi.fn(), + } as never; + }, + }); + + lifecycle.activate(createContext() as never); + vscode.window.showQuickPick.mockImplementation(async (items: readonly unknown[]) => items[0]); + await commandHandler<() => Promise>("ulw.attachHerdrSession")(); + + expect(resolveInvocation).toHaveBeenCalledWith({ + executablePath: "/Applications/Herdr/bin/herdr", + session: "", + socketPath: "/private/tmp/herdr.sock", + env: { PATH: undefined, HERDR_SOCKET_PATH: undefined }, + platform: "darwin", + }); + expect(discoveryInvocation).toEqual( + expect.objectContaining({ + command: "/Applications/Herdr/bin/herdr", + argsPrefix: [], + env: { HERDR_SOCKET_PATH: "/private/tmp/herdr.sock" }, + }), + ); + expect(bridgeInvocation).toBe(discoveryInvocation); + }); + + it("places a named session in both discovery and bridge invocation", async () => { + vscode.resetMocks(); + vscode.setConfiguration({ + "ulw.herdr.enabled": true, + "ulw.herdr.session": "team", + }); + let discoveryInvocation: HerdrInvocation | undefined; + let bridgeInvocation: HerdrInvocation | undefined; + const sourceStateEmitter = new vscode.EventEmitter(); + const lifecycle = new ExtensionLifecycle({ + createCliClient: (invocation) => { + discoveryInvocation = invocation; + return { + versionCheck: async () => ({ version: "0.8.2" }), + listAgents: async () => [agent()], + listWorkspaces: async () => [], + }; + }, + createControlTransport: (options) => { + bridgeInvocation = options.invocation; + return {} as TerminalTransport; + }, + createAttachController: (options) => { + return { + sourceState: { source: "shell", phase: "shell" }, + onSourceState: sourceStateEmitter.event, + attach: vi.fn(async (target: { terminalId: string }) => { + options.transportFactory(target, { cols: 80, rows: 24 }); + }), + detach: vi.fn(), + dispose: vi.fn(), + } as never; + }, + }); + + lifecycle.activate(createContext() as never); + vscode.window.showQuickPick.mockImplementation(async (items: readonly unknown[]) => items[0]); + await commandHandler<() => Promise>("ulw.attachHerdrSession")(); + + expect(discoveryInvocation?.argsPrefix).toEqual(["--session", "team"]); + expect(bridgeInvocation?.argsPrefix).toEqual(["--session", "team"]); + }); + + it("detaches only when a Herdr source is active", async () => { + vscode.resetMocks(); + const shell = createHerdrHarness(); + shell.lifecycle.activate(createContext() as never); + await commandHandler<() => Promise>("ulw.detachHerdrSession")(); + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + "Not attached to a Herdr session", + ); + expect(shell.controller.detach).not.toHaveBeenCalled(); + + vscode.resetMocks(); + const attached = createHerdrHarness({ phase: "attached" }); + attached.lifecycle.activate(createContext() as never); + await commandHandler<(node: { kind: "agent"; agent: HerdrAgent }) => Promise>( + "ulw.herdr.openAgent", + )({ kind: "agent", agent: agent() }); + await commandHandler<() => Promise>("ulw.detachHerdrSession")(); + expect(attached.controller.detach).toHaveBeenCalledOnce(); + }); + + it("does not load Herdr agents until the user enables Herdr", async () => { + vscode.resetMocks(); + const { client, lifecycle } = createHerdrHarness({ + agents: [agent()], + herdrEnabled: false, + }); + lifecycle.activate(createContext() as never); + await Promise.resolve(); + expect(client.listAgents).not.toHaveBeenCalled(); + expect(client.listWorkspaces).not.toHaveBeenCalled(); + }); + + it("loads Spaces and Agents when Herdr is enabled", async () => { + vscode.resetMocks(); + const { client, lifecycle } = createHerdrHarness({ agents: [agent()] }); + lifecycle.activate(createContext() as never); + await vi.waitFor(() => { + expect(client.listWorkspaces).toHaveBeenCalledOnce(); + expect(client.listAgents).toHaveBeenCalledOnce(); + }); + }); + + it("polls Spaces and Agents while Herdr stays enabled", async () => { + vscode.resetMocks(); + vi.useFakeTimers(); + const { client, lifecycle } = createHerdrHarness({ + agents: [agent()], + explorerPollMs: 2_000, + }); + try { + lifecycle.activate(createContext() as never); + await vi.waitFor(() => { + expect(client.listAgents).toHaveBeenCalledOnce(); + }); + await vi.advanceTimersByTimeAsync(2_000); + expect(client.listAgents).toHaveBeenCalledTimes(2); + expect(client.listWorkspaces).toHaveBeenCalledTimes(2); + lifecycle.dispose(); + await vi.advanceTimersByTimeAsync(4_000); + expect(client.listAgents).toHaveBeenCalledTimes(2); + } finally { + lifecycle.dispose(); + vi.useRealTimers(); + } + }); + + it("loads Spaces and Agents after the user enables Herdr at runtime", async () => { + vscode.resetMocks(); + const { client, lifecycle } = createHerdrHarness({ + agents: [agent()], + herdrEnabled: false, + }); + lifecycle.activate(createContext() as never); + await Promise.resolve(); + expect(client.listAgents).not.toHaveBeenCalled(); + + vscode.setConfiguration({ "ulw.herdr.enabled": true }); + vscode.fireConfigurationChange("ulw.herdr.enabled"); + await vi.waitFor(() => { + expect(client.listWorkspaces).toHaveBeenCalledOnce(); + expect(client.listAgents).toHaveBeenCalledOnce(); + }); + }); + + it("registers Spaces and Agents trees and attaches from an agent node", async () => { + vscode.resetMocks(); + vscode.setConfiguration({ "ulw.herdr.enabled": true }); + vscode.workspace.workspaceFolders = [{ uri: vscode.Uri.file("/workspace/one") }]; + const target = agent(); + const { lifecycle, controller } = createHerdrHarness({ + agents: [target], + workspaces: [ + { + workspaceId: "workspace-1", + label: "one", + status: "working", + paneCount: 1, + }, + ], + }); + lifecycle.activate(createContext() as never); + + expect(vscode.window.registerTreeDataProvider).toHaveBeenCalledWith( + "ulw.herdr.spaces", + expect.anything(), + ); + expect(vscode.window.registerTreeDataProvider).toHaveBeenCalledWith( + "ulw.herdr.agents", + expect.anything(), + ); + await commandHandler<() => Promise>("ulw.herdr.refreshExplorer")(); + + await commandHandler<(node: { + kind: "agent"; + agent: HerdrAgent; + }) => Promise>("ulw.herdr.openAgent")({ + kind: "agent", + agent: target, + }); + expect(controller.attach).toHaveBeenCalledWith( + { terminalId: "terminal-1", label: "Agent one" }, + { cols: 80, rows: 24 }, + ); + expect(vscode.window.createWebviewPanel).toHaveBeenCalledWith( + "ulw.terminalEditor", + "Agent one", + vscode.ViewColumn.Active, + expect.objectContaining({ enableScripts: true }), + ); + expect(vscode.window.showQuickPick).not.toHaveBeenCalled(); + + await commandHandler<(node: { + kind: "space"; + space: { + readonly workspaceId: string; + readonly label: string; + readonly status: string; + readonly paneCount: number; + }; + }) => Promise>("ulw.herdr.openSpace")({ + kind: "space", + space: { + workspaceId: "workspace-1", + label: "one", + status: "working", + paneCount: 1, + }, + }); + expect(controller.attach).toHaveBeenCalledTimes(1); + expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith( + "vscode.openFolder", + expect.anything(), + expect.anything(), + ); + }); + + it("opens another space folder in a new window instead of attaching", async () => { + vscode.resetMocks(); + const foreign = agent({ + cwd: "/tmp/other-space", + workspaceId: "workspace-2", + terminalId: "terminal-2", + }); + const { lifecycle, controller } = createHerdrHarness({ + agents: [foreign], + workspaces: [ + { + workspaceId: "workspace-2", + label: "other", + status: "idle", + paneCount: 1, + }, + ], + }); + lifecycle.activate(createContext() as never); + await commandHandler<() => Promise>("ulw.herdr.refreshExplorer")(); + + await commandHandler<(node: { + kind: "space"; + space: { + readonly workspaceId: string; + readonly label: string; + readonly status: string; + readonly paneCount: number; + }; + }) => Promise>("ulw.herdr.openSpace")({ + kind: "space", + space: { + workspaceId: "workspace-2", + label: "other", + status: "idle", + paneCount: 1, + }, + }); + expect(controller.attach).not.toHaveBeenCalled(); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith( + "vscode.openFolder", + expect.objectContaining({ fsPath: expect.stringContaining("other-space") }), + { forceNewWindow: true }, + ); + + await commandHandler<(node: { + kind: "agent"; + agent: HerdrAgent; + }) => Promise>("ulw.herdr.openAgent")({ + kind: "agent", + agent: foreign, + }); + expect(controller.attach).not.toHaveBeenCalled(); + const folderOpens = vscode.commands.executeCommand.mock.calls.filter( + (call) => call[0] === "vscode.openFolder", + ); + expect(folderOpens).toHaveLength(2); + }); + + it("opens each same-space agent in its own editor tab and skips the sidebar shell", async () => { + vscode.resetMocks(); + vscode.setConfiguration({ "ulw.herdr.enabled": true }); + vscode.workspace.workspaceFolders = [{ uri: vscode.Uri.file("/workspace/one") }]; + const first = agent(); + const second = agent({ + paneId: "pane-2", + terminalId: "terminal-2", + title: "Agent two", + }); + const { lifecycle } = createHerdrHarness({ + agents: [first, second], + }); + lifecycle.activate(createContext() as never); + + expect(vscode.window.createWebviewPanel).not.toHaveBeenCalled(); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith( + "workbench.action.closeAuxiliaryBar", + ); + + const open = commandHandler<(node: { + kind: "agent"; + agent: HerdrAgent; + }) => Promise>("ulw.herdr.openAgent"); + await open({ kind: "agent", agent: first }); + await open({ kind: "agent", agent: second }); + await open({ kind: "agent", agent: first }); + + const titles = vscode.window.createWebviewPanel.mock.calls.map((call) => call[1]); + expect(titles).toEqual(["Agent one", "Agent two"]); + const firstPanel = vscode.window.createWebviewPanel.mock.results[0]?.value as { + reveal: ReturnType; + }; + expect(firstPanel.reveal).toHaveBeenCalled(); + }); }); diff --git a/src/core/ExtensionLifecycle.ts b/src/core/ExtensionLifecycle.ts index 0a01e67..696b624 100644 --- a/src/core/ExtensionLifecycle.ts +++ b/src/core/ExtensionLifecycle.ts @@ -1,31 +1,239 @@ +import { execFile } from "child_process"; +import { randomUUID } from "crypto"; +import { tmpdir } from "os"; +import { join } from "path"; import * as vscode from "vscode"; +import { HerdrCliClient } from "../herdr/HerdrCliClient"; +import { + HerdrAttachBusyError, + HerdrAttachController, + herdrSessionId, + type HerdrAttachControllerOptions, + type HerdrAttachPresenter, + type HerdrAttachTarget, + type SourceState, +} from "../herdr/HerdrAttachController"; +import { + HerdrNotInstalledError, + HerdrServerDownError, + HerdrUnsupportedVersionError, +} from "../herdr/errors"; +import { + HerdrControlTransport, + type HerdrControlTransportOptions, +} from "../herdr/HerdrControlTransport"; +import { HerdrInvocationResolver } from "../herdr/HerdrInvocationResolver"; +import { + HerdrSshForward, + type HerdrSshForwardOptions, +} from "../herdr/HerdrSshForward"; +import { + agentAttachLabel, + HerdrAgentsTreeProvider, + HerdrSnapshotStore, + HerdrSpacesTreeProvider, + inferSpaceRoot, + isCurrentWindowRoot, + type HerdrAgentNode, + type HerdrSpaceNode, +} from "../herdr/HerdrExplorer"; +import type { + HerdrAgent, + HerdrCommandRunner, + HerdrInvocation, + HerdrInvocationInput, + HerdrPlatform, + HerdrSocketForward, + HerdrSpace, +} from "../herdr/types"; import { TerminalProvider } from "../providers/TerminalProvider"; +import type { TerminalTransport } from "../terminals/TerminalTransport"; import { TerminalManager } from "../terminals/TerminalManager"; +const DEFAULT_DIMENSIONS = { cols: 80, rows: 24 } as const; +const EXPLORER_POLL_MS = 2_000; +const TAKEOVER_DISCLOSURE = + "Taking control replaces other direct Herdr clients and is not auto-restored"; + function shellQuote(value: string): string { return `'${value.replace(/'/g, "'\\''")}'`; } +interface HerdrCli { + versionCheck(): Promise<{ readonly version: string }>; + listAgents(): Promise; + listWorkspaces(): Promise; +} + +export interface HerdrSocketForwardHandle { + start(): Promise; + dispose(): void; +} + +interface ExtensionLifecycleOptions { + readonly env?: Readonly>; + readonly platform?: HerdrPlatform; + readonly resolveInvocation?: (input: HerdrInvocationInput) => HerdrInvocation; + readonly runCommand?: HerdrCommandRunner; + readonly createCliClient?: (invocation: HerdrInvocation) => HerdrCli; + readonly createSocketForward?: ( + options: HerdrSshForwardOptions, + ) => HerdrSocketForwardHandle; + readonly createControlTransport?: ( + options: HerdrControlTransportOptions, + ) => TerminalTransport; + readonly createAttachController?: ( + options: HerdrAttachControllerOptions, + ) => HerdrAttachController; + readonly explorerPollMs?: number; +} + +interface HerdrQuickPickItem extends vscode.QuickPickItem { + readonly agent: HerdrAgent; +} + +interface HerdrControllerFactory { + (sessionId: string, presenter: HerdrAttachPresenter): HerdrAttachController; +} + export interface UlwExtensionApi { readonly onTerminalStart: vscode.Event; readonly onTerminalData: vscode.Event; readonly onTerminalExit: vscode.Event; + readonly onSourceState: vscode.Event; isTerminalRunning(): boolean; terminalCount(): number; writeToTerminal(data: string): void; toggleEditorLocation(): void; + attachToHerdr(target: HerdrAttachTarget): Promise; + detachHerdr(): Promise; + resizeTerminal(cols: number, rows: number): void; + getSurfaceSnapshot(): { + readonly sourceState: SourceState; + readonly renderedText: string; + }; + getExplorerSnapshot(): { + readonly spaces: readonly HerdrSpace[]; + readonly agents: readonly HerdrAgent[]; + }; + refreshExplorer(): Promise; } export class ExtensionLifecycle implements vscode.Disposable { private terminalManager: TerminalManager | undefined; private provider: TerminalProvider | undefined; + private explorerStore: HerdrSnapshotStore | undefined; + private readonly herdrControllers = new Map(); + private activeForward: HerdrSocketForwardHandle | undefined; + private herdrGeneration = 0; + private readonly sourceStateEmitter = new vscode.EventEmitter(); private readonly disposables: vscode.Disposable[] = []; + public constructor(private readonly options: ExtensionLifecycleOptions = {}) {} + public activate(context: vscode.ExtensionContext): UlwExtensionApi { const terminalManager = new TerminalManager(); - const provider = new TerminalProvider(context.extensionUri, terminalManager); + let invocation = this.resolveHerdrInvocation(); + let client = this.createCliClient(invocation); + const sharedClient: HerdrCli = { + versionCheck: () => client.versionCheck(), + listAgents: () => client.listAgents(), + listWorkspaces: () => client.listWorkspaces(), + }; + const createControlTransport = + this.options.createControlTransport ?? + ((transportOptions: HerdrControlTransportOptions) => + new HerdrControlTransport(transportOptions)); + const createAttachController = + this.options.createAttachController ?? + ((controllerOptions: HerdrAttachControllerOptions) => + new HerdrAttachController(controllerOptions)); + const explorerStore = new HerdrSnapshotStore(sharedClient); + this.explorerStore = explorerStore; + const provider = new TerminalProvider( + context.extensionUri, + terminalManager, + ); this.terminalManager = terminalManager; this.provider = provider; + const makeController = ( + sessionId: string, + presenter: HerdrAttachPresenter, + ): HerdrAttachController => { + const controller = createAttachController({ + manager: terminalManager, + terminalId: sessionId, + transportFactory: (target, dimensions) => + createControlTransport({ + invocation, + terminalId: target.terminalId, + cols: dimensions.cols, + rows: dimensions.rows, + }), + presenter, + }); + this.herdrControllers.set(sessionId, controller); + this.disposables.push( + controller, + controller.onSourceState((state) => this.sourceStateEmitter.fire(state)), + ); + return controller; + }; + + const bootstrapHerdrRuntime = async (store: HerdrSnapshotStore): Promise => { + this.herdrGeneration += 1; + const generation = this.herdrGeneration; + this.activeForward?.dispose(); + this.activeForward = undefined; + const configuration = vscode.workspace.getConfiguration("ulw"); + const remoteTarget = configuration + .get("herdr.remoteTarget", "") + .trim(); + let forwardSockets: HerdrSocketForward | undefined; + if ( + this.herdrEnabled() && + remoteTarget !== "" && + vscode.env.remoteName !== undefined + ) { + const handle = this.createSocketForward({ + target: remoteTarget, + localApiSocket: join(tmpdir(), `ulw-herdr-${randomUUID()}.sock`), + localClientSocket: join( + tmpdir(), + `ulw-herdr-${randomUUID()}-client.sock`, + ), + }); + this.activeForward = handle; + try { + forwardSockets = await handle.start(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`[ULW Herdr] ssh forward failed: ${message}`); + this.activeForward = undefined; + void vscode.window.showWarningMessage( + `Herdr ssh forward failed: ${message}`, + ); + } + if (generation !== this.herdrGeneration) { + handle.dispose(); + return; + } + } + if (generation !== this.herdrGeneration) { + return; + } + invocation = this.resolveHerdrInvocation( + forwardSockets ? { remoteTarget, forwardSockets } : undefined, + ); + client = this.createCliClient(invocation); + if (this.herdrEnabled()) { + this.startExplorerWatch(store); + void vscode.commands.executeCommand("workbench.action.closeAuxiliaryBar"); + await this.refreshExplorerStore(store); + } else { + store.stopWatch(); + } + }; const dataEmitter = new vscode.EventEmitter(); const exitEmitter = new vscode.EventEmitter(); @@ -41,9 +249,13 @@ export class ExtensionLifecycle implements vscode.Disposable { TerminalProvider.viewType, provider, ), - provider, terminalManager, + provider, + this.sourceStateEmitter, vscode.commands.registerCommand("ulw.toggleEditorLocation", () => { + if (this.herdrEnabled()) { + return; + } provider.toggleEditorLocation(); }), vscode.commands.registerCommand("ulw.sendSelectionToTerminal", () => { @@ -64,18 +276,136 @@ export class ExtensionLifecycle implements vscode.Disposable { } }, ), + vscode.commands.registerCommand("ulw.attachHerdrSession", async () => { + if (!(await this.requireHerdrEnabled())) { + return; + } + await this.attachHerdrSession(client, invocation, makeController); + }), + vscode.commands.registerCommand("ulw.detachHerdrSession", async () => { + if (!(await this.requireHerdrEnabled())) { + return; + } + const active = this.activeHerdrController(); + if (!active || active.sourceState.phase === "shell") { + await vscode.window.showInformationMessage( + "Not attached to a Herdr session", + ); + return; + } + await active.detach(); + }), + vscode.window.registerTreeDataProvider( + "ulw.herdr.spaces", + new HerdrSpacesTreeProvider(explorerStore), + ), + vscode.window.registerTreeDataProvider( + "ulw.herdr.agents", + new HerdrAgentsTreeProvider(explorerStore), + ), + vscode.commands.registerCommand( + "ulw.herdr.openAgent", + async (node: HerdrAgentNode) => { + if (!(await this.requireHerdrEnabled())) { + return; + } + if (await this.openForeignFolderIfNeeded(node.agent.cwd)) { + return; + } + await this.attachSelected(makeController, { + label: agentAttachLabel(node.agent), + agent: node.agent, + }); + }, + ), + vscode.commands.registerCommand( + "ulw.herdr.openSpace", + async (node: HerdrSpaceNode) => { + if (!(await this.requireHerdrEnabled())) { + return; + } + const root = inferSpaceRoot(node.space.workspaceId, explorerStore.agents()); + if (!root) { + await vscode.window.showInformationMessage( + `No folder is associated with ${node.space.label}`, + ); + return; + } + await this.openForeignFolderIfNeeded(root); + }, + ), + vscode.commands.registerCommand("ulw.herdr.refreshExplorer", async () => { + if (!(await this.requireHerdrEnabled())) { + return; + } + await this.refreshExplorerStore(explorerStore); + }), + explorerStore, + vscode.workspace.onDidChangeConfiguration((event) => { + if (!event.affectsConfiguration("ulw.herdr")) { + return; + } + void bootstrapHerdrRuntime(explorerStore); + }), ); context.subscriptions.push(this); - provider.openAtConfiguredLocation(); + if (this.herdrEnabled()) { + void bootstrapHerdrRuntime(explorerStore); + } else { + provider.openAtConfiguredLocation(); + } return { onTerminalStart: startEmitter.event, onTerminalData: dataEmitter.event, onTerminalExit: exitEmitter.event, - isTerminalRunning: () => provider.isRunning(), - terminalCount: () => provider.terminalCount(), + onSourceState: this.sourceStateEmitter.event, + isTerminalRunning: () => + this.herdrEnabled() + ? provider.herdrSessionCount() > 0 + : provider.isRunning(), + terminalCount: () => + this.herdrEnabled() + ? provider.herdrSessionCount() + : provider.terminalCount(), writeToTerminal: (data) => provider.write(data), - toggleEditorLocation: () => provider.toggleEditorLocation(), + toggleEditorLocation: () => { + if (this.herdrEnabled()) { + return; + } + provider.toggleEditorLocation(); + }, + attachToHerdr: (target) => this.openHerdrTarget(makeController, target), + detachHerdr: async () => { + const active = this.activeHerdrController(); + if (active) { + await active.detach(); + } + }, + resizeTerminal: (cols, rows) => { + terminalManager.resize(provider.activeSessionId(), cols, rows); + }, + getSurfaceSnapshot: () => { + const sessionId = provider.activeSessionId(); + const controller = this.herdrControllers.get(sessionId); + return { + sourceState: controller?.sourceState ?? { + source: "shell", + phase: "shell", + }, + renderedText: sanitizeTerminalReplay(terminalManager.replay(sessionId)), + }; + }, + getExplorerSnapshot: () => ({ + spaces: explorerStore.spaces(), + agents: explorerStore.agents(), + }), + refreshExplorer: async () => { + if (!this.herdrEnabled()) { + return; + } + await this.refreshExplorerStore(explorerStore); + }, }; } @@ -84,10 +414,322 @@ export class ExtensionLifecycle implements vscode.Disposable { } public dispose(): void { + this.herdrGeneration += 1; + this.activeForward?.dispose(); + this.activeForward = undefined; for (const disposable of this.disposables.splice(0).reverse()) { disposable.dispose(); } this.provider = undefined; this.terminalManager = undefined; + this.explorerStore = undefined; + } + + private herdrEnabled(): boolean { + return vscode.workspace.getConfiguration("ulw").get("herdr.enabled", false); + } + + private async requireHerdrEnabled(): Promise { + if (this.herdrEnabled()) { + return true; + } + const action = await vscode.window.showInformationMessage( + "Turn on ULW Herdr integration to list Spaces/Agents and attach sessions.", + "Enable", + ); + if (action !== "Enable") { + return false; + } + await vscode.workspace + .getConfiguration("ulw") + .update("herdr.enabled", true, vscode.ConfigurationTarget.Global); + if (this.explorerStore) { + this.startExplorerWatch(this.explorerStore); + await this.refreshExplorerStore(this.explorerStore); + } + return true; } + + private startExplorerWatch(store: HerdrSnapshotStore): void { + const intervalMs = this.options.explorerPollMs ?? EXPLORER_POLL_MS; + if (intervalMs <= 0) { + return; + } + store.startWatch(intervalMs); + } + + private async refreshExplorerStore(store: HerdrSnapshotStore): Promise { + try { + await store.refresh(); + console.info( + `[ULW Herdr] listed ${store.spaces().length} spaces, ${store.agents().length} agents`, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`[ULW Herdr] explorer refresh failed: ${message}`); + await vscode.window.showWarningMessage(message); + } + } + + private resolveHerdrInvocation( + overrides?: { remoteTarget?: string; forwardSockets?: HerdrSocketForward }, + ): HerdrInvocation { + const configuration = vscode.workspace.getConfiguration("ulw"); + const input: HerdrInvocationInput = { + executablePath: configuration.get("herdr.executablePath", "herdr"), + socketPath: configuration.get("herdr.socketPath", ""), + session: configuration.get("herdr.session", ""), + env: this.options.env ?? process.env, + platform: this.options.platform ?? (process.platform as HerdrPlatform), + ...(overrides?.forwardSockets + ? { + forwardSockets: overrides.forwardSockets, + remoteTarget: overrides.remoteTarget ?? "", + } + : {}), + }; + const resolveInvocation = + this.options.resolveInvocation ?? HerdrInvocationResolver.resolve.bind(HerdrInvocationResolver); + return resolveInvocation(input); + } + + private createSocketForward( + options: HerdrSshForwardOptions, + ): HerdrSocketForwardHandle { + if (this.options.createSocketForward) { + return this.options.createSocketForward(options); + } + return new HerdrSshForward(options); + } + + private createCliClient(invocation: HerdrInvocation): HerdrCli { + if (this.options.createCliClient) { + return this.options.createCliClient(invocation); + } + return new HerdrCliClient({ + invocation, + run: this.options.runCommand ?? runHerdrCommand, + }); + } + + private async attachHerdrSession( + client: HerdrCli, + invocation: HerdrInvocation, + makeController: HerdrControllerFactory, + showInvocationWarnings = true, + ): Promise { + if (showInvocationWarnings) { + for (const warning of invocation.warnings) { + await vscode.window.showWarningMessage(warning); + } + } + + try { + await client.versionCheck(); + const agents = await client.listAgents(); + const session = this.configuredSession(); + const items = agents.map((entry) => this.quickPickItem(entry)); + const selected = await vscode.window.showQuickPick(items, { + title: TAKEOVER_DISCLOSURE, + placeHolder: + items.length === 0 + ? `No running Herdr agents in session ${session}` + : "Select a running Herdr agent", + matchOnDescription: true, + matchOnDetail: true, + }); + if (!selected) { + return; + } + + try { + await this.attachSelected(makeController, selected); + } catch (error) { + if (error instanceof HerdrAttachBusyError) { + await vscode.window.showInformationMessage( + "Already attached to a Herdr session", + ); + return; + } + if (this.isStaleTargetError(error)) { + const action = await vscode.window.showWarningMessage( + "The selected Herdr agent is no longer running", + "Choose Again", + ); + if (action === "Choose Again") { + await this.attachHerdrSession(client, invocation, makeController, false); + } + return; + } + throw error; + } + } catch (error) { + await this.showHerdrFailure(error, client, invocation, makeController); + } + } + + private async attachSelected( + makeController: HerdrControllerFactory, + selected: HerdrQuickPickItem, + ): Promise { + await this.openHerdrTarget(makeController, { + terminalId: selected.agent.terminalId, + label: selected.label, + }); + } + + private async openHerdrTarget( + makeController: HerdrControllerFactory, + target: HerdrAttachTarget, + ): Promise { + const provider = this.provider; + if (!provider) { + return; + } + let attachFailure: string | undefined; + await provider.openHerdrSession( + target, + async (sessionTarget) => { + const sessionId = herdrSessionId(sessionTarget.terminalId); + const controller = this.herdrControllers.get(sessionId); + if (!controller) { + throw new Error("Herdr session controller was not created"); + } + const stateSubscription = controller.onSourceState((state) => { + if (state.phase === "error" && state.message) { + attachFailure = state.message; + } + }); + try { + await controller.attach(sessionTarget, DEFAULT_DIMENSIONS); + } finally { + stateSubscription.dispose(); + } + }, + makeController, + ); + if (attachFailure) { + throw new Error(attachFailure); + } + } + + private activeHerdrController(): HerdrAttachController | undefined { + const sessionId = this.provider?.activeSessionId(); + if (!sessionId) { + return undefined; + } + return this.herdrControllers.get(sessionId); + } + + private async showHerdrFailure( + error: unknown, + client: HerdrCli, + invocation: HerdrInvocation, + makeController: HerdrControllerFactory, + ): Promise { + if (error instanceof HerdrNotInstalledError) { + const action = await vscode.window.showWarningMessage( + `Herdr executable not found: ${invocation.command}`, + "Open Setting", + ); + if (action === "Open Setting") { + await vscode.commands.executeCommand( + "workbench.action.openSettings", + "ulw.herdr.executablePath", + ); + } + return; + } + if (error instanceof HerdrUnsupportedVersionError) { + await vscode.window.showWarningMessage( + `Herdr 0.8.0 or newer is required (found ${error.version})`, + ); + return; + } + if (error instanceof HerdrServerDownError) { + const action = await vscode.window.showWarningMessage( + `Herdr session ${this.configuredSession()} is not running (${error.displayEndpoint})`, + "Retry", + ); + if (action === "Retry") { + await this.attachHerdrSession(client, invocation, makeController, false); + } + return; + } + const message = error instanceof Error ? error.message : String(error); + await vscode.window.showWarningMessage(message); + } + + private quickPickItem(entry: HerdrAgent): HerdrQuickPickItem { + const title = typeof entry.title === "string" ? entry.title.trim() : ""; + return { + label: title || `${entry.agent} · ${entry.paneId}`, + description: `${entry.status} · ${entry.workspaceId}`, + detail: entry.cwd, + agent: entry, + }; + } + + private configuredSession(): string { + return ( + vscode.workspace + .getConfiguration("ulw") + .get("herdr.session", "") + .trim() || "default" + ); + } + + private async openForeignFolderIfNeeded(root: string): Promise { + if (root.trim().length === 0) { + return false; + } + if (isCurrentWindowRoot(root, vscode.workspace.workspaceFolders)) { + return false; + } + await vscode.commands.executeCommand("vscode.openFolder", vscode.Uri.file(root), { + forceNewWindow: true, + }); + return true; + } + + private isStaleTargetError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /(?:pane|terminal|target).*(?:not found|no longer exists)|not found.*(?:pane|terminal|target)/i.test( + message, + ); + } +} + +function sanitizeTerminalReplay(replay: string): string { + return replay + .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "") + .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") + .replace(/\r/g, "") + .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, ""); } + +const runHerdrCommand: HerdrCommandRunner = ( + command, + args, + env, + timeoutMs, +) => + new Promise((resolve, reject) => { + execFile( + command, + [...args], + { env: { ...env }, timeout: timeoutMs, encoding: "utf8" }, + (error, stdout, stderr) => { + if (error) { + const code = typeof error.code === "number" ? error.code : undefined; + if (code !== undefined) { + resolve({ stdout, stderr, code }); + return; + } + reject(error); + return; + } + resolve({ stdout, stderr, code: 0 }); + }, + ); + }); diff --git a/src/herdr/HerdrAttachController.test.ts b/src/herdr/HerdrAttachController.test.ts new file mode 100644 index 0000000..4318768 --- /dev/null +++ b/src/herdr/HerdrAttachController.test.ts @@ -0,0 +1,476 @@ +import * as vscode from "vscode"; +import { describe, expect, test, vi } from "vitest"; +import { + TerminalManager, + type TerminalExitEvent, +} from "../terminals/TerminalManager"; +import type { + TerminalTransport, + TerminalTransportExitReason, +} from "../terminals/TerminalTransport"; +import { + HerdrAttachController, + HerdrAttachBusyError, + type HerdrAttachManager, + type HerdrAttachPresenter, + type SourceState, +} from "./HerdrAttachController"; + +class FakeTransport implements TerminalTransport { + public readonly kind = "herdr-control" as const; + private readonly outputEmitter = new vscode.EventEmitter<{ + data: string; + replay: "append" | "replace"; + }>(); + private readonly exitEmitter = new vscode.EventEmitter<{ + reason: TerminalTransportExitReason; + message?: string; + }>(); + public readonly onOutput: TerminalTransport["onOutput"] = (listener) => { + this.log.push("subscribe"); + return this.outputEmitter.event(listener); + }; + public readonly onExit = this.exitEmitter.event; + public readonly write = vi.fn(); + public readonly scroll = vi.fn(); + public readonly resize = vi.fn(); + public readonly close = vi.fn( + async (_reason: "release" | "shutdown"): Promise => undefined, + ); + + public constructor(private readonly log: string[]) {} + + public output(data: string, replay: "append" | "replace"): void { + this.log.push(replay === "replace" ? "buffer" : "delta"); + this.outputEmitter.fire({ data, replay }); + } + + public exit(reason: TerminalTransportExitReason, message?: string): void { + this.exitEmitter.fire(message ? { reason, message } : { reason }); + } +} + +class FakeManager implements HerdrAttachManager { + private readonly exitEmitter = new vscode.EventEmitter(); + public readonly onExit = this.exitEmitter.event; + public source: "local-shell" | "herdr-control" | undefined = "local-shell"; + public shellReplay = "shell replay"; + public shellAlive = true; + public readonly attach = vi.fn(( + id: string, + factory: () => TerminalTransport, + _initialReplay?: string, + ) => { + this.log.push(`manager.attach:${id}`); + const transport = factory(); + this.source = "herdr-control"; + transport.onExit(({ reason, message }) => { + if (this.source === "herdr-control") { + this.source = this.shellAlive ? "local-shell" : undefined; + const event = { id, code: 0 } as TerminalExitEvent; + Object.defineProperties(event, { + reason: { value: reason, enumerable: false }, + message: { value: message, enumerable: false }, + }); + this.exitEmitter.fire(event); + } + }); + return transport; + }); + public readonly detach = vi.fn((_id: string) => { + this.log.push("manager.detach"); + this.source = this.shellAlive ? "local-shell" : undefined; + }); + public readonly resize = vi.fn((_id: string, cols: number, rows: number) => { + this.log.push(`manager.resize:${cols}x${rows}`); + }); + public readonly ensureLocalShell = vi.fn((_id: string, _cols: number, _rows: number) => { + this.log.push("manager.ensureLocalShell"); + this.shellAlive = true; + this.source = "local-shell"; + return {}; + }); + public readonly activeSource = vi.fn((_id: string) => this.source); + public readonly replay = vi.fn((_id: string) => this.shellReplay); + + public constructor(private readonly log: string[]) {} +} + +class FakePresenter implements HerdrAttachPresenter { + public readonly states: SourceState[] = []; + public readonly resets: number[] = []; + public readonly output: string[] = []; + + public constructor(private readonly log: string[]) {} + + public postReset(): void { + this.resets.push(this.resets.length + 1); + this.log.push("presenter.reset"); + } + + public postOutput(data: string): void { + this.output.push(data); + this.log.push(`presenter.output:${data}`); + } + + public postSourceState(state: SourceState): void { + this.states.push(state); + this.log.push(`state:${state.phase}`); + } +} + +interface Harness { + readonly log: string[]; + readonly manager: FakeManager; + readonly presenter: FakePresenter; + readonly transports: FakeTransport[]; + readonly controller: HerdrAttachController; + readonly eventStates: SourceState[]; +} + +function setup(): Harness { + const log: string[] = []; + const manager = new FakeManager(log); + const presenter = new FakePresenter(log); + const transports: FakeTransport[] = []; + const controller = new HerdrAttachController({ + manager, + terminalId: "sidebar-shell", + transportFactory: () => { + const transport = new FakeTransport(log); + transports.push(transport); + return transport; + }, + presenter, + }); + const eventStates: SourceState[] = []; + controller.onSourceState((state) => eventStates.push(state)); + return { log, manager, presenter, transports, controller, eventStates }; +} + +async function attachSuccessfully(harness: Harness, label = "Agent A"): Promise { + const attaching = harness.controller.attach( + { terminalId: "herdr-terminal", label }, + { cols: 80, rows: 24 }, + ); + const transport = harness.transports[0]; + transport.output("FULL", "replace"); + await attaching; + return transport; +} + +function phases(harness: Harness): string[] { + return harness.eventStates.map((state) => state.phase); +} + +function last(values: readonly T[]): T | undefined { + return values[values.length - 1]; +} + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +describe("HerdrAttachController", () => { + test("cuts over atomically after the buffered full frame", async () => { + const harness = setup(); + const attaching = harness.controller.attach( + { terminalId: "herdr-terminal", label: "Agent A" }, + { cols: 100, rows: 30 }, + ); + + expect(harness.manager.source).toBe("local-shell"); + expect(harness.log).toEqual(["state:attaching", "subscribe"]); + + harness.transports[0].output("FULL FRAME", "replace"); + await attaching; + + expect(harness.log).toEqual([ + "state:attaching", + "subscribe", + "buffer", + "manager.attach:sidebar-shell", + "presenter.reset", + "presenter.output:FULL FRAME", + "state:attached", + ]); + expect(last(harness.presenter.states)).toEqual({ + source: "herdr", + phase: "attached", + label: "Agent A", + }); + expect(harness.manager.ensureLocalShell).not.toHaveBeenCalled(); + }); + + const preFrameReasons: TerminalTransportExitReason[] = [ + "spawn-error", + "timeout", + "protocol-error", + ]; + test.each(preFrameReasons)( + "row 2: pre-first-frame %s leaves the shell display untouched", + async (reason) => { + const harness = setup(); + const attaching = harness.controller.attach( + { terminalId: "herdr-terminal" }, + { cols: 80, rows: 24 }, + ); + harness.transports[0].exit(reason, `${reason} detail`); + await attaching; + + expect(harness.presenter.resets).toEqual([]); + expect(harness.presenter.output).toEqual([]); + expect(harness.manager.attach).not.toHaveBeenCalled(); + expect(harness.manager.source).toBe("local-shell"); + expect(harness.transports[0].close).toHaveBeenCalledWith("release"); + expect(phases(harness)).toEqual(["attaching", "error", "shell"]); + expect(harness.eventStates.filter((state) => state.phase === "error")).toEqual([ + { source: "shell", phase: "error", message: `${reason} detail` }, + ]); + }, + ); + + test("row 3: explicit detach awaits release and does not restore a local shell", async () => { + const harness = setup(); + const transport = await attachSuccessfully(harness); + const release = deferred(); + transport.close.mockImplementationOnce(() => release.promise); + + const detaching = harness.controller.detach(); + expect(last(phases(harness))).toBe("detaching"); + expect(harness.manager.detach).not.toHaveBeenCalled(); + release.resolve(); + await detaching; + + expect(transport.close).toHaveBeenCalledWith("release"); + expect(harness.manager.detach).toHaveBeenCalledWith("sidebar-shell"); + expect(harness.manager.ensureLocalShell).not.toHaveBeenCalled(); + expect(last(phases(harness))).toBe("shell"); + expect(last(harness.eventStates)).toEqual({ source: "shell", phase: "shell" }); + }); + + const externalRows: Array<{ + row: number; + name: string; + reason: TerminalTransportExitReason; + message?: string; + expectedMessage: string; + }> = [ + { + row: 4, + name: "takeover", + reason: "takeover", + expectedMessage: "Herdr terminal control was taken over by another controller.", + }, + { + row: 5, + name: "pane exit", + reason: "pane-exited", + expectedMessage: "The attached Herdr pane exited.", + }, + { + row: 6, + name: "server stop", + reason: "server-stopped", + expectedMessage: "The Herdr server stopped.", + }, + { + row: 7, + name: "protocol/oversize", + reason: "protocol-error", + message: "8 MiB exceeded", + expectedMessage: "8 MiB exceeded", + }, + { + row: 7, + name: "released externally", + reason: "released", + expectedMessage: "Herdr terminal control was released externally.", + }, + { + row: 7, + name: "process exit", + reason: "process-exit", + expectedMessage: "The Herdr terminal control process exited.", + }, + ]; + test.each(externalRows)( + "row $row: $name restores shell for typed lifecycle failure", + async ({ reason, message, expectedMessage }) => { + const harness = setup(); + const transport = await attachSuccessfully(harness); + transport.exit(reason, message); + await Promise.resolve(); + + const errorStates = harness.eventStates.filter((state) => state.phase === "error"); + expect(errorStates).toHaveLength(1); + expect(errorStates[0].message).toBe(expectedMessage); + expect(phases(harness).slice(-2)).toEqual(["error", "shell"]); + expect(harness.manager.detach).toHaveBeenCalledTimes(1); + expect(harness.manager.ensureLocalShell).not.toHaveBeenCalled(); + + transport.output("STALE", "append"); + expect(harness.presenter.output).not.toContain("STALE"); + expect(harness.manager.detach).toHaveBeenCalledTimes(1); + }, + ); + + test("row 8: detach after the local slot died still does not spawn a shell", async () => { + const harness = setup(); + await attachSuccessfully(harness); + harness.manager.shellAlive = false; + + await harness.controller.detach(); + + expect(harness.manager.ensureLocalShell).not.toHaveBeenCalled(); + expect(last(harness.eventStates)).toEqual({ + source: "shell", + phase: "shell", + }); + }); + + test("row 9: rejects a double attach while attaching or attached", async () => { + const harness = setup(); + const first = harness.controller.attach( + { terminalId: "one" }, + { cols: 80, rows: 24 }, + ); + await expect( + harness.controller.attach({ terminalId: "two" }, { cols: 80, rows: 24 }), + ).rejects.toBeInstanceOf(HerdrAttachBusyError); + + harness.transports[0].output("FULL", "replace"); + await first; + await expect( + harness.controller.attach({ terminalId: "three" }, { cols: 80, rows: 24 }), + ).rejects.toBeInstanceOf(HerdrAttachBusyError); + expect(harness.transports).toHaveLength(1); + }); + + test("real manager seeds the buffered full frame without leaking it to live output", async () => { + const log: string[] = []; + const manager = new TerminalManager(); + const presenter = new FakePresenter(log); + const transport = new FakeTransport(log); + const managerData: string[] = []; + manager.onData(({ data }) => { + managerData.push(data); + log.push(`manager.data:${data}`); + }); + const realAttach = manager.attach.bind(manager); + vi.spyOn(manager, "attach").mockImplementation( + (id, factory, initialReplay) => { + log.push("manager.attach"); + return realAttach(id, factory, initialReplay); + }, + ); + const controller = new HerdrAttachController({ + manager, + terminalId: "sidebar-shell", + transportFactory: () => transport, + presenter, + }); + + const attaching = controller.attach( + { terminalId: "herdr-terminal" }, + { cols: 80, rows: 24 }, + ); + transport.output("FULL", "replace"); + await attaching; + transport.output("DELTA", "append"); + + expect(presenter.output).toEqual(["FULL"]); + expect(managerData).toEqual(["DELTA"]); + expect(log).toEqual([ + "state:attaching", + "subscribe", + "buffer", + "manager.attach", + "presenter.reset", + "presenter.output:FULL", + "state:attached", + "delta", + "manager.data:DELTA", + ]); + expect(manager.replay("sidebar-shell")).toBe("FULLDELTA"); + controller.dispose(); + manager.dispose(); + }); + + test("real manager replay overflow emits one error and restores the shell", async () => { + const log: string[] = []; + const manager = new TerminalManager(); + manager.ensureLocalShell("sidebar-shell", 80, 24); + const presenter = new FakePresenter(log); + const transport = new FakeTransport(log); + const states: SourceState[] = []; + const managerData: string[] = []; + manager.onData(({ data }) => managerData.push(data)); + const controller = new HerdrAttachController({ + manager, + terminalId: "sidebar-shell", + transportFactory: () => transport, + presenter, + }); + controller.onSourceState((state) => states.push(state)); + + const attaching = controller.attach( + { terminalId: "herdr-terminal" }, + { cols: 80, rows: 24 }, + ); + transport.output("FULL", "replace"); + await attaching; + transport.output("x".repeat(8 * 1024 * 1024 + 1), "append"); + await Promise.resolve(); + + expect(states.filter((state) => state.phase === "error")).toEqual([ + { + source: "shell", + phase: "error", + message: "Attached terminal replay exceeded the 8 MiB limit.", + }, + ]); + expect(last(states)).toEqual({ source: "shell", phase: "shell" }); + expect(manager.activeSource("sidebar-shell")).toBe("local-shell"); + expect(managerData).toEqual([]); + transport.output("STALE", "append"); + expect(managerData).toEqual([]); + controller.dispose(); + manager.dispose(); + }); + + test.each(["attaching", "attached"] as const)( + "row 10: dispose during %s is idempotent and suppresses stale events", + async (phase) => { + const harness = setup(); + const attaching = harness.controller.attach( + { terminalId: "herdr-terminal" }, + { cols: 80, rows: 24 }, + ); + const transport = harness.transports[0]; + if (phase === "attached") { + transport.output("FULL", "replace"); + await attaching; + } + const hangingClose = deferred(); + transport.close.mockImplementation(() => hangingClose.promise); + + expect(() => { + harness.controller.dispose(); + harness.controller.dispose(); + }).not.toThrow(); + expect(transport.close).toHaveBeenCalledTimes(1); + expect(transport.close).toHaveBeenCalledWith("release"); + + transport.output("STALE", "replace"); + transport.exit("takeover"); + expect(harness.eventStates.some((state) => state.phase === "error")).toBe(false); + hangingClose.resolve(); + await expect(attaching).resolves.toBeUndefined(); + }, + ); +}); diff --git a/src/herdr/HerdrAttachController.ts b/src/herdr/HerdrAttachController.ts new file mode 100644 index 0000000..c3ecef4 --- /dev/null +++ b/src/herdr/HerdrAttachController.ts @@ -0,0 +1,439 @@ +import * as vscode from "vscode"; +import type { + TerminalExitEvent, + TerminalManager, +} from "../terminals/TerminalManager"; +import type { + TerminalTransport, + TerminalTransportExitReason, +} from "../terminals/TerminalTransport"; +import type { HerdrScrollGesture } from "../types"; + +export type SourceStatePhase = + | "shell" + | "attaching" + | "attached" + | "detaching" + | "error"; + +export interface SourceState { + readonly source: "herdr" | "shell"; + readonly phase: SourceStatePhase; + readonly label?: string; + readonly message?: string; +} + +export interface HerdrAttachTarget { + readonly terminalId: string; + readonly label?: string; +} + +export interface TerminalDimensions { + readonly cols: number; + readonly rows: number; +} + +export interface HerdrAttachManager { + readonly onExit: vscode.Event; + attach( + id: string, + factory: () => TerminalTransport, + initialReplay?: string, + ): TerminalTransport; + detach(id: string): void; + resize(id: string, cols: number, rows: number): void; + ensureLocalShell(id: string, cols: number, rows: number): unknown; + activeSource(id: string): "local-shell" | "herdr-control" | undefined; + replay(id: string): string; +} + +export interface HerdrAttachPresenter { + postReset(): void; + postOutput(data: string): void; + postSourceState(state: SourceState): void; +} + +export interface HerdrAttachControllerOptions { + readonly manager: HerdrAttachManager | TerminalManager; + readonly terminalId: string; + readonly transportFactory: ( + target: HerdrAttachTarget, + dimensions: TerminalDimensions, + ) => TerminalTransport; + readonly presenter: HerdrAttachPresenter; +} + +export class HerdrAttachBusyError extends Error { + public constructor() { + super("A Herdr terminal is already attaching or attached."); + this.name = "HerdrAttachBusyError"; + } +} + +export function herdrSessionId(terminalId: string): string { + return `herdr:${terminalId}`; +} + +type ControllerPhase = "shell" | "attaching" | "attached" | "detaching"; + +export class HerdrAttachController implements vscode.Disposable { + private readonly manager: HerdrAttachManager; + private readonly terminalId: string; + private readonly transportFactory: HerdrAttachControllerOptions["transportFactory"]; + private readonly presenter: HerdrAttachPresenter; + private readonly sourceStateEmitter = new vscode.EventEmitter(); + private phase: ControllerPhase = "shell"; + private dimensions: TerminalDimensions | undefined; + private label: string | undefined; + private transport: TerminalTransport | undefined; + private managedTransport: BufferedAttachTransport | undefined; + private outputSubscription: vscode.Disposable | undefined; + private exitSubscription: vscode.Disposable | undefined; + private managerExitSubscription: vscode.Disposable | undefined; + private generation = 0; + private disposed = false; + private explicitDetach = false; + private pendingAttachResolve: (() => void) | undefined; + + public readonly onSourceState = this.sourceStateEmitter.event; + + public constructor(options: HerdrAttachControllerOptions) { + this.manager = options.manager; + this.terminalId = options.terminalId; + this.transportFactory = options.transportFactory; + this.presenter = options.presenter; + } + + public get sourceState(): SourceState { + if (this.phase === "attached") { + return this.withLabel({ source: "herdr", phase: "attached" }); + } + if (this.phase === "attaching") { + return this.withLabel({ source: "herdr", phase: "attaching" }); + } + if (this.phase === "detaching") { + return { source: "herdr", phase: "detaching" }; + } + return { source: "shell", phase: "shell" }; + } + + public attach( + target: HerdrAttachTarget, + dimensions: TerminalDimensions, + ): Promise { + if (this.disposed) { + return Promise.reject(new Error("Herdr attach controller is disposed.")); + } + if (this.phase !== "shell") { + return Promise.reject(new HerdrAttachBusyError()); + } + + this.phase = "attaching"; + this.dimensions = dimensions; + this.label = target.label; + const generation = ++this.generation; + this.emitState(this.withLabel({ source: "herdr", phase: "attaching" })); + + let transport: TerminalTransport; + try { + transport = this.transportFactory(target, dimensions); + } catch (error) { + this.failBeforeCutover(generation, "spawn-error", errorMessage(error)); + return Promise.resolve(); + } + this.transport = transport; + + return new Promise((resolve) => { + this.pendingAttachResolve = resolve; + this.outputSubscription = transport.onOutput(({ data, replay }) => { + if (!this.isCurrent(generation, transport)) { + return; + } + if (this.phase === "attaching") { + if (replay === "replace") { + this.completeCutover(generation, transport, data); + } + return; + } + if (this.phase === "attached") { + this.managedTransport?.emitOutput(data, replay); + } + }); + this.exitSubscription = transport.onExit(({ reason, message }) => { + if (!this.isCurrent(generation, transport) || this.explicitDetach) { + return; + } + queueMicrotask(() => { + if (!this.isCurrent(generation, transport) || this.explicitDetach) { + return; + } + if (this.phase === "attaching") { + this.failBeforeCutover(generation, reason, message); + } else if (this.phase === "attached") { + this.managedTransport?.emitExit(reason, message); + void this.restoreAfterExternalExit(generation, reason, message); + } + }); + }); + }); + } + + public async detach(): Promise { + if (this.disposed || this.phase === "shell" || this.phase === "detaching") { + return; + } + + const transport = this.managedTransport ?? this.transport; + const generation = ++this.generation; + this.phase = "detaching"; + this.explicitDetach = true; + this.emitState({ source: "herdr", phase: "detaching" }); + this.disposeTransportSubscriptions(); + this.resolvePendingAttach(); + + try { + await transport?.close("release"); + } finally { + if (this.disposed || generation !== this.generation) { + return; + } + this.manager.detach(this.terminalId); + this.transport = undefined; + this.managedTransport = undefined; + this.explicitDetach = false; + this.finishClosed(); + } + } + + public dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.generation += 1; + const transport = this.managedTransport ?? this.transport; + this.transport = undefined; + this.managedTransport = undefined; + this.disposeTransportSubscriptions(); + this.resolvePendingAttach(); + if (transport) { + void transport.close("release"); + if (this.phase === "attached") { + this.manager.detach(this.terminalId); + } + } + this.managerExitSubscription?.dispose(); + this.managerExitSubscription = undefined; + this.sourceStateEmitter.dispose(); + } + + private completeCutover( + generation: number, + transport: TerminalTransport, + fullFrame: string, + ): void { + if (!this.isCurrent(generation, transport) || this.phase !== "attaching") { + return; + } + const managedTransport = new BufferedAttachTransport(transport); + this.managedTransport = managedTransport; + try { + this.subscribeToManagerExit(generation); + this.manager.attach(this.terminalId, () => managedTransport, fullFrame); + } catch (error) { + this.failBeforeCutover(generation, "protocol-error", errorMessage(error)); + return; + } + if (!this.isCurrent(generation, transport)) { + return; + } + this.presenter.postReset(); + this.presenter.postOutput(fullFrame); + this.phase = "attached"; + this.emitState(this.withLabel({ source: "herdr", phase: "attached" })); + this.resolvePendingAttach(); + } + + private failBeforeCutover( + generation: number, + reason: TerminalTransportExitReason, + message?: string, + ): void { + if (generation !== this.generation || this.phase !== "attaching") { + return; + } + const transport = this.transport; + this.generation += 1; + this.transport = undefined; + this.managedTransport = undefined; + this.phase = "shell"; + this.disposeTransportSubscriptions(); + if (transport) { + void transport.close("release"); + } + this.emitState({ + source: "shell", + phase: "error", + message: message ?? exitMessage(reason), + }); + this.emitState({ source: "shell", phase: "shell" }); + this.resolvePendingAttach(); + } + + private async restoreAfterExternalExit( + generation: number, + reason: TerminalTransportExitReason, + message?: string, + ): Promise { + if (!this.isCurrent(generation, this.transport) || this.phase !== "attached") { + return; + } + this.generation += 1; + this.transport = undefined; + this.managedTransport = undefined; + this.phase = "shell"; + this.disposeTransportSubscriptions(); + this.manager.detach(this.terminalId); + this.emitState({ + source: "shell", + phase: "error", + message: message ?? exitMessage(reason), + }); + this.finishClosed(); + } + + private finishClosed(): void { + this.phase = "shell"; + this.label = undefined; + this.emitState({ source: "shell", phase: "shell" }); + } + + private emitState(state: SourceState): void { + if (this.disposed) { + return; + } + this.presenter.postSourceState(state); + this.sourceStateEmitter.fire(state); + } + + private withLabel(state: SourceState): SourceState { + return this.label ? { ...state, label: this.label } : state; + } + + private isCurrent( + generation: number, + transport: TerminalTransport | undefined, + ): boolean { + return ( + !this.disposed && + generation === this.generation && + transport !== undefined && + this.transport === transport + ); + } + + private subscribeToManagerExit(generation: number): void { + this.managerExitSubscription?.dispose(); + this.managerExitSubscription = this.manager.onExit((event) => { + if ( + event.id !== this.terminalId || + generation !== this.generation || + this.phase !== "attached" || + this.explicitDetach || + this.disposed + ) { + return; + } + queueMicrotask(() => { + if (generation !== this.generation || this.phase !== "attached") { + return; + } + void this.restoreAfterExternalExit( + generation, + event.reason, + event.message, + ); + }); + }); + } + + private disposeTransportSubscriptions(): void { + this.outputSubscription?.dispose(); + this.exitSubscription?.dispose(); + this.managerExitSubscription?.dispose(); + this.outputSubscription = undefined; + this.exitSubscription = undefined; + this.managerExitSubscription = undefined; + } + + private resolvePendingAttach(): void { + const resolve = this.pendingAttachResolve; + this.pendingAttachResolve = undefined; + resolve?.(); + } +} + +class BufferedAttachTransport implements TerminalTransport { + public readonly kind = "herdr-control" as const; + private readonly outputEmitter = new vscode.EventEmitter<{ + data: string; + replay: "append" | "replace"; + }>(); + private readonly exitEmitter = new vscode.EventEmitter<{ + reason: TerminalTransportExitReason; + message?: string; + }>(); + public readonly onOutput = this.outputEmitter.event; + public readonly onExit = this.exitEmitter.event; + + public constructor(private readonly transport: TerminalTransport) {} + + public write(data: string): void { + this.transport.write(data); + } + + public scroll(gesture: HerdrScrollGesture): void { + this.transport.scroll(gesture); + } + + public resize(cols: number, rows: number): void { + this.transport.resize(cols, rows); + } + + public close(reason: "release" | "shutdown"): Promise { + return this.transport.close(reason); + } + + public emitOutput(data: string, replay: "append" | "replace"): void { + this.outputEmitter.fire({ data, replay }); + } + + public emitExit(reason: TerminalTransportExitReason, message?: string): void { + this.exitEmitter.fire(message ? { reason, message } : { reason }); + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function exitMessage(reason: TerminalTransportExitReason): string { + switch (reason) { + case "released": + return "Herdr terminal control was released externally."; + case "takeover": + return "Herdr terminal control was taken over by another controller."; + case "pane-exited": + return "The attached Herdr pane exited."; + case "server-stopped": + return "The Herdr server stopped."; + case "protocol-error": + return "The Herdr terminal control protocol failed."; + case "spawn-error": + return "The Herdr terminal control process could not be started."; + case "timeout": + return "Herdr did not provide a terminal frame before the timeout."; + case "process-exit": + return "The Herdr terminal control process exited."; + } +} diff --git a/src/herdr/HerdrCliClient.test.ts b/src/herdr/HerdrCliClient.test.ts new file mode 100644 index 0000000..c0f6bac --- /dev/null +++ b/src/herdr/HerdrCliClient.test.ts @@ -0,0 +1,311 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + HerdrNotInstalledError, + HerdrProtocolError, + HerdrServerDownError, + HerdrUnsupportedVersionError, +} from "./errors"; +import { HerdrCliClient } from "./HerdrCliClient"; +import { HerdrInvocationResolver } from "./HerdrInvocationResolver"; +import type { HerdrCommandRunner, HerdrInvocation } from "./types"; + +const invocation: HerdrInvocation = HerdrInvocationResolver.resolve({ + executablePath: "/opt/herdr", + session: "team", + socketPath: undefined, + env: { PATH: "/bin" }, + platform: "darwin", +}); + +function result(stdout: string, stderr = "", code = 0) { + return Promise.resolve({ stdout, stderr, code }); +} + +describe("HerdrCliClient", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + test("maps supported-version agent list through shared resolver", async () => { + const run = vi + .fn() + .mockImplementationOnce(() => result("herdr 0.8.2\n")) + .mockImplementationOnce(() => + result( + JSON.stringify({ + id: 7, + result: { + agents: [ + { + agent: "claude", + agent_status: "working", + cwd: "/Users/example/repo", + pane_id: "w46:p1", + terminal_id: "terminal-123", + terminal_title_stripped: "Claude Code", + workspace_id: "workspace-456", + ignored_live_field: true, + }, + ], + }, + }), + ), + ); + const client = new HerdrCliClient({ run, invocation }); + + await expect(client.versionCheck()).resolves.toEqual({ version: "0.8.2" }); + await expect(client.listAgents()).resolves.toEqual([ + { + paneId: "w46:p1", + terminalId: "terminal-123", + agent: "claude", + status: "working", + title: "Claude Code", + cwd: "/Users/example/repo", + workspaceId: "workspace-456", + }, + ]); + expect(run).toHaveBeenNthCalledWith( + 1, + "/opt/herdr", + ["--session", "team", "--version"], + { PATH: "/bin" }, + 5_000, + ); + expect(run).toHaveBeenNthCalledWith( + 2, + "/opt/herdr", + ["--session", "team", "agent", "list"], + { PATH: "/bin" }, + 5_000, + ); + }); + + test("maps executable version timeout protocol and capacity failures", async () => { + const missing = new HerdrCliClient({ + invocation, + run: vi.fn().mockRejectedValue( + Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" }), + ), + }); + await expect(missing.versionCheck()).rejects.toMatchObject({ + name: "HerdrNotInstalledError", + displayEndpoint: "session team", + }); + await expect(missing.versionCheck()).rejects.toBeInstanceOf( + HerdrNotInstalledError, + ); + + const unsupported = new HerdrCliClient({ + invocation, + run: () => result("herdr 0.7.9"), + }); + await expect(unsupported.versionCheck()).rejects.toMatchObject({ + name: "HerdrUnsupportedVersionError", + version: "0.7.9", + displayEndpoint: "session team", + }); + await expect(unsupported.versionCheck()).rejects.toBeInstanceOf( + HerdrUnsupportedVersionError, + ); + + vi.useFakeTimers(); + const timeout = new HerdrCliClient({ + invocation, + run: vi.fn().mockReturnValue(new Promise(() => {})), + }); + const timedOut = timeout.listAgents(); + const timeoutRejection = expect(timedOut).rejects.toMatchObject({ + name: "HerdrServerDownError", + displayEndpoint: "session team", + }); + await vi.advanceTimersByTimeAsync(5_000); + await timeoutRejection; + await expect(timedOut).rejects.toBeInstanceOf(HerdrServerDownError); + vi.useRealTimers(); + + const malformed = new HerdrCliClient({ + invocation, + run: () => result("{not-json"), + }); + await expect(malformed.listAgents()).rejects.toBeInstanceOf( + HerdrProtocolError, + ); + + const agents = Array.from({ length: 1_001 }, (_, index) => ({ + agent: `agent-${index}`, + agent_status: "idle", + cwd: `/repo/${index}`, + pane_id: `pane-${index}`, + terminal_id: `terminal-${index}`, + terminal_title_stripped: `Agent ${index}`, + workspace_id: `workspace-${index}`, + })); + const oversized = new HerdrCliClient({ + invocation, + run: () => result(JSON.stringify({ result: { agents } })), + }); + await expect(oversized.listAgents()).rejects.toMatchObject({ + name: "HerdrProtocolError", + displayEndpoint: "session team", + }); + }); + + test("accepts 0.8.2 and rejects 0.7.9", async () => { + const supported = new HerdrCliClient({ + invocation, + run: () => result("herdr 0.8.2"), + }); + const unsupported = new HerdrCliClient({ + invocation, + run: () => result("herdr 0.7.9"), + }); + + await expect(supported.versionCheck()).resolves.toEqual({ version: "0.8.2" }); + await expect(unsupported.versionCheck()).rejects.toBeInstanceOf( + HerdrUnsupportedVersionError, + ); + }); + + test("maps code 127 to not installed", async () => { + const client = new HerdrCliClient({ + invocation, + run: () => result("", "herdr: command not found", 127), + }); + + await expect(client.versionCheck()).rejects.toBeInstanceOf( + HerdrNotInstalledError, + ); + }); + + test("maps a nonzero agent-list exit to server down", async () => { + const client = new HerdrCliClient({ + invocation, + run: () => result("", "failed to connect to herdr server", 1), + }); + + await expect(client.listAgents()).rejects.toMatchObject({ + name: "HerdrServerDownError", + displayEndpoint: "session team", + }); + }); + + test("returns an empty agent list", async () => { + const client = new HerdrCliClient({ + invocation, + run: () => result(JSON.stringify({ id: 1, result: { agents: [] } })), + }); + + await expect(client.listAgents()).resolves.toEqual([]); + }); + + test("rejects a malformed agent row instead of returning misleading values", async () => { + const client = new HerdrCliClient({ + invocation, + run: () => + result( + JSON.stringify({ + result: { + agents: [ + { + agent: "claude", + agent_status: "idle", + cwd: "/repo", + pane_id: "pane", + terminal_id: 123, + terminal_title_stripped: "Claude", + workspace_id: "workspace", + }, + ], + }, + }), + ), + }); + + await expect(client.listAgents()).rejects.toBeInstanceOf( + HerdrProtocolError, + ); + }); + + test("keeps agents whose cwd or title is missing", async () => { + const client = new HerdrCliClient({ + invocation, + run: () => + result( + JSON.stringify({ + result: { + agents: [ + { + agent: "pi", + agent_status: "working", + pane_id: "w46:p1", + terminal_id: "term-1", + workspace_id: "w46", + }, + ], + }, + }), + ), + }); + await expect(client.listAgents()).resolves.toEqual([ + { + paneId: "w46:p1", + terminalId: "term-1", + agent: "pi", + status: "working", + title: "", + cwd: "", + workspaceId: "w46", + }, + ]); + }); + + test("maps workspace list rows for the Spaces tree", async () => { + const run = vi.fn().mockImplementation(() => + result( + JSON.stringify({ + id: "cli:workspace:list", + result: { + type: "workspace_list", + workspaces: [ + { + workspace_id: "w46", + label: "ulwcode", + agent_status: "working", + pane_count: 1, + tab_count: 1, + focused: true, + }, + ], + }, + }), + ), + ); + const client = new HerdrCliClient({ run, invocation }); + + await expect(client.listWorkspaces()).resolves.toEqual([ + { + workspaceId: "w46", + label: "ulwcode", + status: "working", + paneCount: 1, + }, + ]); + expect(run).toHaveBeenCalledWith( + "/opt/herdr", + ["--session", "team", "workspace", "list"], + { PATH: "/bin" }, + 5_000, + ); + }); + + test("rejects a workspace list without result.workspaces", async () => { + const client = new HerdrCliClient({ + invocation, + run: () => result(JSON.stringify({ id: 1, result: {} })), + }); + await expect(client.listWorkspaces()).rejects.toBeInstanceOf( + HerdrProtocolError, + ); + }); +}); diff --git a/src/herdr/HerdrCliClient.ts b/src/herdr/HerdrCliClient.ts new file mode 100644 index 0000000..a7413b3 --- /dev/null +++ b/src/herdr/HerdrCliClient.ts @@ -0,0 +1,316 @@ +import { + HerdrNotInstalledError, + HerdrProtocolError, + HerdrServerDownError, + HerdrUnsupportedVersionError, +} from "./errors"; +import type { + HerdrAgent, + HerdrCommandResult, + HerdrCommandRunner, + HerdrInvocation, + HerdrSpace, + HerdrTimers, +} from "./types"; + +const COMMAND_TIMEOUT_MS = 5_000; +const MAX_AGENTS = 1_000; +const MAX_WORKSPACES = 1_000; +const MINIMUM_VERSION = [0, 8, 0] as const; +const SERVER_UNREACHABLE = + /(?:failed|unable|cannot) to connect|connection refused|server (?:is )?(?:unavailable|not running|unreachable)/i; + +interface HerdrCliClientOptions { + readonly run: HerdrCommandRunner; + readonly invocation: HerdrInvocation; + readonly timers?: HerdrTimers; +} + +interface AgentListEnvelope { + readonly result: { + readonly agents: unknown[]; + }; +} + +interface WorkspaceListEnvelope { + readonly result: { + readonly workspaces: unknown[]; + }; +} + +const defaultTimers: HerdrTimers = { + setTimeout: (callback, timeoutMs) => setTimeout(callback, timeoutMs), + clearTimeout: (handle) => clearTimeout(handle), +}; + +export class HerdrCliClient { + private readonly run: HerdrCommandRunner; + private readonly invocation: HerdrInvocation; + private readonly timers: HerdrTimers; + + public constructor(options: HerdrCliClientOptions) { + this.run = options.run; + this.invocation = options.invocation; + this.timers = options.timers ?? defaultTimers; + } + + public async versionCheck(): Promise<{ readonly version: string }> { + const result = await this.execute(["--version"]); + this.throwForFailure(result, "version check"); + + const match = /(?:^|\s)(\d+)\.(\d+)\.(\d+)(?:[-+][0-9A-Za-z.-]+)?(?:\s|$)/.exec( + `${result.stdout}\n${result.stderr}`, + ); + if (!match) { + throw new HerdrProtocolError( + this.invocation.displayEndpoint, + "the version output did not contain a semantic version", + ); + } + + const version = `${match[1]}.${match[2]}.${match[3]}`; + const parts = [Number(match[1]), Number(match[2]), Number(match[3])]; + if (this.compareVersion(parts, MINIMUM_VERSION) < 0) { + throw new HerdrUnsupportedVersionError( + this.invocation.displayEndpoint, + version, + ); + } + return { version }; + } + + public async listAgents(): Promise { + const result = await this.execute(["agent", "list"]); + this.throwForFailure(result, "agent list"); + + let parsed: unknown; + try { + parsed = JSON.parse(result.stdout); + } catch (error) { + throw new HerdrProtocolError( + this.invocation.displayEndpoint, + "agent list was not valid JSON", + error, + ); + } + + if (!this.isAgentListEnvelope(parsed)) { + throw new HerdrProtocolError( + this.invocation.displayEndpoint, + "agent list did not contain result.agents", + ); + } + if (parsed.result.agents.length > MAX_AGENTS) { + throw new HerdrProtocolError( + this.invocation.displayEndpoint, + `agent list exceeded the ${MAX_AGENTS}-agent limit`, + ); + } + + return parsed.result.agents.map((row, index) => this.mapAgent(row, index)); + } + + public async listWorkspaces(): Promise { + const result = await this.execute(["workspace", "list"]); + this.throwForFailure(result, "workspace list"); + + let parsed: unknown; + try { + parsed = JSON.parse(result.stdout); + } catch (error) { + throw new HerdrProtocolError( + this.invocation.displayEndpoint, + "workspace list was not valid JSON", + error, + ); + } + + if (!this.isWorkspaceListEnvelope(parsed)) { + throw new HerdrProtocolError( + this.invocation.displayEndpoint, + "workspace list did not contain result.workspaces", + ); + } + if (parsed.result.workspaces.length > MAX_WORKSPACES) { + throw new HerdrProtocolError( + this.invocation.displayEndpoint, + `workspace list exceeded the ${MAX_WORKSPACES}-workspace limit`, + ); + } + + return parsed.result.workspaces.map((row, index) => + this.mapWorkspace(row, index), + ); + } + + private async execute(args: readonly string[]): Promise { + const commandArgs = [...this.invocation.argsPrefix, ...args]; + let timeoutHandle: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timeoutHandle = this.timers.setTimeout(() => { + reject( + new HerdrServerDownError( + this.invocation.displayEndpoint, + `command timed out after ${COMMAND_TIMEOUT_MS}ms`, + ), + ); + }, COMMAND_TIMEOUT_MS); + }); + + try { + return await Promise.race([ + this.run( + this.invocation.command, + commandArgs, + this.invocation.env, + COMMAND_TIMEOUT_MS, + ), + timeout, + ]); + } catch (error) { + if (error instanceof HerdrServerDownError) { + throw error; + } + if (this.isMissingExecutable(error)) { + throw new HerdrNotInstalledError( + this.invocation.displayEndpoint, + this.invocation.command, + error, + ); + } + throw new HerdrServerDownError( + this.invocation.displayEndpoint, + this.errorDetail(error), + error, + ); + } finally { + if (timeoutHandle !== undefined) { + this.timers.clearTimeout(timeoutHandle); + } + } + } + + private throwForFailure( + result: HerdrCommandResult, + operation: string, + ): void { + if (result.code === 127) { + throw new HerdrNotInstalledError( + this.invocation.displayEndpoint, + this.invocation.command, + ); + } + const output = `${result.stderr}\n${result.stdout}`.trim(); + if (result.code !== 0 || SERVER_UNREACHABLE.test(output)) { + throw new HerdrServerDownError( + this.invocation.displayEndpoint, + output || `${operation} exited with code ${result.code}`, + ); + } + } + + private isAgentListEnvelope(value: unknown): value is AgentListEnvelope { + if (!this.isRecord(value) || !this.isRecord(value.result)) { + return false; + } + return Array.isArray(value.result.agents); + } + + private isWorkspaceListEnvelope(value: unknown): value is WorkspaceListEnvelope { + if (!this.isRecord(value) || !this.isRecord(value.result)) { + return false; + } + return Array.isArray(value.result.workspaces); + } + + private mapAgent(value: unknown, index: number): HerdrAgent { + if (!this.isRecord(value)) { + throw this.invalidAgent(index); + } + + const paneId = value.pane_id; + const terminalId = value.terminal_id; + const agent = value.agent; + const status = value.agent_status; + const workspaceId = value.workspace_id; + if ( + typeof paneId !== "string" || + typeof terminalId !== "string" || + typeof agent !== "string" || + typeof status !== "string" || + typeof workspaceId !== "string" + ) { + throw this.invalidAgent(index); + } + return { + paneId, + terminalId, + agent, + status, + title: typeof value.terminal_title_stripped === "string" ? value.terminal_title_stripped : "", + cwd: typeof value.cwd === "string" ? value.cwd : "", + workspaceId, + }; + } + + private mapWorkspace(value: unknown, index: number): HerdrSpace { + if (!this.isRecord(value)) { + throw this.invalidWorkspace(index); + } + const workspaceId = value.workspace_id; + const label = value.label; + const status = value.agent_status; + const paneCount = value.pane_count; + if ( + typeof workspaceId !== "string" || + typeof label !== "string" || + typeof status !== "string" || + typeof paneCount !== "number" + ) { + throw this.invalidWorkspace(index); + } + return { workspaceId, label, status, paneCount }; + } + + private invalidWorkspace(index: number): HerdrProtocolError { + return new HerdrProtocolError( + this.invocation.displayEndpoint, + `workspace at index ${index} was missing a required field`, + ); + } + + private invalidAgent(index: number): HerdrProtocolError { + return new HerdrProtocolError( + this.invocation.displayEndpoint, + `agent at index ${index} was missing a required string field`, + ); + } + + private compareVersion( + left: readonly number[], + right: readonly number[], + ): number { + for (let index = 0; index < 3; index += 1) { + const difference = left[index] - right[index]; + if (difference !== 0) { + return difference; + } + } + return 0; + } + + private isMissingExecutable(error: unknown): boolean { + return ( + this.isRecord(error) && + (error.code === "ENOENT" || error.code === 127) + ); + } + + private errorDetail(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } + + private isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; + } +} diff --git a/src/herdr/HerdrControlTransport.test.ts b/src/herdr/HerdrControlTransport.test.ts new file mode 100644 index 0000000..aa65056 --- /dev/null +++ b/src/herdr/HerdrControlTransport.test.ts @@ -0,0 +1,401 @@ +import { EventEmitter } from "events"; +import { PassThrough, Writable } from "stream"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + HerdrControlTransport, + type HerdrControlChild, + type HerdrControlSpawn, +} from "./HerdrControlTransport"; +import { HerdrInvocationResolver } from "./HerdrInvocationResolver"; + +class FakeChild extends EventEmitter implements HerdrControlChild { + public readonly stdout = new PassThrough(); + public readonly stderr = new PassThrough(); + public readonly stdinChunks: string[] = []; + public readonly stdin = new Writable({ + write: (chunk, _encoding, callback) => { + this.stdinChunks.push(chunk.toString("utf8")); + callback(); + }, + }); + public readonly kill = vi.fn((_signal?: NodeJS.Signals | number) => true); +} + +const invocation = HerdrInvocationResolver.resolve({ + executablePath: "/opt/herdr", + session: "team", + socketPath: undefined, + env: { PATH: "/bin" }, + platform: "darwin", +}); + +function frame(data: string, full: boolean, seq: number): string { + return `${JSON.stringify({ + type: "terminal.frame", + bytes: Buffer.from(data, "utf8").toString("base64"), + encoding: "ansi", + full, + width: 80, + height: 24, + seq, + label: "가나다", + })}\n`; +} + +function setup(overrides: Partial[0]> = {}) { + const child = new FakeChild(); + const spawnFn = vi.fn(() => child); + const transport = new HerdrControlTransport({ + invocation, + terminalId: "terminal-123", + cols: 80, + rows: 24, + spawnFn, + timers: { + setTimeout: (callback, timeoutMs) => setTimeout(callback, timeoutMs), + clearTimeout: (handle) => clearTimeout(handle), + }, + ...overrides, + }); + const output: Array<{ data: string; replay: "append" | "replace" }> = []; + const exits: Array<{ reason: string; message?: string }> = []; + transport.onOutput((event) => output.push(event)); + transport.onExit((event) => exits.push(event)); + return { child, spawnFn, transport, output, exits }; +} + +function commands(child: FakeChild): unknown[] { + return child.stdinChunks + .join("") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)); +} + +describe("HerdrControlTransport", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + test("decodes frames and encodes input resize scroll release", async () => { + vi.useFakeTimers(); + const { child, spawnFn, transport, output, exits } = setup(); + + const full = frame("초기 가나다", true, 1); + const splitAt = Buffer.from(full).indexOf(Buffer.from("가")) + 1; + const bytes = Buffer.from(full); + child.stdout.write(bytes.subarray(0, splitAt)); + child.stdout.write(bytes.subarray(splitAt)); + child.stdout.write(frame(" + delta", false, 2)); + + expect(output).toEqual([ + { data: "초기 가나다", replay: "replace" }, + { data: " + delta", replay: "append" }, + ]); + + transport.write("ls\r"); + transport.write("\x1b[<64;4;7M"); + transport.write("\x1b[<65;8;9M"); + transport.write("\x1b[5~"); + transport.write("\x1b[6~"); + transport.write("\x1b[?unknown"); + transport.resize(100, 40); + transport.write("\x1b[5~"); + const closing = transport.close("release"); + + expect(commands(child)).toEqual([ + { + type: "terminal.input", + bytes: Buffer.from("ls\r", "utf8").toString("base64"), + }, + { + type: "terminal.input", + bytes: Buffer.from("\x1b[<64;4;7M", "utf8").toString("base64"), + }, + { + type: "terminal.input", + bytes: Buffer.from("\x1b[<65;8;9M", "utf8").toString("base64"), + }, + { + type: "terminal.input", + bytes: Buffer.from("\x1b[5~", "utf8").toString("base64"), + }, + { + type: "terminal.input", + bytes: Buffer.from("\x1b[6~", "utf8").toString("base64"), + }, + { + type: "terminal.input", + bytes: Buffer.from("\x1b[?unknown", "utf8").toString("base64"), + }, + { type: "terminal.resize", cols: 100, rows: 40 }, + { + type: "terminal.input", + bytes: Buffer.from("\x1b[5~", "utf8").toString("base64"), + }, + { type: "terminal.release" }, + ]); + const input = commands(child)[0] as Record; + expect(Object.keys(input).sort()).toEqual(["bytes", "type"]); + + child.stdout.write( + `${JSON.stringify({ type: "terminal.closed", reason: "detached" })}\n`, + ); + child.emit("exit", 0, null); + await closing; + expect(exits).toEqual([{ reason: "released" }]); + expect(child.kill).not.toHaveBeenCalled(); + expect(spawnFn).toHaveBeenCalledWith( + "/opt/herdr", + [ + "--session", + "team", + "terminal", + "session", + "control", + "terminal-123", + "--takeover", + "--cols", + "80", + "--rows", + "24", + ], + { env: { PATH: "/bin" }, stdio: ["pipe", "pipe", "pipe"] }, + ); + }); + + test("spawn inherits the ssh forward sockets through the invocation env", () => { + const forwardInvocation = HerdrInvocationResolver.resolve({ + executablePath: "/opt/herdr", + session: "team", + remoteTarget: "u@h", + forwardSockets: { + apiSocketPath: "/tmp/f.sock", + clientSocketPath: "/tmp/f-client.sock", + }, + socketPath: undefined, + env: { PATH: "/bin" }, + platform: "darwin", + }); + const { spawnFn } = setup({ invocation: forwardInvocation }); + + expect(spawnFn).toHaveBeenCalledWith( + "/opt/herdr", + [ + "terminal", + "session", + "control", + "terminal-123", + "--takeover", + "--cols", + "80", + "--rows", + "24", + ], + { + env: { + PATH: "/bin", + HERDR_SOCKET_PATH: "/tmp/f.sock", + HERDR_CLIENT_SOCKET_PATH: "/tmp/f-client.sock", + }, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + }); + + test("scroll sends terminal.scroll then same-size resize checkpoint", () => { + const { child, transport } = setup(); + child.stdout.write(frame("ready", true, 1)); + + transport.scroll({ + direction: "up", + lines: 3, + source: "wheel", + column: 4, + row: 7, + modifiers: 0, + }); + transport.resize(100, 40); + transport.scroll({ + direction: "down", + lines: 14, + source: "page_key", + column: 0, + row: 0, + modifiers: 0, + }); + + expect(commands(child)).toEqual([ + { + type: "terminal.scroll", + direction: "up", + lines: 3, + source: "wheel", + column: 4, + row: 7, + modifiers: 0, + }, + { type: "terminal.resize", cols: 80, rows: 24 }, + { type: "terminal.resize", cols: 100, rows: 40 }, + { + type: "terminal.scroll", + direction: "down", + lines: 14, + source: "page_key", + column: 0, + row: 0, + modifiers: 0, + }, + { type: "terminal.resize", cols: 100, rows: 40 }, + ]); + }); + + test("preserves UTF-8 code points split across decoded frame boundaries", () => { + const { child, output } = setup(); + const utf8 = Buffer.from("가나다", "utf8"); + child.stdout.write( + frame(utf8.subarray(0, 4).toString("binary"), true, 1).replace( + Buffer.from(utf8.subarray(0, 4).toString("binary"), "utf8").toString("base64"), + utf8.subarray(0, 4).toString("base64"), + ), + ); + child.stdout.write( + frame(utf8.subarray(4).toString("binary"), false, 2).replace( + Buffer.from(utf8.subarray(4).toString("binary"), "utf8").toString("base64"), + utf8.subarray(4).toString("base64"), + ), + ); + + expect(output).toEqual([ + { data: "가", replay: "replace" }, + { data: "나다", replay: "append" }, + ]); + }); + + test("bounds records and terminates on protocol and timeout failures", async () => { + vi.useFakeTimers(); + + const oversized = setup(); + oversized.child.stdout.write(Buffer.alloc(4 * 1024 * 1024 + 1, 0x78)); + expect(oversized.exits).toEqual([ + expect.objectContaining({ reason: "protocol-error" }), + ]); + expect(oversized.child.kill).toHaveBeenCalledWith("SIGKILL"); + + const malformed = setup(); + malformed.child.stdout.write("{not-json}\n"); + expect(malformed.exits).toEqual([ + expect.objectContaining({ reason: "protocol-error" }), + ]); + + const unknown = setup(); + unknown.child.stdout.write(`${JSON.stringify({ type: "future.record" })}\n`); + expect(unknown.exits).toEqual([ + expect.objectContaining({ reason: "protocol-error" }), + ]); + + const timeout = setup({ firstFrameTimeoutMs: 5_000 }); + await vi.advanceTimersByTimeAsync(5_000); + expect(timeout.exits).toEqual([ + expect.objectContaining({ reason: "timeout" }), + ]); + expect(timeout.child.kill).toHaveBeenCalledWith("SIGKILL"); + + const release = setup({ releaseGraceMs: 2_000 }); + const closing = release.transport.close("release"); + await vi.advanceTimersByTimeAsync(1_999); + expect(release.child.kill).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(release.child.kill).toHaveBeenCalledTimes(1); + expect(release.child.kill).toHaveBeenCalledWith("SIGKILL"); + await closing; + }); + + test("maps spawn errors process exits closure reasons and emits exit once", () => { + const spawnErrorEmitter = new EventEmitter(); + const spawnError = Object.assign(spawnErrorEmitter, { + stdout: new PassThrough(), + stderr: new PassThrough(), + stdin: new PassThrough(), + kill: vi.fn(() => true), + }) as HerdrControlChild; + const spawnFn = vi.fn(() => spawnError); + const transport = new HerdrControlTransport({ + invocation, + terminalId: "missing", + cols: 80, + rows: 24, + spawnFn, + }); + const spawnExits: unknown[] = []; + transport.onExit((event) => spawnExits.push(event)); + spawnErrorEmitter.emit( + "error", + Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" }), + ); + spawnErrorEmitter.emit("exit", -2, null); + expect(spawnExits).toEqual([ + expect.objectContaining({ reason: "spawn-error" }), + ]); + + const processExit = setup(); + processExit.child.emit("exit", 1, "SIGTERM"); + expect(processExit.exits).toEqual([ + expect.objectContaining({ reason: "process-exit" }), + ]); + + const mappings = [ + ["detached", "released"], + ["terminal attach taken over", "takeover"], + ["not found", "pane-exited"], + ["server restart", "server-stopped"], + ] as const; + for (const [closedReason, expected] of mappings) { + const mapped = setup(); + mapped.child.stdout.write( + `${JSON.stringify({ type: "terminal.closed", reason: closedReason })}\n`, + ); + mapped.child.emit("exit", 0, null); + expect(mapped.exits).toEqual([{ reason: expected }]); + } + }); + + test("ignores stdin EPIPE after terminal closure while releasing", async () => { + const { child, transport, exits } = setup(); + child.stdout.write( + `${JSON.stringify({ type: "terminal.closed", reason: "not found" })}\n`, + ); + + const closing = transport.close("release"); + const error = Object.assign(new Error("write EPIPE"), { code: "EPIPE" }); + expect(() => child.stdin.emit("error", error)).not.toThrow(); + + child.emit("exit", 0, null); + await closing; + expect(exits).toEqual([{ reason: "pane-exited" }]); + }); + + test("reports stdin EPIPE as a protocol error while active", () => { + const { child, exits } = setup(); + const error = Object.assign(new Error("write EPIPE"), { code: "EPIPE" }); + + expect(() => child.stdin.emit("error", error)).not.toThrow(); + expect(exits).toEqual([ + expect.objectContaining({ + reason: "protocol-error", + message: expect.stringContaining("write EPIPE"), + }), + ]); + expect(child.kill).toHaveBeenCalledOnce(); + expect(child.kill).toHaveBeenCalledWith("SIGKILL"); + }); + + test("guards empty input and shutdown releases then kills immediately", async () => { + const { child, transport } = setup(); + expect(() => transport.write("")).toThrow(/non-empty/i); + await transport.close("shutdown"); + expect(commands(child)).toEqual([{ type: "terminal.release" }]); + expect(child.kill).toHaveBeenCalledWith("SIGKILL"); + }); +}); diff --git a/src/herdr/HerdrControlTransport.ts b/src/herdr/HerdrControlTransport.ts new file mode 100644 index 0000000..a1398b4 --- /dev/null +++ b/src/herdr/HerdrControlTransport.ts @@ -0,0 +1,467 @@ +import { + spawn as nodeSpawn, + type ChildProcessWithoutNullStreams, + type SpawnOptionsWithoutStdio, +} from "child_process"; +import { StringDecoder } from "string_decoder"; +import * as vscode from "vscode"; +import type { + TerminalTransport, + TerminalTransportExitReason, +} from "../terminals/TerminalTransport"; +import type { HerdrScrollGesture } from "../types"; +import type { HerdrInvocation, HerdrTimers } from "./types"; + +const DEFAULT_FIRST_FRAME_TIMEOUT_MS = 5_000; +const DEFAULT_RELEASE_GRACE_MS = 2_000; +const DEFAULT_MAX_RECORD_BYTES = 4 * 1024 * 1024; +const MAX_DIAGNOSTIC_CHARS = 512; + +export interface HerdrControlChild { + readonly stdin: NodeJS.WritableStream; + readonly stdout: NodeJS.ReadableStream; + readonly stderr: NodeJS.ReadableStream; + readonly kill: (signal?: NodeJS.Signals | number) => boolean; + on(event: "error", listener: (error: Error) => void): this; + on( + event: "exit", + listener: (code: number | null, signal: NodeJS.Signals | null) => void, + ): this; +} + +export type HerdrControlSpawn = ( + command: string, + args: readonly string[], + options: SpawnOptionsWithoutStdio & { + readonly stdio: readonly ["pipe", "pipe", "pipe"]; + }, +) => HerdrControlChild; + +export interface HerdrControlTransportOptions { + readonly invocation: HerdrInvocation; + readonly terminalId: string; + readonly cols: number; + readonly rows: number; + readonly spawnFn?: HerdrControlSpawn; + readonly timers?: HerdrTimers; + readonly firstFrameTimeoutMs?: number; + readonly releaseGraceMs?: number; + readonly maxRecordBytes?: number; +} + +interface TerminalFrameRecord { + readonly type: "terminal.frame"; + readonly bytes: string; + readonly encoding: "ansi"; + readonly full: boolean; + readonly width: number; + readonly height: number; + readonly seq: number; +} + +interface TerminalClosedRecord { + readonly type: "terminal.closed"; + readonly reason: string; +} + +type TimerHandle = ReturnType; + +export class HerdrControlTransport implements TerminalTransport { + public readonly kind = "herdr-control" as const; + + private readonly outputEmitter = new vscode.EventEmitter<{ + data: string; + replay: "append" | "replace"; + }>(); + private readonly exitEmitter = new vscode.EventEmitter<{ + reason: TerminalTransportExitReason; + message?: string; + }>(); + private readonly timers: HerdrTimers; + private readonly releaseGraceMs: number; + private readonly maxRecordBytes: number; + private readonly child: HerdrControlChild; + private readonly lineDecoder = new StringDecoder("utf8"); + private frameDecoder = new StringDecoder("utf8"); + private line = ""; + private lineBytes = 0; + private stderr = ""; + private firstFrameTimer: TimerHandle | undefined; + private releaseTimer: TimerHandle | undefined; + private exitEmitted = false; + private childExited = false; + private closing = false; + private closePromise: Promise | undefined; + private resolveClose: (() => void) | undefined; + private cols: number; + private rows: number; + + public readonly onOutput = this.outputEmitter.event; + public readonly onExit = this.exitEmitter.event; + + public constructor(options: HerdrControlTransportOptions) { + this.timers = options.timers ?? { + setTimeout: (callback, timeoutMs) => setTimeout(callback, timeoutMs), + clearTimeout: (handle) => clearTimeout(handle), + }; + this.releaseGraceMs = + options.releaseGraceMs ?? DEFAULT_RELEASE_GRACE_MS; + this.maxRecordBytes = + options.maxRecordBytes ?? DEFAULT_MAX_RECORD_BYTES; + this.cols = options.cols; + this.rows = options.rows; + const firstFrameTimeoutMs = + options.firstFrameTimeoutMs ?? DEFAULT_FIRST_FRAME_TIMEOUT_MS; + + const spawnFn = options.spawnFn ?? defaultSpawn; + const args = [ + ...options.invocation.argsPrefix, + "terminal", + "session", + "control", + options.terminalId, + "--takeover", + "--cols", + String(options.cols), + "--rows", + String(options.rows), + ]; + + try { + this.child = spawnFn(options.invocation.command, args, { + env: options.invocation.env, + stdio: ["pipe", "pipe", "pipe"], + }); + } catch (error) { + this.child = createFailedChild(); + queueMicrotask(() => this.fail("spawn-error", this.errorMessage(error))); + return; + } + + this.child.stdout.on("data", (chunk: Buffer | string) => { + this.consumeStdout(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + this.child.stderr.on("data", (chunk: Buffer | string) => { + this.stderr = boundedAppend(this.stderr, chunk.toString(), MAX_DIAGNOSTIC_CHARS); + }); + this.child.stdin.on("error", (error) => { + if (isObject(error) && error.code === "EPIPE" && (this.closing || this.exitEmitted)) { + return; + } + this.fail( + "protocol-error", + `Failed to write Herdr command: ${this.errorMessage(error)}`, + ); + }); + this.child.on("error", (error) => { + this.fail("spawn-error", this.errorMessage(error)); + }); + this.child.on("exit", (code, signal) => { + this.childExited = true; + this.clearReleaseTimer(); + this.resolvePendingClose(); + if (!this.exitEmitted) { + const detail = signal ? `signal ${signal}` : `code ${code ?? "unknown"}`; + this.emitExit("process-exit", this.withStderr(detail)); + } + }); + + this.firstFrameTimer = this.timers.setTimeout(() => { + this.fail( + "timeout", + `Herdr did not send a full terminal frame within ${firstFrameTimeoutMs} ms.`, + ); + }, firstFrameTimeoutMs); + } + + public write(data: string): void { + if (data.length === 0) { + throw new Error("Herdr terminal input must be non-empty."); + } + this.send({ + type: "terminal.input", + bytes: Buffer.from(data, "utf8").toString("base64"), + }); + } + + public scroll(gesture: HerdrScrollGesture): void { + this.send({ + type: "terminal.scroll", + direction: gesture.direction, + lines: gesture.lines, + source: gesture.source, + column: gesture.column, + row: gesture.row, + modifiers: gesture.modifiers, + }); + this.send({ type: "terminal.resize", cols: this.cols, rows: this.rows }); + } + + public resize(cols: number, rows: number): void { + this.cols = cols; + this.rows = rows; + this.send({ type: "terminal.resize", cols, rows }); + } + + public close(reason: "release" | "shutdown"): Promise { + if (this.closePromise) { + return this.closePromise; + } + this.closePromise = new Promise((resolve) => { + this.resolveClose = resolve; + }); + this.closing = true; + + if (this.childExited) { + this.resolvePendingClose(); + return this.closePromise; + } + + this.send({ type: "terminal.release" }); + if (reason === "shutdown") { + this.forceKill(); + this.resolvePendingClose(); + return this.closePromise; + } + + this.releaseTimer = this.timers.setTimeout(() => { + this.forceKill(); + this.resolvePendingClose(); + }, this.releaseGraceMs); + return this.closePromise; + } + + private consumeStdout(chunk: Buffer): void { + if (this.exitEmitted) { + return; + } + let start = 0; + for (let index = 0; index < chunk.length; index += 1) { + if (chunk[index] !== 0x0a) { + continue; + } + if (!this.appendLineBytes(chunk.subarray(start, index))) { + return; + } + this.processLine(this.line.endsWith("\r") ? this.line.slice(0, -1) : this.line); + this.line = ""; + this.lineBytes = 0; + start = index + 1; + if (this.exitEmitted) { + return; + } + } + this.appendLineBytes(chunk.subarray(start)); + } + + private appendLineBytes(bytes: Buffer): boolean { + this.lineBytes += bytes.length; + if (this.lineBytes > this.maxRecordBytes) { + this.fail( + "protocol-error", + `Herdr control record exceeded the ${this.maxRecordBytes}-byte limit before parsing.`, + ); + return false; + } + this.line += this.lineDecoder.write(bytes); + return true; + } + + private processLine(line: string): void { + if (line.length === 0) { + return; + } + let record: unknown; + try { + record = JSON.parse(line); + } catch (error) { + this.fail( + "protocol-error", + `Malformed Herdr control JSON: ${this.errorMessage(error)}; record=${bounded(line)}`, + ); + return; + } + + if (!isObject(record) || typeof record.type !== "string") { + this.fail("protocol-error", `Invalid Herdr control record: ${bounded(line)}`); + return; + } + if (record.type === "terminal.frame") { + if (!isFrameRecord(record)) { + this.fail("protocol-error", `Invalid terminal.frame record: ${bounded(line)}`); + return; + } + this.handleFrame(record); + return; + } + if (record.type === "terminal.closed") { + if (!isClosedRecord(record)) { + this.fail("protocol-error", `Invalid terminal.closed record: ${bounded(line)}`); + return; + } + this.handleClosed(record.reason); + return; + } + this.fail( + "protocol-error", + `Unknown Herdr control record type ${JSON.stringify(record.type)}: ${bounded(line)}`, + ); + } + + private handleFrame(record: TerminalFrameRecord): void { + if (record.full) { + this.frameDecoder = new StringDecoder("utf8"); + this.clearFirstFrameTimer(); + } + const bytes = decodeBase64(record.bytes); + if (!bytes) { + this.fail("protocol-error", "terminal.frame bytes are not valid base64."); + return; + } + this.outputEmitter.fire({ + data: this.frameDecoder.write(bytes), + replay: record.full ? "replace" : "append", + }); + } + + private handleClosed(reason: string): void { + // Herdr 0.8.x closure mapping: detach is a normal release, controller + // displacement is takeover, missing targets are pane exits, and all other + // server-side closure diagnostics are classified as server-stopped. + let mapped: TerminalTransportExitReason; + if (reason === "detached") { + mapped = "released"; + } else if (reason === "terminal attach taken over") { + mapped = "takeover"; + } else if (reason === "not found") { + mapped = "pane-exited"; + } else { + mapped = "server-stopped"; + } + this.emitExit(mapped); + } + + private send(command: object): void { + if (this.childExited) { + return; + } + try { + this.child.stdin.write(`${JSON.stringify(command)}\n`); + } catch (error) { + this.fail("protocol-error", `Failed to write Herdr command: ${this.errorMessage(error)}`); + } + } + + private fail(reason: TerminalTransportExitReason, message: string): void { + if (this.exitEmitted) { + return; + } + this.emitExit(reason, this.withStderr(message)); + this.forceKill(); + this.resolvePendingClose(); + } + + private emitExit(reason: TerminalTransportExitReason, message?: string): void { + if (this.exitEmitted) { + return; + } + this.exitEmitted = true; + this.clearFirstFrameTimer(); + this.clearReleaseTimer(); + this.exitEmitter.fire(message ? { reason, message } : { reason }); + } + + private forceKill(): void { + if (!this.childExited) { + this.child.kill("SIGKILL"); + } + } + + private clearFirstFrameTimer(): void { + if (this.firstFrameTimer !== undefined) { + this.timers.clearTimeout(this.firstFrameTimer); + this.firstFrameTimer = undefined; + } + } + + private clearReleaseTimer(): void { + if (this.releaseTimer !== undefined) { + this.timers.clearTimeout(this.releaseTimer); + this.releaseTimer = undefined; + } + } + + private resolvePendingClose(): void { + const resolve = this.resolveClose; + this.resolveClose = undefined; + resolve?.(); + } + + private withStderr(message: string): string { + return this.stderr ? `${message} stderr=${bounded(this.stderr)}` : message; + } + + private errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } +} + +const defaultSpawn: HerdrControlSpawn = (command, args, options) => + nodeSpawn(command, [...args], options) as ChildProcessWithoutNullStreams; + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isFrameRecord( + record: Record, +): record is Record & TerminalFrameRecord { + return ( + record.type === "terminal.frame" && + typeof record.bytes === "string" && + record.encoding === "ansi" && + typeof record.full === "boolean" && + Number.isInteger(record.width) && + Number.isInteger(record.height) && + Number.isInteger(record.seq) + ); +} + +function isClosedRecord( + record: Record, +): record is Record & TerminalClosedRecord { + return record.type === "terminal.closed" && typeof record.reason === "string"; +} + +function decodeBase64(value: string): Buffer | undefined { + if (value.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) { + return undefined; + } + return Buffer.from(value, "base64"); +} + +function bounded(value: string): string { + return value.length <= MAX_DIAGNOSTIC_CHARS + ? value + : `${value.slice(0, MAX_DIAGNOSTIC_CHARS)}...`; +} + +function boundedAppend(current: string, next: string, limit: number): string { + return bounded(`${current}${next}`).slice(0, limit + 3); +} + +function createFailedChild(): HerdrControlChild { + let child: HerdrControlChild; + const stream = { + on: () => stream, + write: () => false, + } as unknown as NodeJS.ReadableStream & NodeJS.WritableStream; + child = { + stdin: stream, + stdout: stream, + stderr: stream, + kill: () => false, + on: () => child, + }; + return child; +} diff --git a/src/herdr/HerdrExplorer.test.ts b/src/herdr/HerdrExplorer.test.ts new file mode 100644 index 0000000..39fee72 --- /dev/null +++ b/src/herdr/HerdrExplorer.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it, vi } from "vitest"; +import * as path from "node:path"; +import { + HerdrAgentsTreeProvider, + HerdrSnapshotStore, + HerdrSpacesTreeProvider, + inferSpaceRoot, + isCurrentWindowRoot, +} from "./HerdrExplorer"; +import type { HerdrAgent, HerdrSpace } from "./types"; + +function space(overrides: Partial = {}): HerdrSpace { + return { + workspaceId: "w46", + label: "ulwcode", + status: "working", + paneCount: 1, + ...overrides, + }; +} + +function agent(overrides: Partial = {}): HerdrAgent { + return { + paneId: "w46:p1", + terminalId: "term-1", + agent: "pi", + status: "working", + title: "omo - ulwcode", + cwd: "/repo", + workspaceId: "w46", + ...overrides, + }; +} + +describe("HerdrExplorer", () => { + it("lists spaces as leaves that open the space command", async () => { + const store = new HerdrSnapshotStore({ + listWorkspaces: async () => [space()], + listAgents: async () => [agent()], + }); + await store.refresh(); + const provider = new HerdrSpacesTreeProvider(store); + + const children = await provider.getChildren(); + expect(children).toEqual([ + { + kind: "space", + space: space(), + }, + ]); + const item = provider.getTreeItem(children[0]); + expect(item.label).toBe("ulwcode"); + expect(item.description).toBe("working"); + expect(item.command).toEqual({ + command: "ulw.herdr.openSpace", + title: "Open Space", + arguments: [children[0]], + }); + }); + + it("lists agents as leaves that attach without a QuickPick", async () => { + const store = new HerdrSnapshotStore({ + listWorkspaces: async () => [space()], + listAgents: async () => [agent({ title: "" })], + }); + await store.refresh(); + const provider = new HerdrAgentsTreeProvider(store); + + const children = await provider.getChildren(); + expect(children).toEqual([ + { + kind: "agent", + agent: agent({ title: "" }), + }, + ]); + const item = provider.getTreeItem(children[0]); + expect(item.label).toBe("pi \u00b7 w46:p1"); + expect(item.command).toEqual({ + command: "ulw.herdr.openAgent", + title: "Attach Agent", + arguments: [children[0]], + }); + }); + + it("refreshes once when the tree is first expanded", async () => { + const listWorkspaces = vi.fn(async () => [space()]); + const listAgents = vi.fn(async () => [agent()]); + const store = new HerdrSnapshotStore({ listWorkspaces, listAgents }); + const provider = new HerdrAgentsTreeProvider(store); + + const children = await provider.getChildren(); + expect(listAgents).toHaveBeenCalledOnce(); + expect(listWorkspaces).toHaveBeenCalledOnce(); + expect(children).toHaveLength(1); + + await provider.getChildren(); + expect(listAgents).toHaveBeenCalledOnce(); + }); + + it("returns no children when Herdr lists are empty", async () => { + const store = new HerdrSnapshotStore({ + listWorkspaces: async () => [], + listAgents: async () => [], + }); + await store.refresh(); + expect(await new HerdrSpacesTreeProvider(store).getChildren()).toEqual([]); + expect(await new HerdrAgentsTreeProvider(store).getChildren()).toEqual([]); + }); + + it("infers a space root from the first agent cwd in that workspace", () => { + expect( + inferSpaceRoot("w46", [ + agent({ workspaceId: "w2K", cwd: "/other" }), + agent({ cwd: "/Users/ilseoblee/workspace/ULW/ulwcode" }), + ]), + ).toBe(path.resolve("/Users/ilseoblee/workspace/ULW/ulwcode")); + expect(inferSpaceRoot("w99", [agent()])).toBeUndefined(); + }); + + it("treats the current VS Code folder as the current space", () => { + const root = path.resolve("/repo"); + expect( + isCurrentWindowRoot(root, [{ uri: { fsPath: "/repo" } }]), + ).toBe(true); + expect( + isCurrentWindowRoot(root, [{ uri: { fsPath: "/other" } }]), + ).toBe(false); + }); + + it("polls Herdr lists on an interval until watch is stopped", async () => { + vi.useFakeTimers(); + const listWorkspaces = vi.fn(async () => [space()]); + const listAgents = vi + .fn() + .mockResolvedValueOnce([agent()]) + .mockResolvedValue([agent({ paneId: "w46:p2", terminalId: "term-2", title: "second" })]); + const store = new HerdrSnapshotStore({ listWorkspaces, listAgents }); + try { + store.startWatch(2_000); + + expect(listAgents).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(2_000); + expect(listAgents).toHaveBeenCalledOnce(); + expect(store.agents()).toEqual([agent()]); + + await vi.advanceTimersByTimeAsync(2_000); + expect(listAgents).toHaveBeenCalledTimes(2); + expect(store.agents()).toEqual([ + agent({ paneId: "w46:p2", terminalId: "term-2", title: "second" }), + ]); + + store.stopWatch(); + await vi.advanceTimersByTimeAsync(4_000); + expect(listAgents).toHaveBeenCalledTimes(2); + } finally { + store.stopWatch(); + vi.useRealTimers(); + } + }); + + it("keeps the previous snapshot when refresh fails", async () => { + const listWorkspaces = vi + .fn() + .mockResolvedValueOnce([space()]) + .mockRejectedValueOnce(new Error("server down")); + const store = new HerdrSnapshotStore({ + listWorkspaces, + listAgents: async () => [agent()], + }); + await store.refresh(); + await expect(store.refresh()).rejects.toThrow("server down"); + expect(store.spaces()).toEqual([space()]); + }); +}); diff --git a/src/herdr/HerdrExplorer.ts b/src/herdr/HerdrExplorer.ts new file mode 100644 index 0000000..e1109e1 --- /dev/null +++ b/src/herdr/HerdrExplorer.ts @@ -0,0 +1,186 @@ +import * as path from "node:path"; +import * as vscode from "vscode"; +import type { HerdrAgent, HerdrSpace } from "./types"; + +export interface HerdrExplorerSource { + listWorkspaces(): Promise; + listAgents(): Promise; +} + +export interface HerdrSpaceNode { + readonly kind: "space"; + readonly space: HerdrSpace; +} + +export interface HerdrAgentNode { + readonly kind: "agent"; + readonly agent: HerdrAgent; +} + +export class HerdrSnapshotStore { + private cachedSpaces: readonly HerdrSpace[] = []; + private cachedAgents: readonly HerdrAgent[] = []; + private loaded = false; + private inflight: Promise | undefined; + private watchHandle: ReturnType | undefined; + private readonly changeEmitter = new vscode.EventEmitter(); + + public readonly onDidChangeTreeData = this.changeEmitter.event; + + public constructor(private readonly source: HerdrExplorerSource) {} + + public spaces(): readonly HerdrSpace[] { + return this.cachedSpaces; + } + + public agents(): readonly HerdrAgent[] { + return this.cachedAgents; + } + + public async ensureLoaded(): Promise { + if (this.loaded) { + return; + } + if (this.inflight) { + await this.inflight; + return; + } + this.inflight = this.refresh().finally(() => { + this.inflight = undefined; + }); + await this.inflight; + } + + public async refresh(): Promise { + const [spaces, agents] = await Promise.all([ + this.source.listWorkspaces(), + this.source.listAgents(), + ]); + this.cachedSpaces = spaces; + this.cachedAgents = agents; + this.loaded = true; + this.changeEmitter.fire(); + } + + public startWatch(intervalMs: number): void { + this.stopWatch(); + this.watchHandle = setInterval(() => { + if (this.inflight) { + return; + } + this.inflight = this.refresh() + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + console.error(`[ULW Herdr] explorer poll failed: ${message}`); + }) + .finally(() => { + this.inflight = undefined; + }); + }, intervalMs); + } + + public stopWatch(): void { + if (this.watchHandle === undefined) { + return; + } + clearInterval(this.watchHandle); + this.watchHandle = undefined; + } + + public dispose(): void { + this.stopWatch(); + this.changeEmitter.dispose(); + } +} + +export class HerdrSpacesTreeProvider + implements vscode.TreeDataProvider +{ + public readonly onDidChangeTreeData = this.store.onDidChangeTreeData; + + public constructor(private readonly store: HerdrSnapshotStore) {} + + public getTreeItem(element: HerdrSpaceNode): vscode.TreeItem { + const item = new vscode.TreeItem( + element.space.label, + vscode.TreeItemCollapsibleState.None, + ); + item.description = element.space.status; + item.command = { + command: "ulw.herdr.openSpace", + title: "Open Space", + arguments: [element], + }; + return item; + } + + public async getChildren(): Promise { + await this.store.ensureLoaded(); + return this.store.spaces().map((space) => ({ kind: "space", space })); + } +} + +export class HerdrAgentsTreeProvider + implements vscode.TreeDataProvider +{ + public readonly onDidChangeTreeData = this.store.onDidChangeTreeData; + + public constructor(private readonly store: HerdrSnapshotStore) {} + + public getTreeItem(element: HerdrAgentNode): vscode.TreeItem { + const title = element.agent.title.trim(); + const item = new vscode.TreeItem( + title || `${element.agent.agent} \u00b7 ${element.agent.paneId}`, + vscode.TreeItemCollapsibleState.None, + ); + item.description = `${element.agent.status} \u00b7 ${element.agent.workspaceId}`; + item.command = { + command: "ulw.herdr.openAgent", + title: "Attach Agent", + arguments: [element], + }; + return item; + } + + public async getChildren(): Promise { + await this.store.ensureLoaded(); + return this.store.agents().map((agent) => ({ kind: "agent", agent })); + } +} + +export function agentAttachLabel(agent: HerdrAgent): string { + const title = agent.title.trim(); + return title || `${agent.agent} \u00b7 ${agent.paneId}`; +} + +export function normalizeRoot(value: string): string { + const resolved = path.resolve(value); + const parsed = path.parse(resolved); + const withoutTrailingSeparator = + resolved.length > parsed.root.length + ? resolved.replace(/[\\/]+$/, "") + : resolved; + return process.platform === "win32" + ? withoutTrailingSeparator.toLowerCase() + : withoutTrailingSeparator; +} + +export function inferSpaceRoot( + workspaceId: string, + agents: readonly HerdrAgent[], +): string | undefined { + const cwd = agents.find( + (agent) => agent.workspaceId === workspaceId && agent.cwd.length > 0, + )?.cwd; + return cwd ? path.resolve(cwd) : undefined; +} + +export function isCurrentWindowRoot( + root: string, + folders: readonly { readonly uri: { readonly fsPath: string } }[] | undefined, +): boolean { + const normalized = normalizeRoot(root); + return (folders ?? []).some( + (folder) => normalizeRoot(folder.uri.fsPath) === normalized, + ); +} diff --git a/src/herdr/HerdrInvocationResolver.test.ts b/src/herdr/HerdrInvocationResolver.test.ts new file mode 100644 index 0000000..a754a84 --- /dev/null +++ b/src/herdr/HerdrInvocationResolver.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, test } from "vitest"; +import { HerdrInvocationResolver } from "./HerdrInvocationResolver"; + +const platforms = ["darwin", "linux", "win32"] as const; + +describe.each(platforms)("HerdrInvocationResolver on %s", (platform) => { + test("session wins over socketPath and removes inherited socket selection", () => { + const resolved = HerdrInvocationResolver.resolve({ + executablePath: "/opt/herdr", + session: "work", + socketPath: "/explicit/herdr.sock", + env: { PATH: "/bin", HERDR_SOCKET_PATH: "/inherited/herdr.sock" }, + platform, + }); + + expect(resolved).toEqual({ + command: "/opt/herdr", + argsPrefix: ["--session", "work"], + env: { PATH: "/bin" }, + displayEndpoint: "session work", + warnings: [ + "Herdr session \"work\" is configured; socketPath \"/explicit/herdr.sock\" is ignored.", + ], + }); + expect(Object.isFrozen(resolved)).toBe(true); + expect(Object.isFrozen(resolved.argsPrefix)).toBe(true); + expect(Object.isFrozen(resolved.env)).toBe(true); + expect(Object.isFrozen(resolved.warnings)).toBe(true); + }); + + test("an explicit socketPath overrides the inherited socket", () => { + expect( + HerdrInvocationResolver.resolve({ + executablePath: "herdr-custom", + session: "", + socketPath: "/explicit/herdr.sock", + env: { + PATH: "/bin", + HERDR_SOCKET_PATH: "/inherited/herdr.sock", + OMITTED: undefined, + }, + platform, + }), + ).toEqual({ + command: "herdr-custom", + argsPrefix: [], + env: { PATH: "/bin", HERDR_SOCKET_PATH: "/explicit/herdr.sock" }, + displayEndpoint: "socket /explicit/herdr.sock", + warnings: [], + }); + }); + + test("passes through an inherited socket when no setting selects an endpoint", () => { + expect( + HerdrInvocationResolver.resolve({ + executablePath: "", + session: " ", + socketPath: undefined, + env: { HERDR_SOCKET_PATH: "/inherited/herdr.sock" }, + platform, + }), + ).toEqual({ + command: "herdr", + argsPrefix: [], + env: { HERDR_SOCKET_PATH: "/inherited/herdr.sock" }, + displayEndpoint: "inherited socket /inherited/herdr.sock", + warnings: [], + }); + }); + + test("uses the herdr default when no endpoint is selected", () => { + expect( + HerdrInvocationResolver.resolve({ + executablePath: undefined, + session: undefined, + socketPath: "", + env: { PATH: "/bin" }, + platform, + }), + ).toEqual({ + command: "herdr", + argsPrefix: [], + env: { PATH: "/bin" }, + displayEndpoint: "herdr default", + warnings: [], + }); + }); +}); + +describe("HerdrInvocationResolver ssh forward", () => { + const forward = { apiSocketPath: "/tmp/f.sock", clientSocketPath: "/tmp/f-client.sock" }; + + test("routes the invocation through the forwarded sockets", () => { + expect( + HerdrInvocationResolver.resolve({ + executablePath: "/opt/herdr", + session: "team", + remoteTarget: "u@h", + forwardSockets: forward, + socketPath: "/explicit/herdr.sock", + env: { PATH: "/bin", HERDR_SOCKET_PATH: "/inherited/herdr.sock" }, + platform: "darwin", + }), + ).toEqual({ + command: "/opt/herdr", + argsPrefix: [], + env: { + PATH: "/bin", + HERDR_SOCKET_PATH: "/tmp/f.sock", + HERDR_CLIENT_SOCKET_PATH: "/tmp/f-client.sock", + }, + displayEndpoint: "forward u@h", + warnings: [ + 'Herdr forwarding to "u@h" is active; session "team" is ignored.', + 'Herdr forwarding to "u@h" is active; socketPath "/explicit/herdr.sock" is ignored.', + ], + }); + }); + + test("uses the forwarded sockets without session or socket settings", () => { + expect( + HerdrInvocationResolver.resolve({ + executablePath: "herdr", + remoteTarget: "u@h", + forwardSockets: forward, + env: { PATH: "/bin" }, + platform: "darwin", + }), + ).toEqual({ + command: "herdr", + argsPrefix: [], + env: { + PATH: "/bin", + HERDR_SOCKET_PATH: "/tmp/f.sock", + HERDR_CLIENT_SOCKET_PATH: "/tmp/f-client.sock", + }, + displayEndpoint: "forward u@h", + warnings: [], + }); + }); + + test("ignores incomplete forward sockets and keeps legacy resolution", () => { + expect( + HerdrInvocationResolver.resolve({ + executablePath: "herdr", + remoteTarget: "u@h", + forwardSockets: { apiSocketPath: "/tmp/f.sock", clientSocketPath: " " }, + session: "s", + env: { HERDR_SOCKET_PATH: "/inherited/herdr.sock" }, + platform: "darwin", + }), + ).toEqual({ + command: "herdr", + argsPrefix: ["--session", "s"], + env: {}, + displayEndpoint: "session s", + warnings: [], + }); + }); +}); + +describe("HerdrInvocationResolver PATH", () => { + test("prepends common bin dirs so GUI VS Code can find herdr", () => { + const invocation = HerdrInvocationResolver.resolve({ + executablePath: "herdr", + session: "", + socketPath: "", + env: { PATH: "/usr/bin", HOME: "/Users/tester" }, + platform: "darwin", + }); + expect(invocation.env.PATH.split(":")).toEqual([ + "/Users/tester/.local/bin", + "/usr/bin", + ]); + }); +}); diff --git a/src/herdr/HerdrInvocationResolver.ts b/src/herdr/HerdrInvocationResolver.ts new file mode 100644 index 0000000..1a45b74 --- /dev/null +++ b/src/herdr/HerdrInvocationResolver.ts @@ -0,0 +1,98 @@ +import type { HerdrInvocation, HerdrInvocationInput } from "./types"; + +const SOCKET_ENV = "HERDR_SOCKET_PATH"; +const CLIENT_SOCKET_ENV = "HERDR_CLIENT_SOCKET_PATH"; + +export class HerdrInvocationResolver { + public static resolve(input: HerdrInvocationInput): HerdrInvocation { + const command = input.executablePath?.trim() || "herdr"; + const session = input.session?.trim() || ""; + const socketPath = input.socketPath?.trim() || ""; + const forward = input.forwardSockets; + const forwardApi = forward?.apiSocketPath?.trim() || ""; + const forwardClient = forward?.clientSocketPath?.trim() || ""; + const remoteTarget = input.remoteTarget?.trim() || ""; + const forwardActive = + forwardApi !== "" && forwardClient !== "" && remoteTarget !== ""; + const env = this.copyEnvironment(input.env); + this.prependCommonBinDirs(env, input.platform); + const argsPrefix: string[] = []; + const warnings: string[] = []; + let displayEndpoint = "herdr default"; + + if (forwardActive) { + env[SOCKET_ENV] = forwardApi; + env[CLIENT_SOCKET_ENV] = forwardClient; + if (session) { + warnings.push( + `Herdr forwarding to \"${remoteTarget}\" is active; session \"${session}\" is ignored.`, + ); + } + if (socketPath) { + warnings.push( + `Herdr forwarding to \"${remoteTarget}\" is active; socketPath \"${socketPath}\" is ignored.`, + ); + } + return Object.freeze({ + command, + argsPrefix: Object.freeze([]), + env: Object.freeze(env), + displayEndpoint: `forward ${remoteTarget}`, + warnings: Object.freeze(warnings), + }); + } + + if (session) { + argsPrefix.push("--session", session); + delete env[SOCKET_ENV]; + displayEndpoint = `session ${session}`; + if (socketPath) { + warnings.push( + `Herdr session \"${session}\" is configured; socketPath \"${socketPath}\" is ignored.`, + ); + } + } else if (socketPath) { + env[SOCKET_ENV] = socketPath; + displayEndpoint = `socket ${socketPath}`; + } else if (env[SOCKET_ENV]) { + displayEndpoint = `inherited socket ${env[SOCKET_ENV]}`; + } + + return Object.freeze({ + command, + argsPrefix: Object.freeze(argsPrefix), + env: Object.freeze(env), + displayEndpoint, + warnings: Object.freeze(warnings), + }); + } + + private static copyEnvironment( + source: Readonly>, + ): Record { + const env: Record = {}; + for (const [key, value] of Object.entries(source)) { + if (value !== undefined) { + env[key] = value; + } + } + return env; + } + + private static prependCommonBinDirs( + env: Record, + platform: HerdrInvocationInput["platform"], + ): void { + const home = env.HOME ?? env.USERPROFILE; + if (!home) { + return; + } + const extra = `${home}/.local/bin`; + const separator = platform === "win32" ? ";" : ":"; + const parts = (env.PATH ?? "").split(separator).filter(Boolean); + if (parts.includes(extra)) { + return; + } + env.PATH = [extra, ...parts].join(separator); + } +} diff --git a/src/herdr/HerdrSshForward.live.test.ts b/src/herdr/HerdrSshForward.live.test.ts new file mode 100644 index 0000000..3902acd --- /dev/null +++ b/src/herdr/HerdrSshForward.live.test.ts @@ -0,0 +1,139 @@ +// Opt-in live integration test: drives the REAL HerdrSshForward over REAL ssh +// against a live Herdr server. Skipped unless ULW_LIVE_SSH=1 is set. +import { execFile, spawn } from "child_process"; +import { promises as fs } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { describe, expect, test } from "vitest"; +import { HerdrSshForward } from "./HerdrSshForward"; + +const HERDR = process.env.ULW_E2E_HERDR ?? "/Users/ilseoblee/.local/bin/herdr"; + +function run( + cmd: string, + args: string[], + env?: NodeJS.ProcessEnv, +): Promise<{ stdout: string; stderr: string; code: number }> { + return new Promise((resolve) => { + execFile( + cmd, + args, + { encoding: "utf8", timeout: 15_000, env: { ...process.env, ...env } }, + (error, stdout, stderr) => { + const code = error ? 1 : 0; + resolve({ stdout: String(stdout), stderr: String(stderr), code }); + }, + ); + }); +} + +function runBridge( + cmd: string, + args: string[], + env: NodeJS.ProcessEnv, + holdMs: number, +): Promise<{ stdout: string; stderr: string; code: number }> { + return new Promise((resolve) => { + const child = spawn(cmd, args, { + env: { ...process.env, ...env }, + stdio: ["pipe", "pipe", "pipe"], + }); + setTimeout(() => child.stdin.end(), holdMs); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d: Buffer) => { + stdout += d.toString(); + }); + child.stderr.on("data", (d: Buffer) => { + stderr += d.toString(); + }); + child.on("close", (code) => resolve({ stdout, stderr, code: code ?? 0 })); + }); +} + +describe.skipIf(process.env.ULW_LIVE_SSH !== "1")( + "HerdrSshForward live over ssh localhost", + () => { + test("forwards listing and attach through the real ssh child, then disposes", async () => { + const forward = new HerdrSshForward({ + target: "localhost", + localApiSocket: join(tmpdir(), `ulw-live-${process.pid}.sock`), + localClientSocket: join(tmpdir(), `ulw-live-${process.pid}-client.sock`), + }); + + const sockets = await forward.start(); + expect(sockets.apiSocketPath).toBe( + join(tmpdir(), `ulw-live-${process.pid}.sock`), + ); + + const listing = await run(HERDR, ["agent", "list"], { + HERDR_SOCKET_PATH: sockets.apiSocketPath, + }); + const parsed = JSON.parse(listing.stdout) as { + result?: { agents?: unknown[] }; + }; + const agentCount = (parsed.result?.agents ?? []).length; + // eslint-disable-next-line no-console + console.log(`[live] agents through forward: ${agentCount}`); + expect(agentCount).toBeGreaterThan(0); + + const workspaceDir = join(tmpdir(), `ulw-live-ws-${process.pid}`); + await fs.mkdir(workspaceDir, { recursive: true }); + const created = await run( + HERDR, + ["workspace", "create", "--cwd", workspaceDir, "--label", "ulw-live-probe", "--no-focus"], + ); + const createdJson = JSON.parse(created.stdout) as { + result: { + workspace: { workspace_id: string }; + root_pane: { terminal_id: string }; + }; + }; + const workspaceId = createdJson.result.workspace.workspace_id; + const terminalId = createdJson.result.root_pane.terminal_id; + + const bridge = await runBridge( + HERDR, + [ + "terminal", + "session", + "control", + terminalId, + "--takeover", + "--cols", + "80", + "--rows", + "24", + ], + { HERDR_SOCKET_PATH: sockets.apiSocketPath }, + 3_000, + ); + const frames = (bridge.stdout.match(/terminal\.frame/g) ?? []).length; + const closures = (bridge.stdout.match(/terminal\.closed/g) ?? []).length; + // eslint-disable-next-line no-console + console.log( + `[live] bridge exit=${bridge.code} frames=${frames} closures=${closures} stderr="${bridge.stderr.trim()}"`, + ); + expect(bridge.code).toBe(0); + expect(frames).toBeGreaterThan(0); + expect(closures).toBe(1); + expect(bridge.stderr).not.toContain("failed"); + + await run(HERDR, ["workspace", "close", workspaceId]); + await fs.rm(workspaceDir, { recursive: true, force: true }); + + forward.dispose(); + + const sshAlive = await new Promise((resolve) => { + execFile("pgrep", ["-f", `ulw-live-${process.pid}.sock`], (error) => + resolve(error ? 0 : 1), + ); + }); + // eslint-disable-next-line no-console + console.log(`[live] ssh child alive after dispose: ${sshAlive}`); + expect(sshAlive).toBe(0); + await expect(fs.access(sockets.apiSocketPath)).rejects.toThrow(); + await expect(fs.access(sockets.clientSocketPath)).rejects.toThrow(); + }, 30_000); + }, +); diff --git a/src/herdr/HerdrSshForward.test.ts b/src/herdr/HerdrSshForward.test.ts new file mode 100644 index 0000000..05d4509 --- /dev/null +++ b/src/herdr/HerdrSshForward.test.ts @@ -0,0 +1,199 @@ +import { createServer, type Server } from "net"; +import { promises as fs } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { EventEmitter } from "events"; +import { PassThrough, Writable } from "stream"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + HerdrSshForward, + type HerdrSshForwardChild, + type HerdrSshSpawn, +} from "./HerdrSshForward"; + +class FakeSshChild extends EventEmitter implements HerdrSshForwardChild { + public readonly stderr = new PassThrough(); + public readonly kill = vi.fn((_signal?: NodeJS.Signals | number) => true); +} + +let uniqueId = 0; +const trackedServers: Server[] = []; +const trackedPaths: string[] = []; +const trackedChildren: FakeSshChild[] = []; + +function makePaths(): { api: string; client: string } { + uniqueId += 1; + const api = join(tmpdir(), `ulw-fwd-test-${process.pid}-${uniqueId}.sock`); + const client = join(tmpdir(), `ulw-fwd-test-${process.pid}-${uniqueId}-client.sock`); + trackedPaths.push(api, client); + return { api, client }; +} + +function listenOn(path: string): Promise { + const server = createServer(); + trackedServers.push(server); + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(path, () => resolve(server)); + }); +} + +async function listenAll(paths: readonly string[]): Promise { + return Promise.all(paths.map((path) => listenOn(path))); +} + +async function closeAll(): Promise { + for (const server of trackedServers.splice(0)) { + await new Promise((resolve) => server.close(() => resolve())); + } +} + +function makeForward( + paths: { api: string; client: string }, + overrides: Partial[0]> = {}, +) { + const child = new FakeSshChild(); + trackedChildren.push(child); + const spawnFn = vi.fn( + (_command: string, _args: readonly string[], _options: unknown) => child, + ) as unknown as HerdrSshSpawn; + const forward = new HerdrSshForward({ + target: "u@h", + localApiSocket: paths.api, + localClientSocket: paths.client, + spawnFn, + homeQuery: async () => "/remotehome", + ...overrides, + }); + return { forward, child, spawnFn }; +} + +afterEach(async () => { + await closeAll(); + for (const child of trackedChildren.splice(0)) { + child.removeAllListeners(); + } + for (const path of trackedPaths.splice(0)) { + await fs.rm(path, { force: true }); + } +}); + +describe("HerdrSshForward", () => { + test("builds dual -L arguments with the target as one argv element", () => { + expect( + HerdrSshForward.buildArgs( + { + remoteApiSocket: "/remotehome/.config/herdr/herdr.sock", + remoteClientSocket: "/remotehome/.config/herdr/herdr-client.sock", + }, + "u@h -J jump; rm -rf /", + "/tmp/a.sock", + "/tmp/a-client.sock", + ), + ).toEqual([ + "-nNT", + "-o", + "ExitOnForwardFailure=yes", + "-L", + "/tmp/a.sock:/remotehome/.config/herdr/herdr.sock", + "-L", + "/tmp/a-client.sock:/remotehome/.config/herdr/herdr-client.sock", + "u@h -J jump; rm -rf /", + ]); + }); + + test("queries the remote home, spawns ssh with dual forwards, and resolves when sockets accept", async () => { + const paths = makePaths(); + const servers = await listenAll([paths.api, paths.client]); + const { forward, spawnFn } = makeForward(paths); + + const sockets = await forward.start(); + + expect(sockets).toEqual({ + apiSocketPath: paths.api, + clientSocketPath: paths.client, + }); + expect(spawnFn).toHaveBeenCalledWith( + "ssh", + [ + "-nNT", + "-o", + "ExitOnForwardFailure=yes", + "-L", + `${paths.api}:/remotehome/.config/herdr/herdr.sock`, + "-L", + `${paths.client}:/remotehome/.config/herdr/herdr-client.sock`, + "u@h", + ], + { stdio: ["ignore", "ignore", "pipe"] }, + ); + void servers; + }); + + test("rejects with the ssh stderr when the child exits before readiness", async () => { + const paths = makePaths(); + const { forward, child } = makeForward(paths, { readinessTimeoutMs: 5_000 }); + + const starting = forward.start(); + // Let start() attach its exit/error listeners before the child fails. + await new Promise((resolve) => setImmediate(resolve)); + child.stderr.write("Host key verification failed.\n"); + child.emit("exit", 255, null); + + await expect(starting).rejects.toThrow(/Host key verification failed/); + }); + + test("stops polling and kills the child when readiness times out", async () => { + vi.useFakeTimers(); + const paths = makePaths(); + const { forward, child } = makeForward(paths, { readinessTimeoutMs: 200 }); + + const starting = forward.start(); + const expectation = expect(starting).rejects.toThrow(/did not become ready/); + await vi.advanceTimersByTimeAsync(200); + await expectation; + + expect(child.kill).toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + vi.useRealTimers(); + }); + + test("does not spawn ssh when dispose happens during the remote-home query", async () => { + const paths = makePaths(); + let releaseHome: (home: string) => void = () => undefined; + const { forward, spawnFn } = makeForward(paths, { + homeQuery: () => + new Promise((resolve) => { + releaseHome = resolve; + }), + }); + + const starting = forward.start(); + forward.dispose(); + releaseHome("/remotehome"); + + await expect(starting).rejects.toThrow(/disposed/); + expect(spawnFn).not.toHaveBeenCalled(); + }); + + test("dispose kills the ssh child and removes the local sockets", async () => { + const paths = makePaths(); + const servers = await listenAll([paths.api, paths.client]); + const { forward, child } = makeForward(paths); + + await forward.start(); + await closeAll(); + void servers; + for (const path of [paths.api, paths.client]) { + await fs.rm(path, { force: true }); + await fs.writeFile(path, "stale"); + } + + forward.dispose(); + + expect(child.kill).toHaveBeenCalled(); + for (const path of [paths.api, paths.client]) { + await expect(fs.access(path)).rejects.toThrow(); + } + }); +}); diff --git a/src/herdr/HerdrSshForward.ts b/src/herdr/HerdrSshForward.ts new file mode 100644 index 0000000..593c525 --- /dev/null +++ b/src/herdr/HerdrSshForward.ts @@ -0,0 +1,235 @@ +import { connect } from "net"; +import { execFile, spawn as nodeSpawn } from "child_process"; +import { rmSync } from "fs"; +import type { Readable } from "stream"; +import type { HerdrSocketForward } from "./types"; + +export interface HerdrSshForwardChild { + readonly stderr: Readable; + kill(signal?: NodeJS.Signals | number): boolean; + on( + event: "exit", + listener: (code: number | null, signal: NodeJS.Signals | null) => void, + ): unknown; + on(event: "error", listener: (error: Error) => void): unknown; +} + +export type HerdrSshSpawn = ( + command: string, + args: readonly string[], + options: { readonly stdio: readonly ["ignore", "ignore", "pipe"] }, +) => HerdrSshForwardChild; + +export interface HerdrSshForwardOptions { + readonly target: string; + readonly localApiSocket: string; + readonly localClientSocket: string; + readonly spawnFn?: HerdrSshSpawn; + readonly homeQuery?: () => Promise; + readonly readinessTimeoutMs?: number; +} + +export interface HerdrSshForwardPaths { + readonly remoteApiSocket: string; + readonly remoteClientSocket: string; +} + +const DEFAULT_READINESS_TIMEOUT_MS = 10_000; +const READINESS_POLL_MS = 100; +const MAX_SSH_DIAGNOSTIC_CHARS = 512; + +function defaultSpawn( + command: string, + args: readonly string[], + options: { readonly stdio: readonly ["ignore", "ignore", "pipe"] }, +): HerdrSshForwardChild { + return nodeSpawn(command, [...args], { + stdio: [...options.stdio], + }) as unknown as HerdrSshForwardChild; +} + +function tryConnect(path: string): Promise { + return new Promise((resolve, reject) => { + const socket = connect(path); + socket.once("connect", () => { + socket.destroy(); + resolve(); + }); + socket.once("error", reject); + }); +} + +export class HerdrSshForward { + private readonly options: HerdrSshForwardOptions; + private child: HerdrSshForwardChild | undefined; + private disposed = false; + + public constructor(options: HerdrSshForwardOptions) { + this.options = options; + } + + public static buildArgs( + paths: HerdrSshForwardPaths, + target: string, + localApiSocket: string, + localClientSocket: string, + ): string[] { + return [ + "-nNT", + "-o", + "ExitOnForwardFailure=yes", + "-L", + `${localApiSocket}:${paths.remoteApiSocket}`, + "-L", + `${localClientSocket}:${paths.remoteClientSocket}`, + target, + ]; + } + + public async start(): Promise { + const home = (await this.resolveRemoteHome()).replace(/\/+$/, ""); + if (this.disposed) { + throw new Error( + `Herdr ssh forward to "${this.options.target}" was disposed during startup.`, + ); + } + const paths: HerdrSshForwardPaths = { + remoteApiSocket: `${home}/.config/herdr/herdr.sock`, + remoteClientSocket: `${home}/.config/herdr/herdr-client.sock`, + }; + const spawnFn = this.options.spawnFn ?? defaultSpawn; + const child = spawnFn( + "ssh", + HerdrSshForward.buildArgs( + paths, + this.options.target, + this.options.localApiSocket, + this.options.localClientSocket, + ), + { stdio: ["ignore", "ignore", "pipe"] }, + ); + this.child = child; + + let stderr = ""; + child.stderr.on("data", (chunk: Buffer | string) => { + stderr = `${stderr}${chunk.toString()}`.slice(-MAX_SSH_DIAGNOSTIC_CHARS); + }); + + const readinessTimeoutMs = + this.options.readinessTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS; + let settled = false; + let pollTimer: ReturnType | undefined; + let readinessTimer: ReturnType | undefined; + const stop = (): void => { + if (settled) { + return; + } + settled = true; + if (pollTimer !== undefined) { + clearInterval(pollTimer); + } + if (readinessTimer !== undefined) { + clearTimeout(readinessTimer); + } + }; + const failed = new Promise((_, reject) => { + child.on("exit", (code) => { + stop(); + this.dispose(); + reject( + new Error( + `Herdr ssh forward to "${this.options.target}" failed (exit code ${code ?? "unknown"}): ${stderr.trim()}`, + ), + ); + }); + child.on("error", (error) => { + stop(); + this.dispose(); + reject( + new Error( + `Herdr ssh forward to "${this.options.target}" failed: ${error.message}`, + ), + ); + }); + readinessTimer = setTimeout(() => { + stop(); + this.dispose(); + reject( + new Error( + `Herdr ssh forward to "${this.options.target}" did not become ready within ${readinessTimeoutMs} ms. ${stderr.trim()}`, + ), + ); + }, readinessTimeoutMs); + }); + + const ready = new Promise((resolve) => { + const attempt = (): void => { + if (settled) { + return; + } + Promise.all([ + tryConnect(this.options.localApiSocket), + tryConnect(this.options.localClientSocket), + ]).then( + () => { + stop(); + resolve(); + }, + () => undefined, + ); + }; + attempt(); + pollTimer = setInterval(attempt, READINESS_POLL_MS); + child.on("exit", stop); + child.on("error", stop); + }); + + try { + await Promise.race([ready, failed]); + } catch (error) { + stop(); + this.dispose(); + throw error; + } finally { + stop(); + } + + return { + apiSocketPath: this.options.localApiSocket, + clientSocketPath: this.options.localClientSocket, + }; + } + + public dispose(): void { + this.disposed = true; + this.child?.kill("SIGTERM"); + this.child = undefined; + rmSync(this.options.localApiSocket, { force: true }); + rmSync(this.options.localClientSocket, { force: true }); + } + + private resolveRemoteHome(): Promise { + if (this.options.homeQuery) { + return this.options.homeQuery(); + } + return new Promise((resolve, reject) => { + execFile( + "ssh", + [this.options.target, "printf", "%s", "$HOME"], + { timeout: 10_000, encoding: "utf8" }, + (error, stdout, stderr) => { + const home = stdout.trim(); + if (error || home === "") { + reject( + new Error( + `could not resolve the remote home directory: ${stderr || error?.message || "empty output"}`, + ), + ); + return; + } + resolve(home); + }, + ); + }); + } +} diff --git a/src/herdr/errors.ts b/src/herdr/errors.ts new file mode 100644 index 0000000..488a295 --- /dev/null +++ b/src/herdr/errors.ts @@ -0,0 +1,69 @@ +export abstract class HerdrError extends Error { + public readonly displayEndpoint: string; + public readonly cause?: unknown; + + protected constructor( + message: string, + displayEndpoint: string, + cause?: unknown, + ) { + super(message); + this.name = new.target.name; + this.displayEndpoint = displayEndpoint; + this.cause = cause; + } +} + +export class HerdrNotInstalledError extends HerdrError { + public constructor( + displayEndpoint: string, + executable: string, + cause?: unknown, + ) { + super( + `Herdr executable \"${executable}\" was not found for ${displayEndpoint}.`, + displayEndpoint, + cause, + ); + } +} + +export class HerdrUnsupportedVersionError extends HerdrError { + public readonly version: string; + + public constructor(displayEndpoint: string, version: string) { + super( + `Herdr ${version} at ${displayEndpoint} is unsupported; version 0.8.0 or newer is required.`, + displayEndpoint, + ); + this.version = version; + } +} + +export class HerdrServerDownError extends HerdrError { + public constructor( + displayEndpoint: string, + detail: string, + cause?: unknown, + ) { + super( + `Herdr is unavailable at ${displayEndpoint}${detail ? `: ${detail}` : "."}`, + displayEndpoint, + cause, + ); + } +} + +export class HerdrProtocolError extends HerdrError { + public constructor( + displayEndpoint: string, + detail: string, + cause?: unknown, + ) { + super( + `Herdr returned an invalid response from ${displayEndpoint}: ${detail}`, + displayEndpoint, + cause, + ); + } +} diff --git a/src/herdr/types.ts b/src/herdr/types.ts new file mode 100644 index 0000000..395cd8f --- /dev/null +++ b/src/herdr/types.ts @@ -0,0 +1,62 @@ +export type HerdrPlatform = "darwin" | "linux" | "win32"; + +export interface HerdrSocketForward { + readonly apiSocketPath: string; + readonly clientSocketPath: string; +} + +export interface HerdrInvocationInput { + readonly executablePath?: string; + readonly session?: string; + readonly socketPath?: string; + readonly remoteTarget?: string; + readonly forwardSockets?: HerdrSocketForward; + readonly env: Readonly>; + readonly platform: HerdrPlatform; +} + +export interface HerdrInvocation { + readonly command: string; + readonly argsPrefix: readonly string[]; + readonly env: Readonly>; + readonly displayEndpoint: string; + readonly warnings: readonly string[]; +} + +export interface HerdrCommandResult { + readonly stdout: string; + readonly stderr: string; + readonly code: number; +} + +export type HerdrCommandRunner = ( + command: string, + args: readonly string[], + env: Readonly>, + timeoutMs: number, +) => Promise; + +export interface HerdrTimers { + readonly setTimeout: ( + callback: () => void, + timeoutMs: number, + ) => ReturnType; + readonly clearTimeout: (handle: ReturnType) => void; +} + +export interface HerdrAgent { + readonly paneId: string; + readonly terminalId: string; + readonly agent: string; + readonly status: string; + readonly title: string; + readonly cwd: string; + readonly workspaceId: string; +} + +export interface HerdrSpace { + readonly workspaceId: string; + readonly label: string; + readonly status: string; + readonly paneCount: number; +} diff --git a/src/providers/TerminalProvider.test.ts b/src/providers/TerminalProvider.test.ts index da77bc7..ffb275d 100644 --- a/src/providers/TerminalProvider.test.ts +++ b/src/providers/TerminalProvider.test.ts @@ -2,23 +2,37 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type * as ptyMock from "../test/mocks/node-pty"; import type { HostMessage, WebviewMessage } from "../types"; import * as vscode from "../test/mocks/vscode"; +import { + HerdrAttachController, + herdrSessionId, + type HerdrAttachPresenter, +} from "../herdr/HerdrAttachController"; import { TerminalManager } from "../terminals/TerminalManager"; +import type { + TerminalTransport, + TerminalTransportExitReason, +} from "../terminals/TerminalTransport"; import { TerminalProvider } from "./TerminalProvider"; vi.mock("node-pty", async () => vi.importActual("../test/mocks/node-pty")); const nodePty = await vi.importActual("../test/mocks/node-pty"); +const extensionUri = vscode.Uri.file("/extension") as unknown as import("vscode").Uri; interface TestWebview { html: string; options: unknown; readonly cspSource: string; readonly postMessage: ReturnType; - asWebviewUri(uri: vscode.Uri): vscode.Uri; + asWebviewUri(uri: unknown): unknown; onDidReceiveMessage(listener: (message: WebviewMessage) => void): vscode.Disposable; send(message: WebviewMessage): void; } + +function lastResult(results: readonly { value: T }[]) { + return results[results.length - 1]; +} function createView(): { readonly view: unknown; readonly webview: TestWebview } { const messageEmitter = new vscode.EventEmitter(); const disposeEmitter = new vscode.EventEmitter(); @@ -40,15 +54,384 @@ function createView(): { readonly view: unknown; readonly webview: TestWebview } }; } +class FakeHerdrTransport implements TerminalTransport { + public readonly kind = "herdr-control" as const; + private readonly outputEmitter = new vscode.EventEmitter<{ + data: string; + replay: "append" | "replace"; + }>(); + private readonly exitEmitter = new vscode.EventEmitter<{ + reason: TerminalTransportExitReason; + message?: string; + }>(); + public readonly onOutput = this.outputEmitter.event; + public readonly onExit = this.exitEmitter.event; + public readonly write = vi.fn(); + public readonly scroll = vi.fn(); + public readonly resize = vi.fn(); + public readonly close = vi.fn(async () => undefined); + + public output(data: string, replay: "append" | "replace"): void { + this.outputEmitter.fire({ data, replay }); + } + + public exit(reason: TerminalTransportExitReason, message?: string): void { + this.exitEmitter.fire(message ? { reason, message } : { reason }); + } +} + +function createAttachHarness(): { + readonly manager: TerminalManager; + readonly provider: TerminalProvider; + readonly controller: HerdrAttachController; + readonly transports: FakeHerdrTransport[]; +} { + const manager = new TerminalManager(); + const transports: FakeHerdrTransport[] = []; + let provider!: TerminalProvider; + const presenter: HerdrAttachPresenter = { + postReset: () => provider.postReset(), + postOutput: (data) => provider.postOutput(data), + postSourceState: (state) => provider.postSourceState(state), + }; + const controller = new HerdrAttachController({ + manager, + terminalId: "sidebar-shell", + transportFactory: () => { + const transport = new FakeHerdrTransport(); + transports.push(transport); + return transport; + }, + presenter, + }); + provider = new TerminalProvider(extensionUri, manager, controller); + return { manager, provider, controller, transports }; +} + +async function attach( + controller: HerdrAttachController, + transports: FakeHerdrTransport[], + label = "Agent A", +): Promise { + const attaching = controller.attach( + { terminalId: "herdr-terminal", label }, + { cols: 80, rows: 24 }, + ); + const transport = transports[0]; + transport.output("HERDR FULL", "replace"); + await attaching; + return transport; +} + +function posted(webview: { readonly postMessage: ReturnType }): unknown[] { + return webview.postMessage.mock.calls.map(([message]) => message); +} + describe("TerminalProvider", () => { - beforeEach(() => vscode.resetMocks()); + beforeEach(() => { + vscode.resetMocks(); + vscode.setConfiguration({ "ulw.sidebar.enabled": true }); + }); + + describe("Herdr controller integration", () => { + it("mirrors attach output to both mounted surfaces while badge and reset target the active surface", async () => { + const { provider, controller, transports } = createAttachHarness(); + const { view, webview } = createView(); + provider.resolveWebviewView(view as never); + webview.send({ type: "ready", cols: 80, rows: 24 }); + provider.toggleEditorLocation(); + const panel = lastResult(vscode.window.createWebviewPanel.mock.results) + ?.value as vscode.MockWebviewPanel; + panel.webview.send({ type: "ready", cols: 100, rows: 30 }); + webview.postMessage.mockClear(); + panel.webview.postMessage.mockClear(); + + await attach(controller, transports); + + expect(posted(panel.webview)).toEqual([ + { + type: "sourceState", + source: "herdr", + phase: "attaching", + label: "Agent A", + }, + { type: "reset" }, + { type: "output", data: "HERDR FULL" }, + { + type: "sourceState", + source: "herdr", + phase: "attached", + label: "Agent A", + }, + ]); + expect(posted(webview)).toEqual([ + { type: "output", data: "HERDR FULL" }, + ]); + expect(posted(webview)).toContainEqual({ + type: "output", + data: "HERDR FULL", + }); + expect(posted(webview)).not.toContainEqual( + expect.objectContaining({ type: "sourceState" }), + ); + expect(posted(webview)).not.toContainEqual({ type: "reset" }); + expect(posted(panel.webview)).toContainEqual({ + type: "output", + data: "HERDR FULL", + }); + expect(posted(panel.webview)).toContainEqual({ + type: "sourceState", + source: "herdr", + phase: "attached", + label: "Agent A", + }); + }); + + it("posts reset immediately before live replacement output", async () => { + const { provider, controller, transports } = createAttachHarness(); + const { view, webview } = createView(); + provider.resolveWebviewView(view as never); + webview.send({ type: "ready", cols: 80, rows: 24 }); + const transport = await attach(controller, transports); + webview.postMessage.mockClear(); + + transport.output("HERDR REPLACEMENT", "replace"); + + expect(posted(webview)).toEqual([ + { type: "reset" }, + { type: "output", data: "HERDR REPLACEMENT" }, + ]); + }); + + it("rehydrates attached source and current badge on every surface ready", async () => { + const { manager, provider, controller, transports } = createAttachHarness(); + const { view, webview } = createView(); + provider.resolveWebviewView(view as never); + webview.send({ type: "ready", cols: 80, rows: 24 }); + await attach(controller, transports); + transports[0].output(" + DELTA", "append"); + const ensureLocalShell = vi.spyOn(manager, "ensureLocalShell"); + + provider.toggleEditorLocation(); + const panel = lastResult(vscode.window.createWebviewPanel.mock.results) + ?.value as vscode.MockWebviewPanel; + panel.webview.postMessage.mockClear(); + panel.webview.send({ type: "ready", cols: 100, rows: 30 }); + + expect(posted(panel.webview)).toEqual([ + expect.objectContaining({ type: "config", fontSize: 14 }), + { + type: "sourceState", + source: "herdr", + phase: "attached", + label: "Agent A", + }, + { type: "reset" }, + { type: "output", data: "HERDR FULL + DELTA" }, + { type: "focus" }, + ]); + expect(ensureLocalShell).not.toHaveBeenCalled(); + expect(manager.activeSource("sidebar-shell")).toBe("herdr-control"); + }); + + it("rehydrates a switched surface mid-attach without replacing the attachment", async () => { + const { manager, provider, controller, transports } = createAttachHarness(); + const { view, webview } = createView(); + provider.resolveWebviewView(view as never); + webview.send({ type: "ready", cols: 80, rows: 24 }); + const shell = lastResult(nodePty.spawn.mock.results) + ?.value as ptyMock.MockPtyProcess; + shell.emitData("shell history"); + const ensureLocalShell = vi.spyOn(manager, "ensureLocalShell"); + const attaching = controller.attach( + { terminalId: "herdr-terminal", label: "Agent A" }, + { cols: 80, rows: 24 }, + ); + + provider.toggleEditorLocation(); + const panel = lastResult(vscode.window.createWebviewPanel.mock.results) + ?.value as vscode.MockWebviewPanel; + panel.webview.postMessage.mockClear(); + panel.webview.send({ type: "ready", cols: 100, rows: 30 }); + + expect(posted(panel.webview)).toEqual([ + expect.objectContaining({ type: "config", fontSize: 14 }), + { + type: "sourceState", + source: "herdr", + phase: "attaching", + label: "Agent A", + }, + { type: "reset" }, + { type: "output", data: "shell history" }, + { type: "focus" }, + ]); + expect(ensureLocalShell).not.toHaveBeenCalled(); + + transports[0].output("HERDR FULL", "replace"); + await attaching; + expect(manager.activeSource("sidebar-shell")).toBe("herdr-control"); + }); + + it("rejects inactive input and routes active input and provider writes to Herdr", async () => { + const { provider, controller, transports } = createAttachHarness(); + const { view, webview } = createView(); + provider.resolveWebviewView(view as never); + webview.send({ type: "ready", cols: 80, rows: 24 }); + const transport = await attach(controller, transports); + provider.toggleEditorLocation(); + const panel = lastResult(vscode.window.createWebviewPanel.mock.results) + ?.value as vscode.MockWebviewPanel; + panel.webview.send({ type: "ready", cols: 100, rows: 30 }); + transport.write.mockClear(); + + webview.send({ type: "input", data: "inactive\r" }); + panel.webview.send({ type: "input", data: "active\r" }); + provider.write("selection-or-file"); + + expect(transport.write.mock.calls).toEqual([ + ["active\r"], + ["selection-or-file"], + ]); + }); + + it("focuses the active Herdr editor tab and keeps global writes on it", async () => { + const manager = new TerminalManager(); + const writeSpy = vi + .spyOn(manager, "write") + .mockImplementation(() => undefined); + const provider = new TerminalProvider(extensionUri, manager); + const makeController = ( + sessionId: string, + presenter: HerdrAttachPresenter, + ) => + new HerdrAttachController({ + manager, + terminalId: sessionId, + transportFactory: () => { + throw new Error("no transport expected in this test"); + }, + presenter, + }); + const open = (terminalId: string) => + provider.openHerdrSession( + { terminalId, label: terminalId }, + async () => undefined, + makeController, + ); + + await open("agent-a"); + const panelA = lastResult(vscode.window.createWebviewPanel.mock.results) + ?.value as vscode.MockWebviewPanel; + await open("agent-b"); + const panelB = lastResult(vscode.window.createWebviewPanel.mock.results) + ?.value as vscode.MockWebviewPanel; + const idA = herdrSessionId("agent-a"); + const idB = herdrSessionId("agent-b"); + + expect(provider.activeSessionId()).toBe(idB); + panelA.fireViewState(true); + expect(provider.activeSessionId()).toBe(idA); + provider.write("to-focused"); + expect(writeSpy).toHaveBeenLastCalledWith(idA, "to-focused"); + + const resizeSpy = vi + .spyOn(manager, "resize") + .mockImplementation(() => undefined); + const scrollSpy = vi + .spyOn(manager, "scroll") + .mockImplementation(() => undefined); + writeSpy.mockClear(); + panelB.webview.send({ type: "input", data: "from-inactive\r" }); + panelB.webview.send({ type: "resize", cols: 120, rows: 40 }); + panelB.webview.send({ + type: "scroll", + direction: "up", + lines: 2, + source: "wheel", + column: 0, + row: 0, + modifiers: 0, + }); + expect(writeSpy.mock.calls).toEqual([]); + expect(resizeSpy).not.toHaveBeenCalled(); + expect(scrollSpy).not.toHaveBeenCalled(); + + panelA.webview.send({ type: "input", data: "from-active\r" }); + expect(writeSpy.mock.calls).toEqual([[idA, "from-active\r"]]); + + panelA.dispose(); + expect(provider.activeSessionId()).toBe(idB); + }); + + it("restores shell without shell-exit banner when bridge closes", async () => { + const { provider, controller, transports } = createAttachHarness(); + const { view, webview } = createView(); + provider.resolveWebviewView(view as never); + webview.send({ type: "ready", cols: 80, rows: 24 }); + const shell = lastResult(nodePty.spawn.mock.results) + ?.value as ptyMock.MockPtyProcess; + shell.emitData("shell replay"); + const transport = await attach(controller, transports); + webview.postMessage.mockClear(); + + transport.exit("takeover", "taken elsewhere"); + await Promise.resolve(); + await Promise.resolve(); + + expect(posted(webview)).toEqual([ + { + type: "sourceState", + source: "shell", + phase: "error", + message: "taken elsewhere", + }, + { type: "sourceState", source: "shell", phase: "shell" }, + ]); + expect(posted(webview)).not.toContainEqual( + expect.objectContaining({ type: "exit" }), + ); + }); + + it("leaves shell display untouched when attach fails before the first frame", async () => { + const { provider, controller, transports } = createAttachHarness(); + const { view, webview } = createView(); + provider.resolveWebviewView(view as never); + webview.send({ type: "ready", cols: 80, rows: 24 }); + webview.postMessage.mockClear(); + + const attaching = controller.attach( + { terminalId: "herdr-terminal", label: "Agent A" }, + { cols: 80, rows: 24 }, + ); + transports[0].exit("protocol-error", "bad first frame"); + await attaching; + + expect(posted(webview)).toEqual([ + { + type: "sourceState", + source: "herdr", + phase: "attaching", + label: "Agent A", + }, + { + type: "sourceState", + source: "shell", + phase: "error", + message: "bad first frame", + }, + { type: "sourceState", source: "shell", phase: "shell" }, + ]); + expect(posted(webview)).not.toContainEqual({ type: "reset" }); + }); + }); it("starts one shell from ready and forwards the terminal contract", () => { const manager = new TerminalManager(); - const createSpy = vi.spyOn(manager, "createTerminal"); + const ensureSpy = vi.spyOn(manager, "ensureLocalShell"); const writeSpy = vi.spyOn(manager, "write"); const resizeSpy = vi.spyOn(manager, "resize"); - const provider = new TerminalProvider(vscode.Uri.file("/extension"), manager); + const provider = new TerminalProvider(extensionUri, manager); const { view, webview } = createView(); provider.resolveWebviewView(view as never); @@ -56,24 +439,26 @@ describe("TerminalProvider", () => { webview.send({ type: "input", data: "pwd\r" }); webview.send({ type: "resize", cols: 100, rows: 30 }); - expect(createSpy).toHaveBeenCalledOnce(); - expect(createSpy).toHaveBeenCalledWith("sidebar-shell", 90, 28); + expect(ensureSpy).toHaveBeenCalledOnce(); + expect(ensureSpy).toHaveBeenCalledWith("sidebar-shell", 90, 28); expect(writeSpy).toHaveBeenCalledWith("sidebar-shell", "pwd\r"); expect(resizeSpy).toHaveBeenCalledWith("sidebar-shell", 100, 30); - expect(webview.postMessage).toHaveBeenCalledWith( + expect(posted(webview)).toEqual([ expect.objectContaining({ type: "config", fontSize: 14 }), - ); - expect(webview.postMessage).toHaveBeenCalledWith({ type: "focus" }); + { type: "sourceState", source: "shell", phase: "shell" }, + { type: "reset" }, + { type: "focus" }, + ]); expect(webview.html).toContain('id="terminal-container"'); }); it("forwards PTY output and exit without pane or session metadata", () => { const manager = new TerminalManager(); - const provider = new TerminalProvider(vscode.Uri.file("/extension"), manager); + const provider = new TerminalProvider(extensionUri, manager); const { view, webview } = createView(); provider.resolveWebviewView(view as never); webview.send({ type: "ready", cols: 80, rows: 24 }); - const process = nodePty.spawn.mock.results.at(-1) + const process = lastResult(nodePty.spawn.mock.results) ?.value as ptyMock.MockPtyProcess; process.emitData("hello"); @@ -92,7 +477,7 @@ describe("TerminalProvider", () => { it("copies drag-selected terminal text through the host clipboard", () => { const manager = new TerminalManager(); - const provider = new TerminalProvider(vscode.Uri.file("/extension"), manager); + const provider = new TerminalProvider(extensionUri, manager); const { view, webview } = createView(); provider.resolveWebviewView(view as never); @@ -105,7 +490,7 @@ describe("TerminalProvider", () => { it("ignores empty drag selections", () => { const manager = new TerminalManager(); - const provider = new TerminalProvider(vscode.Uri.file("/extension"), manager); + const provider = new TerminalProvider(extensionUri, manager); const { view, webview } = createView(); provider.resolveWebviewView(view as never); @@ -116,17 +501,27 @@ describe("TerminalProvider", () => { it("saves pasted images and posts their path to the terminal", async () => { const manager = new TerminalManager(); - const provider = new TerminalProvider(vscode.Uri.file("/extension"), manager); + const provider = new TerminalProvider(extensionUri, manager); const { view, webview } = createView(); provider.resolveWebviewView(view as never); webview.send({ type: "ready", cols: 80, rows: 24 }); + let resolveClipboard!: () => void; + const clipboardPosted = new Promise((resolve) => { + resolveClipboard = resolve; + }); + webview.postMessage.mockImplementation(async (message: HostMessage) => { + if (message.type === "clipboardImage") { + resolveClipboard(); + } + return true; + }); webview.send({ type: "imagePasted", data: "data:image/png;base64,ZmFrZQ==", }); - await new Promise((resolve) => setTimeout(resolve, 50)); + await clipboardPosted; expect(webview.postMessage).toHaveBeenCalledWith( expect.objectContaining({ type: "clipboardImage" }), ); @@ -134,7 +529,7 @@ describe("TerminalProvider", () => { it("rejects oversized images", () => { const manager = new TerminalManager(); - const provider = new TerminalProvider(vscode.Uri.file("/extension"), manager); + const provider = new TerminalProvider(extensionUri, manager); const { view, webview } = createView(); provider.resolveWebviewView(view as never); webview.send({ type: "ready", cols: 80, rows: 24 }); @@ -152,7 +547,7 @@ describe("TerminalProvider", () => { it("rejects malformed image data", () => { const manager = new TerminalManager(); - const provider = new TerminalProvider(vscode.Uri.file("/extension"), manager); + const provider = new TerminalProvider(extensionUri, manager); const { view, webview } = createView(); provider.resolveWebviewView(view as never); webview.send({ type: "ready", cols: 80, rows: 24 }); @@ -166,7 +561,7 @@ describe("TerminalProvider", () => { it("kills the native shell when disposed", () => { const manager = new TerminalManager(); - const provider = new TerminalProvider(vscode.Uri.file("/extension"), manager); + const provider = new TerminalProvider(extensionUri, manager); const { view, webview } = createView(); provider.resolveWebviewView(view as never); webview.send({ type: "ready", cols: 80, rows: 24 }); @@ -179,7 +574,7 @@ describe("TerminalProvider", () => { it("reuses the existing shell and reacts to terminal settings", () => { const manager = new TerminalManager(); - const provider = new TerminalProvider(vscode.Uri.file("/extension"), manager); + const provider = new TerminalProvider(extensionUri, manager); const { view, webview } = createView(); provider.resolveWebviewView(view as never); webview.send({ type: "ready", cols: 80, rows: 24 }); @@ -200,13 +595,21 @@ describe("TerminalProvider", () => { it("filters unrelated PTY events and disconnects a disposed view", () => { const manager = new TerminalManager(); - const provider = new TerminalProvider(vscode.Uri.file("/extension"), manager); + const provider = new TerminalProvider(extensionUri, manager); const { view, webview } = createView(); provider.resolveWebviewView(view as never); const count = webview.postMessage.mock.calls.length; - manager["dataEmitter"].fire({ id: "other", data: "ignored" }); - manager["exitEmitter"].fire({ id: "other", code: 1 }); + manager["dataEmitter"].fire({ + id: "other", + data: "ignored", + replay: "append", + }); + manager["exitEmitter"].fire({ + id: "other", + code: 1, + reason: "process-exit", + }); (view as { onDidDispose: (listener: () => void) => vscode.Disposable }) .onDidDispose(() => undefined); provider["view"] = undefined; @@ -217,7 +620,7 @@ describe("TerminalProvider", () => { it("opens an editor-group terminal surface with its own html and message bridge", () => { const manager = new TerminalManager(); - const provider = new TerminalProvider(vscode.Uri.file("/extension"), manager); + const provider = new TerminalProvider(extensionUri, manager); const { view, webview } = createView(); provider.resolveWebviewView(view as never); webview.send({ type: "ready", cols: 80, rows: 24 }); @@ -239,7 +642,7 @@ describe("TerminalProvider", () => { "workbench.action.closeAuxiliaryBar", ); - const panel = vscode.window.createWebviewPanel.mock.results.at(-1) + const panel = lastResult(vscode.window.createWebviewPanel.mock.results) ?.value as vscode.MockWebviewPanel; expect(panel.webview.html).toContain('id="terminal-container"'); expect(panel.webview.html).not.toBe(webview.html); @@ -247,22 +650,22 @@ describe("TerminalProvider", () => { it("routes editor ready/input/resize and PTY output through the editor surface", () => { const manager = new TerminalManager(); - const createSpy = vi.spyOn(manager, "createTerminal"); + const ensureSpy = vi.spyOn(manager, "ensureLocalShell"); const writeSpy = vi.spyOn(manager, "write"); const resizeSpy = vi.spyOn(manager, "resize"); - const provider = new TerminalProvider(vscode.Uri.file("/extension"), manager); + const provider = new TerminalProvider(extensionUri, manager); const { view, webview } = createView(); provider.resolveWebviewView(view as never); provider.toggleEditorLocation(); - const panel = vscode.window.createWebviewPanel.mock.results.at(-1) + const panel = lastResult(vscode.window.createWebviewPanel.mock.results) ?.value as vscode.MockWebviewPanel; panel.webview.send({ type: "ready", cols: 120, rows: 40 }); panel.webview.send({ type: "input", data: "ls\r" }); panel.webview.send({ type: "resize", cols: 130, rows: 42 }); - expect(createSpy).toHaveBeenCalledWith("sidebar-shell", 120, 40); + expect(ensureSpy).toHaveBeenCalledWith("sidebar-shell", 120, 40); expect(writeSpy).toHaveBeenCalledWith("sidebar-shell", "ls\r"); expect(resizeSpy).toHaveBeenCalledWith("sidebar-shell", 130, 42); expect(panel.webview.postMessage).toHaveBeenCalledWith( @@ -270,7 +673,7 @@ describe("TerminalProvider", () => { ); expect(panel.webview.postMessage).toHaveBeenCalledWith({ type: "focus" }); - const process = nodePty.spawn.mock.results.at(-1) + const process = lastResult(nodePty.spawn.mock.results) ?.value as ptyMock.MockPtyProcess; process.emitData("editor-out"); @@ -286,7 +689,7 @@ describe("TerminalProvider", () => { it("ignores ready and resize from the inactive sidebar while editor mode is active", () => { const manager = new TerminalManager(); - const provider = new TerminalProvider(vscode.Uri.file("/extension"), manager); + const provider = new TerminalProvider(extensionUri, manager); const { view, webview } = createView(); provider.resolveWebviewView(view as never); webview.send({ type: "ready", cols: 80, rows: 24 }); @@ -303,13 +706,13 @@ describe("TerminalProvider", () => { it("returns to the sidebar surface when toggled again", () => { const manager = new TerminalManager(); - const provider = new TerminalProvider(vscode.Uri.file("/extension"), manager); + const provider = new TerminalProvider(extensionUri, manager); const { view, webview } = createView(); provider.resolveWebviewView(view as never); webview.send({ type: "ready", cols: 80, rows: 24 }); provider.toggleEditorLocation(); - const panel = vscode.window.createWebviewPanel.mock.results.at(-1) + const panel = lastResult(vscode.window.createWebviewPanel.mock.results) ?.value as vscode.MockWebviewPanel; expect(provider.isEditorLocation()).toBe(true); @@ -323,18 +726,40 @@ describe("TerminalProvider", () => { expect(webview.postMessage).toHaveBeenCalledWith({ type: "focus" }); }); + it("keeps the editor panel when sidebar ULW is disabled", () => { + vscode.setConfiguration({ "ulw.sidebar.enabled": false }); + const manager = new TerminalManager(); + const provider = new TerminalProvider(extensionUri, manager); + const { view, webview } = createView(); + provider.resolveWebviewView(view as never); + webview.send({ type: "ready", cols: 80, rows: 24 }); + + provider.toggleEditorLocation(); + const panel = lastResult(vscode.window.createWebviewPanel.mock.results) + ?.value as vscode.MockWebviewPanel; + vscode.commands.executeCommand.mockClear(); + + provider.toggleEditorLocation(); + + expect(provider.isEditorLocation()).toBe(true); + expect(panel.dispose).not.toHaveBeenCalled(); + expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith( + "workbench.view.extension.ulwContainer", + ); + }); + it("returns to sidebar when the editor panel is closed by the workbench", () => { const manager = new TerminalManager(); - const provider = new TerminalProvider(vscode.Uri.file("/extension"), manager); + const provider = new TerminalProvider(extensionUri, manager); const { view, webview } = createView(); provider.resolveWebviewView(view as never); provider.toggleEditorLocation(); - const panel = vscode.window.createWebviewPanel.mock.results.at(-1) + const panel = lastResult(vscode.window.createWebviewPanel.mock.results) ?.value as vscode.MockWebviewPanel; expect(provider.isEditorLocation()).toBe(true); - panel.dispose(); + (panel.dispose as unknown as () => void)(); expect(provider.isEditorLocation()).toBe(false); expect(webview.postMessage).toHaveBeenCalledWith({ type: "focus" }); @@ -343,44 +768,64 @@ describe("TerminalProvider", () => { ); }); + it("stays in editor mode when the panel is closed and sidebar ULW is disabled", () => { + vscode.setConfiguration({ "ulw.sidebar.enabled": false }); + const manager = new TerminalManager(); + const provider = new TerminalProvider(extensionUri, manager); + provider.toggleEditorLocation(); + const panel = lastResult(vscode.window.createWebviewPanel.mock.results) + ?.value as vscode.MockWebviewPanel; + vscode.commands.executeCommand.mockClear(); + + (panel.dispose as unknown as () => void)(); + + expect(provider.isEditorLocation()).toBe(true); + expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith( + "workbench.view.extension.ulwContainer", + ); + }); + it("replays scrollback when the editor surface becomes ready", () => { const manager = new TerminalManager(); - const provider = new TerminalProvider(vscode.Uri.file("/extension"), manager); + const provider = new TerminalProvider(extensionUri, manager); const { view, webview } = createView(); provider.resolveWebviewView(view as never); webview.send({ type: "ready", cols: 80, rows: 24 }); - const process = nodePty.spawn.mock.results.at(-1) + const process = lastResult(nodePty.spawn.mock.results) ?.value as ptyMock.MockPtyProcess; process.emitData("prior output"); provider.toggleEditorLocation(); - const panel = vscode.window.createWebviewPanel.mock.results.at(-1) + const panel = lastResult(vscode.window.createWebviewPanel.mock.results) ?.value as vscode.MockWebviewPanel; panel.webview.postMessage.mockClear(); panel.webview.send({ type: "ready", cols: 100, rows: 30 }); - expect(panel.webview.postMessage).toHaveBeenCalledWith({ - type: "output", - data: "prior output", - }); + expect(posted(panel.webview)).toEqual([ + expect.objectContaining({ type: "config", fontSize: 14 }), + { type: "sourceState", source: "shell", phase: "shell" }, + { type: "reset" }, + { type: "output", data: "prior output" }, + { type: "focus" }, + ]); }); it("mirrors live PTY output to both surfaces so the inactive one keeps running session text", () => { const manager = new TerminalManager(); - const provider = new TerminalProvider(vscode.Uri.file("/extension"), manager); + const provider = new TerminalProvider(extensionUri, manager); const { view, webview } = createView(); provider.resolveWebviewView(view as never); webview.send({ type: "ready", cols: 80, rows: 24 }); provider.toggleEditorLocation(); - const panel = vscode.window.createWebviewPanel.mock.results.at(-1) + const panel = lastResult(vscode.window.createWebviewPanel.mock.results) ?.value as vscode.MockWebviewPanel; panel.webview.send({ type: "ready", cols: 100, rows: 30 }); webview.postMessage.mockClear(); panel.webview.postMessage.mockClear(); - const process = nodePty.spawn.mock.results.at(-1) + const process = lastResult(nodePty.spawn.mock.results) ?.value as ptyMock.MockPtyProcess; process.emitData("agent still running\r\n"); @@ -396,7 +841,7 @@ describe("TerminalProvider", () => { it("ignores input from the inactive sidebar while editor mode is active", () => { const manager = new TerminalManager(); - const provider = new TerminalProvider(vscode.Uri.file("/extension"), manager); + const provider = new TerminalProvider(extensionUri, manager); const { view, webview } = createView(); provider.resolveWebviewView(view as never); webview.send({ type: "ready", cols: 80, rows: 24 }); @@ -411,7 +856,7 @@ describe("TerminalProvider", () => { it("reads ulw.defaultLocation as editor by default and sidebar on request", () => { const manager = new TerminalManager(); - const provider = new TerminalProvider(vscode.Uri.file("/extension"), manager); + const provider = new TerminalProvider(extensionUri, manager); expect(provider.getDefaultLocation()).toBe("editor"); vscode.setConfiguration({ "ulw.defaultLocation": "sidebar" }); @@ -422,26 +867,41 @@ describe("TerminalProvider", () => { it("openAtConfiguredLocation opens the editor by default and only stays sidebar when configured", () => { const manager = new TerminalManager(); - const provider = new TerminalProvider(vscode.Uri.file("/extension"), manager); + const provider = new TerminalProvider(extensionUri, manager); provider.openAtConfiguredLocation(); expect(vscode.window.createWebviewPanel).toHaveBeenCalledOnce(); expect(provider.isEditorLocation()).toBe(true); vscode.window.createWebviewPanel.mockClear(); - vscode.setConfiguration({ "ulw.defaultLocation": "sidebar" }); + vscode.setConfiguration({ + "ulw.defaultLocation": "sidebar", + "ulw.sidebar.enabled": true, + }); provider.openAtConfiguredLocation(); expect(vscode.window.createWebviewPanel).not.toHaveBeenCalled(); }); + it("opens the editor even when defaultLocation is sidebar if sidebar ULW is disabled", () => { + vscode.setConfiguration({ + "ulw.defaultLocation": "sidebar", + "ulw.sidebar.enabled": false, + }); + const manager = new TerminalManager(); + const provider = new TerminalProvider(extensionUri, manager); + provider.openAtConfiguredLocation(); + expect(vscode.window.createWebviewPanel).toHaveBeenCalledOnce(); + expect(provider.isEditorLocation()).toBe(true); + }); + it("starts the shell from editor ready without a sidebar surface", () => { const manager = new TerminalManager(); - const createSpy = vi.spyOn(manager, "createTerminal"); + const ensureSpy = vi.spyOn(manager, "ensureLocalShell"); const writeSpy = vi.spyOn(manager, "write"); - const provider = new TerminalProvider(vscode.Uri.file("/extension"), manager); + const provider = new TerminalProvider(extensionUri, manager); provider.toggleEditorLocation(); - const panel = vscode.window.createWebviewPanel.mock.results.at(-1) + const panel = lastResult(vscode.window.createWebviewPanel.mock.results) ?.value as vscode.MockWebviewPanel; expect(panel.webview.html).toContain('id="terminal-container"'); @@ -450,7 +910,7 @@ describe("TerminalProvider", () => { panel.webview.send({ type: "ready", cols: 90, rows: 28 }); panel.webview.send({ type: "input", data: "echo hi\r" }); - expect(createSpy).toHaveBeenCalledWith("sidebar-shell", 90, 28); + expect(ensureSpy).toHaveBeenCalledWith("sidebar-shell", 90, 28); expect(writeSpy).toHaveBeenCalledWith("sidebar-shell", "echo hi\r"); expect(panel.webview.postMessage).toHaveBeenCalledWith( expect.objectContaining({ type: "config" }), @@ -460,16 +920,16 @@ describe("TerminalProvider", () => { it("initializes a newly mounted sidebar even while editor mode is active", () => { const manager = new TerminalManager(); - const provider = new TerminalProvider(vscode.Uri.file("/extension"), manager); + const provider = new TerminalProvider(extensionUri, manager); const { view, webview } = createView(); provider.resolveWebviewView(view as never); webview.send({ type: "ready", cols: 80, rows: 24 }); - const process = nodePty.spawn.mock.results.at(-1) + const process = lastResult(nodePty.spawn.mock.results) ?.value as ptyMock.MockPtyProcess; process.emitData("history"); provider.toggleEditorLocation(); - const panel = vscode.window.createWebviewPanel.mock.results.at(-1) + const panel = lastResult(vscode.window.createWebviewPanel.mock.results) ?.value as vscode.MockWebviewPanel; panel.webview.send({ type: "ready", cols: 100, rows: 30 }); @@ -489,8 +949,8 @@ describe("TerminalProvider", () => { it("dispose suppresses the workbench restore side effect", () => { const manager = new TerminalManager(); - const provider = new TerminalProvider(vscode.Uri.file("/extension"), manager); - const { view, webview } = createView(); + const provider = new TerminalProvider(extensionUri, manager); + const { view } = createView(); provider.resolveWebviewView(view as never); provider.toggleEditorLocation(); vscode.commands.executeCommand.mockClear(); @@ -501,4 +961,133 @@ describe("TerminalProvider", () => { "workbench.view.extension.ulwContainer", ); }); + + describe("characterization: current one-PTY provider behavior", () => { + it("ensures or resizes from ready and posts config before focus", () => { + const manager = new TerminalManager(); + const ensureSpy = vi.spyOn(manager, "ensureLocalShell"); + const resizeSpy = vi.spyOn(manager, "resize"); + const provider = new TerminalProvider(extensionUri, manager); + const { view, webview } = createView(); + + provider.resolveWebviewView(view as never); + webview.send({ type: "ready", cols: 90, rows: 28 }); + webview.send({ type: "ready", cols: 100, rows: 30 }); + + expect(ensureSpy).toHaveBeenCalledWith("sidebar-shell", 90, 28); + expect(resizeSpy).toHaveBeenCalledWith("sidebar-shell", 100, 30); + expect(nodePty.spawn).toHaveBeenCalledWith( + expect.any(String), + expect.any(Array), + expect.objectContaining({ cols: 90, rows: 28 }), + ); + expect(webview.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "config", fontSize: 14 }), + ); + expect(webview.postMessage).toHaveBeenCalledWith({ type: "focus" }); + }); + + it("ignores input and resize from the inactive surface", () => { + const manager = new TerminalManager(); + const provider = new TerminalProvider(extensionUri, manager); + const { view, webview } = createView(); + provider.resolveWebviewView(view as never); + webview.send({ type: "ready", cols: 80, rows: 24 }); + const writeSpy = vi.spyOn(manager, "write"); + const resizeSpy = vi.spyOn(manager, "resize"); + + provider.toggleEditorLocation(); + writeSpy.mockClear(); + resizeSpy.mockClear(); + webview.send({ type: "input", data: "ghost\r" }); + webview.send({ type: "resize", cols: 11, rows: 11 }); + + expect(writeSpy).not.toHaveBeenCalled(); + expect(resizeSpy).not.toHaveBeenCalled(); + }); + + it("replays scrollback to a freshly read surface, caps it, and clears on exit", () => { + const manager = new TerminalManager(); + const provider = new TerminalProvider(extensionUri, manager); + const { view, webview } = createView(); + provider.resolveWebviewView(view as never); + webview.send({ type: "ready", cols: 80, rows: 24 }); + const process = lastResult(nodePty.spawn.mock.results) + ?.value as ptyMock.MockPtyProcess; + const large = "x".repeat(500_100); + process.emitData(large); + + provider.toggleEditorLocation(); + const panel = lastResult(vscode.window.createWebviewPanel.mock.results) + ?.value as vscode.MockWebviewPanel; + panel.webview.postMessage.mockClear(); + panel.webview.send({ type: "ready", cols: 100, rows: 30 }); + + expect(panel.webview.postMessage).toHaveBeenCalledWith({ + type: "output", + data: large.slice(-500_000), + }); + expect(panel.webview.postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "output", data: large }), + ); + + panel.webview.postMessage.mockClear(); + process.emitExit(0); + panel.webview.send({ type: "ready", cols: 100, rows: 30 }); + + expect(webview.postMessage).toHaveBeenCalledWith({ + type: "exit", + code: 0, + signal: undefined, + }); + expect(panel.webview.postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "output", data: large.slice(-1) }), + ); + }); + + it("posts exit banner payload and resets scrollback on exit", () => { + const manager = new TerminalManager(); + const provider = new TerminalProvider(extensionUri, manager); + const { view, webview } = createView(); + provider.resolveWebviewView(view as never); + webview.send({ type: "ready", cols: 80, rows: 24 }); + const process = lastResult(nodePty.spawn.mock.results) + ?.value as ptyMock.MockPtyProcess; + process.emitData("before-exit"); + process.emitExit(12, 9); + + expect(webview.postMessage).toHaveBeenCalledWith({ + type: "exit", + code: 12, + signal: 9, + }); + + provider.toggleEditorLocation(); + const panel = lastResult(vscode.window.createWebviewPanel.mock.results) + ?.value as vscode.MockWebviewPanel; + panel.webview.postMessage.mockClear(); + panel.webview.send({ type: "ready", cols: 100, rows: 30 }); + + expect(panel.webview.postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "output", data: "before-exit" }), + ); + }); + + it("keeps the same PTY alive across surface switching", () => { + const manager = new TerminalManager(); + const ensureSpy = vi.spyOn(manager, "ensureLocalShell"); + const provider = new TerminalProvider(extensionUri, manager); + const { view, webview } = createView(); + provider.resolveWebviewView(view as never); + webview.send({ type: "ready", cols: 80, rows: 24 }); + + expect(provider.terminalCount()).toBe(1); + provider.toggleEditorLocation(); + expect(provider.terminalCount()).toBe(1); + provider.toggleEditorLocation(); + expect(provider.terminalCount()).toBe(1); + expect(ensureSpy).toHaveBeenCalledOnce(); + expect(nodePty.spawn).toHaveBeenCalledOnce(); + }); + }); }); diff --git a/src/providers/TerminalProvider.ts b/src/providers/TerminalProvider.ts index c5e6ba7..8e6f65b 100644 --- a/src/providers/TerminalProvider.ts +++ b/src/providers/TerminalProvider.ts @@ -3,6 +3,13 @@ import * as os from "os"; import * as path from "path"; import { randomBytes, randomUUID } from "crypto"; import * as vscode from "vscode"; +import type { + HerdrAttachController, + HerdrAttachPresenter, + HerdrAttachTarget, + SourceState, +} from "../herdr/HerdrAttachController"; +import { herdrSessionId } from "../herdr/HerdrAttachController"; import type { CursorStyle, HostMessage, TerminalConfig, WebviewMessage } from "../types"; import { TerminalManager } from "../terminals/TerminalManager"; import { renderTerminalHtml } from "../webview/terminal/html"; @@ -11,43 +18,75 @@ const TERMINAL_ID = "sidebar-shell"; const EDITOR_VIEW_TYPE = "ulw.terminalEditor"; const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"] as const; const MAX_IMAGE_SIZE = 5 * 1024 * 1024; -const MAX_SCROLLBACK_CHARS = 500_000; export type TerminalLocation = "sidebar" | "editor"; -export class TerminalProvider implements vscode.WebviewViewProvider, vscode.Disposable { +type HerdrEditorSession = { + readonly panel: vscode.WebviewPanel; + readonly controller: HerdrAttachController; + readonly target: HerdrAttachTarget; +}; + +export class TerminalProvider + implements vscode.WebviewViewProvider, vscode.Disposable, HerdrAttachPresenter +{ public static readonly viewType = "ulw"; private view: vscode.WebviewView | undefined; private editorPanel: vscode.WebviewPanel | undefined; private activeLocation: TerminalLocation = "sidebar"; private disposing = false; - private scrollback = ""; private readonly disposables: vscode.Disposable[] = []; + private readonly herdrSessions = new Map(); + private activeTerminalId = TERMINAL_ID; public constructor( private readonly extensionUri: vscode.Uri, private readonly terminalManager: TerminalManager, + private readonly attachController?: HerdrAttachController, ) { this.disposables.push( - terminalManager.onData(({ id, data }) => { - if (id !== TERMINAL_ID) { + terminalManager.onData(({ id, data, replay }) => { + if (id === TERMINAL_ID) { + if (replay === "replace") { + this.postMessage({ type: "reset" }); + } + this.postMessage({ type: "output", data }); + return; + } + const session = this.herdrSessions.get(id); + if (!session) { return; } - this.appendScrollback(data); - this.postMessage({ type: "output", data }); + if (replay === "replace") { + void session.panel.webview.postMessage({ type: "reset" }); + } + void session.panel.webview.postMessage({ type: "output", data }); }), terminalManager.onExit(({ id, code, signal }) => { - if (id !== TERMINAL_ID) { + if (id === TERMINAL_ID) { + if ( + this.terminalManager.activeSource(TERMINAL_ID) === "herdr-control" || + this.attachController?.sourceState.phase === "attached" + ) { + return; + } + this.postMessage({ type: "exit", code, signal }); + return; + } + const session = this.herdrSessions.get(id); + if (!session) { return; } - this.scrollback = ""; - this.postMessage({ type: "exit", code, signal }); + void session.panel.webview.postMessage({ type: "exit", code, signal }); }), vscode.workspace.onDidChangeConfiguration((event) => { if (event.affectsConfiguration("ulw")) { this.postMessage({ type: "config", ...this.readConfig() }); } + if (event.affectsConfiguration("ulw.sidebar.enabled")) { + this.applySidebarVisibility(); + } }), ); } @@ -69,13 +108,16 @@ export class TerminalProvider implements vscode.WebviewViewProvider, vscode.Disp } public openAtConfiguredLocation(): void { - if (this.readDefaultLocation() === "editor") { + if (this.readDefaultLocation() === "editor" || !this.sidebarEnabled()) { this.openEditorPanel(); } } public toggleEditorLocation(): void { if (this.editorPanel) { + if (!this.sidebarEnabled()) { + return; + } this.closeEditorPanel(); return; } @@ -83,7 +125,7 @@ export class TerminalProvider implements vscode.WebviewViewProvider, vscode.Disp } public isEditorLocation(): boolean { - return this.activeLocation === "editor" && this.editorPanel !== undefined; + return this.activeLocation === "editor"; } public getDefaultLocation(): TerminalLocation { @@ -91,7 +133,114 @@ export class TerminalProvider implements vscode.WebviewViewProvider, vscode.Disp } public write(data: string): void { - this.terminalManager.write(TERMINAL_ID, data); + this.terminalManager.write(this.activeTerminalId, data); + } + + public async openHerdrSession( + target: HerdrAttachTarget, + attach: (target: HerdrAttachTarget) => Promise, + createController: (sessionId: string, presenter: HerdrAttachPresenter) => HerdrAttachController, + ): Promise { + const sessionId = herdrSessionId(target.terminalId); + const existing = this.herdrSessions.get(sessionId); + if (existing) { + this.focusHerdrSession(sessionId); + existing.panel.reveal(vscode.ViewColumn.Active); + if (existing.controller.sourceState.phase === "shell") { + await attach(target); + return; + } + this.postSourceStateToPanel(existing.panel, existing.controller.sourceState); + return; + } + + const title = target.label?.trim() || target.terminalId; + const panel = vscode.window.createWebviewPanel( + EDITOR_VIEW_TYPE, + title, + vscode.ViewColumn.Active, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [this.extensionUri], + }, + ); + this.configureWebview(panel.webview); + const presenter: HerdrAttachPresenter = { + postReset: () => { + void panel.webview.postMessage({ type: "reset" }); + }, + postOutput: (data) => { + void panel.webview.postMessage({ type: "output", data }); + }, + postSourceState: (state) => { + this.postSourceStateToPanel(panel, state); + }, + }; + const controller = createController(sessionId, presenter); + const session: HerdrEditorSession = { panel, controller, target }; + this.herdrSessions.set(sessionId, session); + this.focusHerdrSession(sessionId); + const messageSubscription = panel.webview.onDidReceiveMessage( + (message: WebviewMessage) => { + this.handleHerdrSessionMessage(sessionId, message); + }, + ); + const viewStateSubscription = panel.onDidChangeViewState( + ({ webviewPanel }) => { + if (webviewPanel.active) { + this.focusHerdrSession(sessionId); + } + }, + ); + const disposeSubscription = panel.onDidDispose(() => { + messageSubscription.dispose(); + viewStateSubscription.dispose(); + disposeSubscription.dispose(); + const current = this.herdrSessions.get(sessionId); + if (current?.panel !== panel) { + return; + } + this.herdrSessions.delete(sessionId); + current.controller.dispose(); + if (this.activeTerminalId === sessionId) { + const remaining = [...this.herdrSessions.keys()]; + this.activeTerminalId = + remaining.length > 0 ? remaining[remaining.length - 1] : TERMINAL_ID; + } + }); + panel.webview.html = this.renderHtml(panel.webview); + await attach(target); + } + + public herdrSessionCount(): number { + return this.herdrSessions.size; + } + + public activeSessionId(): string { + return this.activeTerminalId; + } + + private focusHerdrSession(sessionId: string): void { + const session = this.herdrSessions.get(sessionId); + if (!session) { + return; + } + this.herdrSessions.delete(sessionId); + this.herdrSessions.set(sessionId, session); + this.activeTerminalId = sessionId; + } + + public postReset(): void { + this.postToSurface(this.activeLocation, { type: "reset" }); + } + + public postOutput(data: string): void { + this.postMessage({ type: "output", data }); + } + + public postSourceState(state: SourceState): void { + this.postSourceStateToSurface(this.activeLocation, state); } public isRunning(): boolean { @@ -104,6 +253,11 @@ export class TerminalProvider implements vscode.WebviewViewProvider, vscode.Disp public dispose(): void { this.disposing = true; + for (const [sessionId, session] of this.herdrSessions) { + session.controller.dispose(); + session.panel.dispose(); + this.herdrSessions.delete(sessionId); + } this.terminalManager.kill(TERMINAL_ID); const panel = this.editorPanel; this.editorPanel = undefined; @@ -113,7 +267,6 @@ export class TerminalProvider implements vscode.WebviewViewProvider, vscode.Disp } this.view = undefined; this.activeLocation = "sidebar"; - this.scrollback = ""; this.disposing = false; } @@ -148,6 +301,11 @@ export class TerminalProvider implements vscode.WebviewViewProvider, vscode.Disp disposeSubscription.dispose(); if (this.editorPanel === panel && !this.disposing) { this.editorPanel = undefined; + if (!this.sidebarEnabled()) { + this.activeLocation = "editor"; + void vscode.commands.executeCommand("workbench.action.closeAuxiliaryBar"); + return; + } this.activeLocation = "sidebar"; this.postMessage({ type: "focus" }); void vscode.commands.executeCommand("workbench.view.extension.ulwContainer"); @@ -163,6 +321,12 @@ export class TerminalProvider implements vscode.WebviewViewProvider, vscode.Disp return; } this.editorPanel = undefined; + if (!this.sidebarEnabled()) { + this.activeLocation = "editor"; + panel.dispose(); + void vscode.commands.executeCommand("workbench.action.closeAuxiliaryBar"); + return; + } this.activeLocation = "sidebar"; panel.dispose(); if (!this.disposing) { @@ -176,14 +340,29 @@ export class TerminalProvider implements vscode.WebviewViewProvider, vscode.Disp case "ready": { const isActive = source === this.activeLocation; if (isActive) { - if (!this.terminalManager.hasTerminal(TERMINAL_ID)) { - this.terminalManager.createTerminal(TERMINAL_ID, message.cols, message.rows); - } else { + const activeSource = this.terminalManager.activeSource(TERMINAL_ID); + const controllerPhase = this.attachController?.sourceState.phase ?? "shell"; + if (activeSource === undefined && controllerPhase === "shell") { + this.terminalManager.ensureLocalShell( + TERMINAL_ID, + message.cols, + message.rows, + ); + } else if (activeSource !== undefined) { this.terminalManager.resize(TERMINAL_ID, message.cols, message.rows); } } this.postToSurface(source, { type: "config", ...this.readConfig() }); - this.replayScrollback(source); + const sourceState: SourceState = this.attachController?.sourceState ?? { + source: "shell", + phase: "shell", + }; + this.postSourceStateToSurface(source, sourceState); + this.postToSurface(source, { type: "reset" }); + const replay = this.terminalManager.replay(TERMINAL_ID); + if (replay.length > 0) { + this.postToSurface(source, { type: "output", data: replay }); + } if (isActive) { this.postMessage({ type: "focus" }); } @@ -195,6 +374,12 @@ export class TerminalProvider implements vscode.WebviewViewProvider, vscode.Disp } this.terminalManager.write(TERMINAL_ID, message.data); break; + case "scroll": + if (source !== this.activeLocation) { + return; + } + this.terminalManager.scroll(TERMINAL_ID, message); + break; case "resize": if (source !== this.activeLocation) { return; @@ -238,18 +423,17 @@ export class TerminalProvider implements vscode.WebviewViewProvider, vscode.Disp void this.view?.webview.postMessage(message); } - private replayScrollback(source: TerminalLocation): void { - if (!this.scrollback) { - return; - } - this.postToSurface(source, { type: "output", data: this.scrollback }); - } - - private appendScrollback(data: string): void { - this.scrollback += data; - if (this.scrollback.length > MAX_SCROLLBACK_CHARS) { - this.scrollback = this.scrollback.slice(this.scrollback.length - MAX_SCROLLBACK_CHARS); - } + private postSourceStateToSurface( + source: TerminalLocation, + state: SourceState, + ): void { + this.postToSurface(source, { + type: "sourceState", + source: state.source, + phase: state.phase, + ...(state.label === undefined ? {} : { label: state.label }), + ...(state.message === undefined ? {} : { message: state.message }), + }); } private configureWebview(webview: vscode.Webview): void { @@ -306,7 +490,91 @@ export class TerminalProvider implements vscode.WebviewViewProvider, vscode.Disp return { mimeType: match[1], buffer: Buffer.from(match[2], "base64") }; } + private handleHerdrSessionMessage( + sessionId: string, + message: WebviewMessage, + ): void { + const session = this.herdrSessions.get(sessionId); + if (!session) { + return; + } + switch (message.type) { + case "ready": { + const source = this.terminalManager.activeSource(sessionId); + if (source !== undefined && sessionId === this.activeTerminalId) { + this.terminalManager.resize(sessionId, message.cols, message.rows); + } + void session.panel.webview.postMessage({ type: "config", ...this.readConfig() }); + this.postSourceStateToPanel(session.panel, session.controller.sourceState); + void session.panel.webview.postMessage({ type: "reset" }); + const replay = this.terminalManager.replay(sessionId); + if (replay.length > 0) { + void session.panel.webview.postMessage({ type: "output", data: replay }); + } + void session.panel.webview.postMessage({ type: "focus" }); + break; + } + case "input": + if (sessionId === this.activeTerminalId) { + this.terminalManager.write(sessionId, message.data); + } + break; + case "scroll": + if (sessionId === this.activeTerminalId) { + this.terminalManager.scroll(sessionId, message); + } + break; + case "resize": + if (sessionId === this.activeTerminalId) { + this.terminalManager.resize(sessionId, message.cols, message.rows); + } + break; + case "copy": + if (message.text) { + void vscode.env.clipboard.writeText(message.text); + } + break; + case "imagePasted": + void this.saveImageAndPostPath(message.data); + break; + default: { + const _exhaustive: never = message; + void _exhaustive; + } + } + } + + private postSourceStateToPanel( + panel: vscode.WebviewPanel, + state: SourceState, + ): void { + void panel.webview.postMessage({ + type: "sourceState", + source: state.source, + phase: state.phase, + ...(state.label === undefined ? {} : { label: state.label }), + ...(state.message === undefined ? {} : { message: state.message }), + }); + } + + private applySidebarVisibility(): void { + if (this.sidebarEnabled()) { + return; + } + if (this.editorPanel) { + this.activeLocation = "editor"; + } + void vscode.commands.executeCommand("workbench.action.closeAuxiliaryBar"); + } + + private sidebarEnabled(): boolean { + return vscode.workspace.getConfiguration("ulw").get("sidebar.enabled", true); + } + private readDefaultLocation(): TerminalLocation { + if (!this.sidebarEnabled()) { + return "editor"; + } const configuration = vscode.workspace.getConfiguration("ulw"); const configured = configuration.get("defaultLocation", "editor"); return configured === "sidebar" ? "sidebar" : "editor"; diff --git a/src/terminals/LocalShellTransport.ts b/src/terminals/LocalShellTransport.ts new file mode 100644 index 0000000..c09e18d --- /dev/null +++ b/src/terminals/LocalShellTransport.ts @@ -0,0 +1,128 @@ +import * as os from "os"; +import * as pty from "node-pty"; +import * as vscode from "vscode"; +import type { HerdrScrollGesture } from "../types"; +import type { TerminalTransport } from "./TerminalTransport"; + +export class LocalShellTransport implements TerminalTransport { + public readonly kind = "local-shell" as const; + public readonly pid: number; + public exitCode: number | undefined; + public exitSignal: number | undefined; + + private readonly outputEmitter = new vscode.EventEmitter<{ + data: string; + replay: "append"; + }>(); + private readonly exitEmitter = new vscode.EventEmitter<{ + reason: "process-exit"; + message?: string; + }>(); + private readonly process: pty.IPty; + private closed = false; + + public readonly onOutput = this.outputEmitter.event; + public readonly onExit = this.exitEmitter.event; + + public constructor( + cols: number, + rows: number, + cwd = LocalShellTransport.resolveWorkingDirectory(), + ) { + const configuration = vscode.workspace.getConfiguration("ulw"); + const configuredShell = configuration.get("shellPath", "").trim(); + const shell = configuredShell || vscode.env.shell || this.defaultShell(); + const args = configuration.get("shellArgs", []); + + this.process = pty.spawn(shell, [...args], { + name: "xterm-256color", + cols: this.normalizeDimension(cols, 80), + rows: this.normalizeDimension(rows, 24), + cwd, + env: this.buildEnvironment(), + }); + this.pid = this.process.pid; + this.process.onData((data) => { + if (!this.closed) { + this.outputEmitter.fire({ data, replay: "append" }); + } + }); + this.process.onExit(({ exitCode, signal }) => { + if (this.closed) { + return; + } + this.closed = true; + this.exitCode = exitCode; + this.exitSignal = signal; + const signalMessage = signal === undefined ? "" : `, signal ${signal}`; + this.exitEmitter.fire({ + reason: "process-exit", + message: `code ${exitCode}${signalMessage}`, + }); + }); + } + + public unwrap(): pty.IPty { + return this.process; + } + + public write(data: string): void { + this.process.write(data); + } + + public scroll(_gesture: HerdrScrollGesture): void { + // Local shells scroll through xterm; Herdr scroll is attach-only. + } + + public resize(cols: number, rows: number): void { + if (cols < 1 || rows < 1) { + return; + } + this.process.resize(cols, rows); + } + + public async close(_reason: "release" | "shutdown"): Promise { + if (this.closed) { + return; + } + this.closed = true; + this.process.kill(); + } + + private static resolveWorkingDirectory(): string { + return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? os.homedir(); + } + + private defaultShell(): string { + if (process.platform === "win32") { + return process.env.COMSPEC ?? "cmd.exe"; + } + return process.env.SHELL ?? "/bin/sh"; + } + + private buildEnvironment(): Record { + const environment: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) { + environment[key] = value; + } + } + environment.TERM = "xterm-256color"; + environment.COLORTERM = "truecolor"; + const utf8Locale = + environment.LANG && environment.LANG.includes("UTF-8") + ? environment.LANG + : "en_US.UTF-8"; + if (!environment.LANG || !environment.LANG.includes("UTF-8")) { + environment.LANG = utf8Locale; + } + if (!environment.LC_CTYPE) { + environment.LC_CTYPE = environment.LANG; + } + return environment; + } + + private normalizeDimension(value: number, fallback: number): number { + return Number.isInteger(value) && value > 0 ? value : fallback; + } +} diff --git a/src/terminals/TerminalManager.test.ts b/src/terminals/TerminalManager.test.ts index 48a1421..760026b 100644 --- a/src/terminals/TerminalManager.test.ts +++ b/src/terminals/TerminalManager.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type * as ptyMock from "../test/mocks/node-pty"; import * as vscode from "../test/mocks/vscode"; +import type { TerminalTransport } from "./TerminalTransport"; vi.mock("node-pty", async () => vi.importActual("../test/mocks/node-pty"), @@ -148,4 +149,216 @@ describe("TerminalManager", () => { expect(data).not.toHaveBeenCalled(); expect(exit).not.toHaveBeenCalled(); }); + + describe("transport seam", () => { + class FakeTerminalTransport implements TerminalTransport { + public readonly kind = "herdr-control" as const; + private readonly outputEmitter = new vscode.EventEmitter<{ + data: string; + replay: "append" | "replace"; + }>(); + private readonly exitEmitter = new vscode.EventEmitter<{ + reason: "released" | "protocol-error"; + message?: string; + }>(); + + public readonly onOutput = this.outputEmitter.event; + public readonly onExit = this.exitEmitter.event; + public readonly write = vi.fn<(data: string) => void>(); + public readonly scroll = vi.fn(); + public readonly resize = vi.fn<(cols: number, rows: number) => void>(); + public readonly close = vi.fn(async (_reason: "release" | "shutdown") => undefined); + + public emitOutput(data: string, replay: "append" | "replace"): void { + this.outputEmitter.fire({ data, replay }); + } + + public emitExit(reason: "released" | "protocol-error", message?: string): void { + this.exitEmitter.fire({ reason, message }); + } + } + + it("switches one slot to an attached transport and restores shell replay", () => { + const manager = new TerminalManager(); + const data = vi.fn(); + manager.onData(data); + const shell = manager.createTerminal( + "shell", + 80, + 24, + ) as unknown as ptyMock.MockPtyProcess; + shell.emitData("shell-A"); + const attached = new FakeTerminalTransport(); + + manager.attach("shell", () => attached); + manager.write("shell", "attached-input"); + manager.resize("shell", 120, 40); + shell.emitData("shell-B"); + + expect(manager.activeSource("shell")).toBe("herdr-control"); + expect(attached.write).toHaveBeenCalledWith("attached-input"); + expect(attached.resize).toHaveBeenCalledWith(120, 40); + expect(shell.write).not.toHaveBeenCalled(); + expect(shell.resize).not.toHaveBeenCalled(); + expect(shell.kill).not.toHaveBeenCalled(); + expect(data).toHaveBeenCalledTimes(1); + expect(data.mock.calls[0][0].replay).toBe("append"); + + manager.detach("shell"); + manager.write("shell", "shell-input"); + manager.resize("shell", 100, 30); + + expect(manager.activeSource("shell")).toBe("local-shell"); + expect(manager.replay("shell")).toBe("shell-Ashell-B"); + expect(shell.write).toHaveBeenCalledWith("shell-input"); + expect(shell.resize).toHaveBeenCalledWith(100, 30); + expect(shell.kill).not.toHaveBeenCalled(); + expect(attached.close).toHaveBeenCalledWith("release"); + }); + + it("retains the latest full frame and following deltas for attached replay", () => { + const manager = new TerminalManager(); + const data = vi.fn(); + manager.onData(data); + manager.createTerminal("shell", 80, 24); + const attached = new FakeTerminalTransport(); + manager.attach("shell", () => attached); + + attached.emitOutput("A", "replace"); + attached.emitOutput("B", "append"); + attached.emitOutput("C", "append"); + + expect(manager.replay("shell")).toBe("ABC"); + expect(data.mock.calls.map(([event]) => [event.data, event.replay])).toEqual([ + ["A", "replace"], + ["B", "append"], + ["C", "append"], + ]); + + attached.emitOutput("D", "replace"); + + expect(manager.replay("shell")).toBe("D"); + expect(data.mock.calls[3][0].replay).toBe("replace"); + }); + + it("ignores stale attached output and enforces replay bounds", () => { + const manager = new TerminalManager(); + const data = vi.fn(); + const exit = vi.fn(); + manager.onData(data); + manager.onExit(exit); + const shell = manager.createTerminal( + "shell", + 80, + 24, + ) as unknown as ptyMock.MockPtyProcess; + shell.emitData("shell-replay"); + const attached = new FakeTerminalTransport(); + manager.attach("shell", () => attached); + + attached.emitOutput("x".repeat(8 * 1024 * 1024 + 1), "replace"); + + expect(exit).toHaveBeenCalledWith( + expect.objectContaining({ + id: "shell", + reason: "protocol-error", + message: expect.stringContaining("8 MiB"), + }), + ); + expect(manager.activeSource("shell")).toBe("local-shell"); + expect(manager.replay("shell")).toBe("shell-replay"); + expect(shell.kill).not.toHaveBeenCalled(); + expect(attached.close).toHaveBeenCalledWith("release"); + + data.mockClear(); + exit.mockClear(); + attached.emitOutput("stale", "append"); + attached.emitExit("protocol-error", "stale exit"); + + expect(data).not.toHaveBeenCalled(); + expect(exit).not.toHaveBeenCalled(); + }); + + it("suppresses stale attached output after detach", () => { + const manager = new TerminalManager(); + const data = vi.fn(); + const exit = vi.fn(); + manager.onData(data); + manager.onExit(exit); + manager.createTerminal("shell", 80, 24); + const attached = new FakeTerminalTransport(); + manager.attach("shell", () => attached); + + manager.detach("shell"); + attached.emitOutput("stale", "append"); + attached.emitExit("protocol-error", "stale exit"); + + expect(data).not.toHaveBeenCalled(); + expect(exit).not.toHaveBeenCalled(); + expect(manager.replay("shell")).toBe(""); + expect(manager.activeSource("shell")).toBe("local-shell"); + }); + + it("keeps createTerminal as an idempotent single-spawn adapter", () => { + const manager = new TerminalManager(); + + const first = manager.createTerminal("shell", 120, 40); + const second = manager.createTerminal("shell", 80, 24); + + expect(first).toBe(second); + expect(nodePty.spawn).toHaveBeenCalledOnce(); + }); + }); + + it("counts attached-only Herdr sessions as running terminals", () => { + const manager = new TerminalManager(); + const transport: TerminalTransport = { + kind: "herdr-control", + write: vi.fn(), + scroll: vi.fn(), + resize: vi.fn(), + close: vi.fn(async () => undefined), + onOutput: () => ({ dispose() {} }), + onExit: () => ({ dispose() {} }), + }; + + manager.attach("herdr:term-a", () => transport, "frame"); + manager.attach("herdr:term-b", () => transport, "frame"); + + expect(manager.terminalCount()).toBe(2); + expect(manager.hasTerminal("herdr:term-a")).toBe(true); + }); + + describe("characterization: current one-PTY lifecycle", () => { + it("returns the same pty instance for an existing terminal id", () => { + const manager = new TerminalManager(); + + const first = manager.createTerminal("shell", 120, 40); + const second = manager.createTerminal("shell", 80, 24); + + expect(first).toBe(second); + expect(nodePty.spawn).toHaveBeenCalledOnce(); + }); + + it("drops stale onData and onExit after kill", () => { + const manager = new TerminalManager(); + const data = vi.fn(); + const exit = vi.fn(); + manager.onData(data); + manager.onExit(exit); + const process = manager.createTerminal( + "shell", + 80, + 24, + ) as unknown as ptyMock.MockPtyProcess; + + manager.kill("shell"); + process.emitData("stale"); + process.emitExit(0, 9); + + expect(data).not.toHaveBeenCalled(); + expect(exit).not.toHaveBeenCalled(); + expect(manager.hasTerminal("shell")).toBe(false); + }); + }); }); diff --git a/src/terminals/TerminalManager.ts b/src/terminals/TerminalManager.ts index 486a160..2ac941f 100644 --- a/src/terminals/TerminalManager.ts +++ b/src/terminals/TerminalManager.ts @@ -1,16 +1,27 @@ -import * as os from "os"; -import * as pty from "node-pty"; +import type * as pty from "node-pty"; import * as vscode from "vscode"; +import type { HerdrScrollGesture } from "../types"; +import { LocalShellTransport } from "./LocalShellTransport"; +import type { + TerminalTransport, + TerminalTransportExitReason, +} from "./TerminalTransport"; + +const MAX_SHELL_REPLAY_CHARS = 500_000; +const MAX_ATTACHED_REPLAY_BYTES = 8 * 1024 * 1024; export interface TerminalDataEvent { readonly id: string; readonly data: string; + readonly replay: "append" | "replace"; } export interface TerminalExitEvent { readonly id: string; readonly code: number; readonly signal?: number; + readonly reason: TerminalTransportExitReason; + readonly message?: string; } export interface TerminalStartEvent { @@ -18,9 +29,17 @@ export interface TerminalStartEvent { readonly pid: number; } +interface TerminalSlot { + localShell?: LocalShellTransport; + localGeneration: number; + localReplay: string; + attached?: TerminalTransport; + attachedGeneration: number; + attachedReplay: string; +} + export class TerminalManager implements vscode.Disposable { - private readonly terminals = new Map(); - private readonly generations = new Map(); + private readonly slots = new Map(); private readonly dataEmitter = new vscode.EventEmitter(); private readonly exitEmitter = new vscode.EventEmitter(); private readonly startEmitter = new vscode.EventEmitter(); @@ -33,78 +52,186 @@ export class TerminalManager implements vscode.Disposable { id: string, cols: number, rows: number, - cwd = this.resolveWorkingDirectory(), + cwd?: string, ): pty.IPty { - const existing = this.terminals.get(id); - if (existing) { - return existing; + return this.ensureLocalShell(id, cols, rows, cwd).unwrap(); + } + + public ensureLocalShell( + id: string, + cols: number, + rows: number, + cwd?: string, + ): LocalShellTransport { + const slot = this.getOrCreateSlot(id); + if (slot.localShell) { + return slot.localShell; } - const configuration = vscode.workspace.getConfiguration("ulw"); - const configuredShell = configuration.get("shellPath", "").trim(); - const shell = configuredShell || vscode.env.shell || this.defaultShell(); - const args = configuration.get("shellArgs", []); - const generation = (this.generations.get(id) ?? 0) + 1; - this.generations.set(id, generation); - - const process = pty.spawn(shell, [...args], { - name: "xterm-256color", - cols: this.normalizeDimension(cols, 80), - rows: this.normalizeDimension(rows, 24), - cwd, - env: this.buildEnvironment(), + const shell = new LocalShellTransport(cols, rows, cwd); + const generation = slot.localGeneration + 1; + slot.localGeneration = generation; + slot.localShell = shell; + this.startEmitter.fire({ id, pid: shell.pid }); + shell.onOutput(({ data, replay }) => { + if (slot.localGeneration !== generation || slot.localShell !== shell) { + return; + } + slot.localReplay = this.appendShellReplay(slot.localReplay, data); + if (!slot.attached) { + this.dataEmitter.fire(this.createDataEvent(id, data, replay)); + } + }); + shell.onExit(({ reason, message }) => { + if (slot.localGeneration !== generation || slot.localShell !== shell) { + return; + } + slot.localShell = undefined; + slot.localReplay = ""; + this.exitEmitter.fire( + this.createExitEvent( + id, + shell.exitCode ?? 0, + shell.exitSignal, + reason, + message, + ), + ); + this.deleteEmptySlot(id, slot); }); + return shell; + } + + public attach( + id: string, + transportFactory: () => TerminalTransport, + initialReplay = "", + ): TerminalTransport { + const slot = this.getOrCreateSlot(id); + const previous = slot.attached; + if (previous) { + slot.attachedGeneration += 1; + slot.attached = undefined; + slot.attachedReplay = ""; + void previous.close("release"); + } - this.terminals.set(id, process); - this.startEmitter.fire({ id, pid: process.pid }); - process.onData((data) => { - if (this.generations.get(id) === generation) { - this.dataEmitter.fire({ id, data }); + const transport = transportFactory(); + const generation = slot.attachedGeneration + 1; + slot.attachedGeneration = generation; + slot.attached = transport; + slot.attachedReplay = initialReplay; + transport.onOutput(({ data, replay }) => { + if (!this.isCurrentAttached(slot, transport, generation)) { + return; + } + const nextReplay = replay === "replace" ? data : slot.attachedReplay + data; + if (Buffer.byteLength(nextReplay, "utf8") > MAX_ATTACHED_REPLAY_BYTES) { + this.failAttachedReplay(id, slot, transport, generation); + return; } + slot.attachedReplay = nextReplay; + this.dataEmitter.fire(this.createDataEvent(id, data, replay)); }); - process.onExit(({ exitCode, signal }) => { - if (this.generations.get(id) !== generation) { + transport.onExit(({ reason, message }) => { + if (!this.isCurrentAttached(slot, transport, generation)) { return; } - this.terminals.delete(id); - this.exitEmitter.fire({ id, code: exitCode, signal }); + slot.attached = undefined; + slot.attachedReplay = ""; + this.exitEmitter.fire( + this.createExitEvent(id, 0, undefined, reason, message), + ); + this.deleteEmptySlot(id, slot); }); + return transport; + } + + public detach(id: string): void { + const slot = this.slots.get(id); + const attached = slot?.attached; + if (!slot || !attached) { + return; + } + slot.attachedGeneration += 1; + slot.attached = undefined; + slot.attachedReplay = ""; + void attached.close("release"); + this.deleteEmptySlot(id, slot); + } - return process; + public activeSource( + id: string, + ): "local-shell" | "herdr-control" | undefined { + const slot = this.slots.get(id); + return slot?.attached?.kind ?? slot?.localShell?.kind; + } + + public replay(id: string): string { + const slot = this.slots.get(id); + if (!slot) { + return ""; + } + return slot.attached ? slot.attachedReplay : slot.localReplay; } public hasTerminal(id: string): boolean { - return this.terminals.has(id); + const slot = this.slots.get(id); + return slot?.attached !== undefined || slot?.localShell !== undefined; } public terminalCount(): number { - return this.terminals.size; + let count = 0; + for (const slot of this.slots.values()) { + if (slot.localShell || slot.attached) { + count += 1; + } + } + return count; } public write(id: string, data: string): void { - this.terminals.get(id)?.write(data); + const slot = this.slots.get(id); + (slot?.attached ?? slot?.localShell)?.write(data); + } + + public scroll(id: string, gesture: HerdrScrollGesture): void { + const slot = this.slots.get(id); + slot?.attached?.scroll(gesture); } public resize(id: string, cols: number, rows: number): void { - const terminal = this.terminals.get(id); - if (!terminal || cols < 1 || rows < 1) { + if (cols < 1 || rows < 1) { return; } - terminal.resize(cols, rows); + const slot = this.slots.get(id); + (slot?.attached ?? slot?.localShell)?.resize(cols, rows); } public kill(id: string): void { - const terminal = this.terminals.get(id); - if (!terminal) { + const slot = this.slots.get(id); + if (!slot) { return; } - this.generations.set(id, (this.generations.get(id) ?? 0) + 1); - this.terminals.delete(id); - terminal.kill(); + this.slots.delete(id); + const attached = slot.attached; + const shell = slot.localShell; + slot.attachedGeneration += 1; + slot.localGeneration += 1; + slot.attached = undefined; + slot.localShell = undefined; + slot.attachedReplay = ""; + slot.localReplay = ""; + if (attached) { + void attached.close("shutdown"); + } + if (shell) { + void shell.close("shutdown"); + } } public dispose(): void { - for (const id of [...this.terminals.keys()]) { + for (const id of [...this.slots.keys()]) { this.kill(id); } this.dataEmitter.dispose(); @@ -112,40 +239,92 @@ export class TerminalManager implements vscode.Disposable { this.startEmitter.dispose(); } - private resolveWorkingDirectory(): string { - return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? os.homedir(); + private getOrCreateSlot(id: string): TerminalSlot { + let slot = this.slots.get(id); + if (!slot) { + slot = { + localGeneration: 0, + localReplay: "", + attachedGeneration: 0, + attachedReplay: "", + }; + this.slots.set(id, slot); + } + return slot; } - private defaultShell(): string { - if (process.platform === "win32") { - return process.env.COMSPEC ?? "cmd.exe"; - } - return process.env.SHELL ?? "/bin/sh"; + private appendShellReplay(current: string, data: string): string { + const replay = current + data; + return replay.length > MAX_SHELL_REPLAY_CHARS + ? replay.slice(replay.length - MAX_SHELL_REPLAY_CHARS) + : replay; } - private buildEnvironment(): Record { - const environment: Record = {}; - for (const [key, value] of Object.entries(process.env)) { - if (value !== undefined) { - environment[key] = value; - } - } - environment.TERM = "xterm-256color"; - environment.COLORTERM = "truecolor"; - const utf8Locale = - environment.LANG && environment.LANG.includes("UTF-8") - ? environment.LANG - : "en_US.UTF-8"; - if (!environment.LANG || !environment.LANG.includes("UTF-8")) { - environment.LANG = utf8Locale; - } - if (!environment.LC_CTYPE) { - environment.LC_CTYPE = environment.LANG; + private isCurrentAttached( + slot: TerminalSlot, + transport: TerminalTransport, + generation: number, + ): boolean { + return ( + slot.attachedGeneration === generation && slot.attached === transport + ); + } + + private failAttachedReplay( + id: string, + slot: TerminalSlot, + transport: TerminalTransport, + generation: number, + ): void { + if (!this.isCurrentAttached(slot, transport, generation)) { + return; } - return environment; + slot.attachedGeneration += 1; + slot.attached = undefined; + slot.attachedReplay = ""; + void transport.close("release"); + this.exitEmitter.fire( + this.createExitEvent( + id, + 0, + undefined, + "protocol-error", + "Attached terminal replay exceeded the 8 MiB limit.", + ), + ); + this.deleteEmptySlot(id, slot); } - private normalizeDimension(value: number, fallback: number): number { - return Number.isInteger(value) && value > 0 ? value : fallback; + private createDataEvent( + id: string, + data: string, + replay: "append" | "replace", + ): TerminalDataEvent { + const event = { id, data } as TerminalDataEvent; + Object.defineProperty(event, "replay", { value: replay, enumerable: false }); + return event; + } + + private createExitEvent( + id: string, + code: number, + signal: number | undefined, + reason: TerminalTransportExitReason, + message: string | undefined, + ): TerminalExitEvent { + const event = (signal === undefined + ? { id, code } + : { id, code, signal }) as TerminalExitEvent; + Object.defineProperties(event, { + reason: { value: reason, enumerable: false }, + message: { value: message, enumerable: false }, + }); + return event; + } + + private deleteEmptySlot(id: string, slot: TerminalSlot): void { + if (!slot.localShell && !slot.attached) { + this.slots.delete(id); + } } } diff --git a/src/terminals/TerminalTransport.ts b/src/terminals/TerminalTransport.ts new file mode 100644 index 0000000..e545d78 --- /dev/null +++ b/src/terminals/TerminalTransport.ts @@ -0,0 +1,28 @@ +import type * as vscode from "vscode"; +import type { HerdrScrollGesture } from "../types"; + +export type TerminalTransportExitReason = + | "released" + | "takeover" + | "pane-exited" + | "server-stopped" + | "protocol-error" + | "spawn-error" + | "timeout" + | "process-exit"; + +export interface TerminalTransport { + readonly kind: "local-shell" | "herdr-control"; + readonly onOutput: vscode.Event<{ + data: string; + replay: "append" | "replace"; + }>; + readonly onExit: vscode.Event<{ + reason: TerminalTransportExitReason; + message?: string; + }>; + write(data: string): void; + scroll(gesture: HerdrScrollGesture): void; + resize(cols: number, rows: number): void; + close(reason: "release" | "shutdown"): Promise; +} diff --git a/src/test/e2e/suite/activation.e2e.ts b/src/test/e2e/suite/activation.e2e.ts index 00a9d55..f426c0c 100644 --- a/src/test/e2e/suite/activation.e2e.ts +++ b/src/test/e2e/suite/activation.e2e.ts @@ -69,4 +69,21 @@ suite("Native sidebar terminal", () => { assert.strictEqual(api.isTerminalRunning(), true); assert.strictEqual(api.terminalCount(), 1); }); + + test("registers Herdr explorer commands after activate", async () => { + const extension = vscode.extensions.getExtension( + "islee23520.opencode-sidebar-tui", + ); + assert.ok(extension, "Extension should be available in the test host"); + await extension.activate(); + const commands = await vscode.commands.getCommands(true); + assert.ok( + commands.includes("ulw.herdr.refreshExplorer"), + "ulw.herdr.refreshExplorer must be registered after activate", + ); + assert.ok( + commands.includes("ulw.herdr.openAgent"), + "ulw.herdr.openAgent must be registered after activate", + ); + }); }); diff --git a/src/test/e2e/suite/herdr-attach.e2e.ts b/src/test/e2e/suite/herdr-attach.e2e.ts new file mode 100644 index 0000000..efe775a --- /dev/null +++ b/src/test/e2e/suite/herdr-attach.e2e.ts @@ -0,0 +1,488 @@ +import * as assert from "assert"; +import { execFile } from "child_process"; +import { promises as fs } from "fs"; +import * as os from "os"; +import * as path from "path"; +import * as vscode from "vscode"; + +interface SourceState { + readonly source: "shell" | "herdr"; + readonly phase: "shell" | "attaching" | "attached" | "detaching" | "error"; + readonly label?: string; + readonly message?: string; +} + +interface SurfaceSnapshot { + readonly sourceState: SourceState; + readonly renderedText: string; +} + +interface UlwExtensionApi { + readonly onTerminalStart: vscode.Event; + readonly onTerminalData: vscode.Event; + readonly onTerminalExit: vscode.Event; + readonly onSourceState: vscode.Event; + isTerminalRunning(): boolean; + terminalCount(): number; + writeToTerminal(data: string): void; + toggleEditorLocation(): void; + attachToHerdr(target: { terminalId: string; label?: string }): Promise; + detachHerdr(): Promise; + resizeTerminal(cols: number, rows: number): void; + getSurfaceSnapshot(): SurfaceSnapshot; + getExplorerSnapshot(): { + readonly spaces: readonly { + readonly workspaceId: string; + readonly label: string; + }[]; + readonly agents: readonly { + readonly terminalId: string; + readonly workspaceId: string; + }[]; + }; + refreshExplorer(): Promise; +} + +interface ScratchWorkspace { + readonly tempDir: string; + readonly workspaceId: string; + readonly rootPaneId: string; + readonly rootTerminalId: string; + readonly deadPaneId: string; + readonly deadTerminalId: string; +} + +interface CommandResult { + readonly stdout: string; + readonly stderr: string; +} + +interface ProcessInspection { + readonly processIds: readonly number[]; + readonly inspectionFailed: boolean; + readonly error?: string; +} + +const HERDR = process.env.ULW_E2E_HERDR ?? "/Users/ilseoblee/.local/bin/herdr"; +const EVIDENCE_DIR = path.resolve( + ".omo/evidence/task-10-herdr-agent-attach", +); +const COMMAND_TIMEOUT_MS = 10_000; +const EVENT_TIMEOUT_MS = 10_000; + +function runHerdr(args: readonly string[]): Promise { + return new Promise((resolve, reject) => { + execFile( + HERDR, + [...args], + { encoding: "utf8", timeout: COMMAND_TIMEOUT_MS }, + (error, stdout, stderr) => { + if (error) { + reject( + new Error( + `${HERDR} ${args.join(" ")} failed: ${stderr || error.message}`, + ), + ); + return; + } + resolve({ stdout, stderr }); + }, + ); + }); +} + +function parseResult(stdout: string): Record { + const parsed = JSON.parse(stdout) as { result?: Record }; + assert.ok(parsed.result, `Herdr response had no result: ${stdout}`); + return parsed.result; +} + +function waitForEvent( + event: vscode.Event, + predicate: (value: T) => boolean, + description: string, + timeoutMs = EVENT_TIMEOUT_MS, +): Promise { + return new Promise((resolve, reject) => { + const timeout = AbortSignal.timeout(timeoutMs); + const subscription = event((value) => { + if (!predicate(value)) { + return; + } + timeout.removeEventListener("abort", onAbort); + subscription.dispose(); + resolve(value); + }); + const onAbort = () => { + subscription.dispose(); + reject(new Error(`Timed out waiting for ${description}`)); + }; + timeout.addEventListener("abort", onAbort, { once: true }); + }); +} + +function waitForOutput( + event: vscode.Event, + expected: string, +): Promise { + let output = ""; + return waitForEvent( + event, + (chunk) => { + output += chunk; + return output.includes(expected); + }, + `terminal output ${expected}; output was ${output}`, + ).then(() => output); +} + +async function inspectProcesses(paneId: string): Promise { + try { + const result = parseResult( + (await runHerdr(["pane", "process-info", "--pane", paneId])).stdout, + ); + const processInfo = result.process_info as + | { + shell_pid?: number; + foreground_processes?: Array<{ pid?: number }>; + } + | undefined; + const ids = [ + processInfo?.shell_pid, + ...(processInfo?.foreground_processes ?? []).map((entry) => entry.pid), + ]; + return { + processIds: [ + ...new Set(ids.filter((id): id is number => Number.isInteger(id))), + ], + inspectionFailed: false, + }; + } catch (error) { + return { + processIds: [], + inspectionFailed: true, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +function assertPhaseOrder( + phases: readonly SourceState["phase"][], + first: SourceState["phase"], + second: SourceState["phase"], +): void { + const firstIndex = phases.indexOf(first); + const secondIndex = phases.indexOf(second); + assert.ok(firstIndex >= 0, `Expected phase ${first}; observed ${phases.join(", ")}`); + assert.ok( + secondIndex > firstIndex, + `Expected ${first} before ${second}; observed ${phases.join(", ")}`, + ); +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +suite("Live Herdr terminal attach", () => { + let scratch: ScratchWorkspace | undefined; + const scratchProcessIds = new Set(); + + suiteSetup(async function () { + this.timeout(20_000); + await fs.mkdir(EVIDENCE_DIR, { recursive: true }); + + const version = await runHerdr(["--version"]); + assert.match( + version.stdout, + /^herdr 0\.8\./, + `Live suite requires Herdr 0.8.x, got ${version.stdout.trim()}`, + ); + + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ulw-e2e-")); + const created = parseResult( + ( + await runHerdr([ + "workspace", + "create", + "--cwd", + tempDir, + "--label", + "ulw-e2e", + "--no-focus", + ]) + ).stdout, + ); + const workspace = created.workspace as { workspace_id?: string }; + const rootPane = created.root_pane as { + pane_id?: string; + terminal_id?: string; + }; + assert.ok(workspace.workspace_id, "workspace create must return workspace_id"); + assert.ok(rootPane.pane_id, "workspace create must return root pane_id"); + assert.ok(rootPane.terminal_id, "workspace create must return root terminal_id"); + + await runHerdr([ + "pane", + "wait-output", + rootPane.pane_id, + "--regex", + ".+", + "--source", + "visible", + "--lines", + "20", + "--timeout", + "5000", + "--raw", + ]); + + const split = parseResult( + ( + await runHerdr([ + "pane", + "split", + rootPane.pane_id, + "--direction", + "right", + "--cwd", + tempDir, + "--no-focus", + ]) + ).stdout, + ); + const deadPane = split.pane as { pane_id?: string; terminal_id?: string }; + assert.ok(deadPane.pane_id, "pane split must return pane_id"); + assert.ok(deadPane.terminal_id, "pane split must return terminal_id"); + + await runHerdr([ + "pane", + "run", + rootPane.pane_id, + "printf 'ULW_E2E_READY\n'; exec /bin/sh", + ]); + await runHerdr([ + "pane", + "wait-output", + rootPane.pane_id, + "--match", + "ULW_E2E_READY", + "--source", + "visible", + "--lines", + "50", + "--timeout", + "5000", + "--raw", + ]); + + scratch = { + tempDir, + workspaceId: workspace.workspace_id, + rootPaneId: rootPane.pane_id, + rootTerminalId: rootPane.terminal_id, + deadPaneId: deadPane.pane_id, + deadTerminalId: deadPane.terminal_id, + }; + const rootInspection = await inspectProcesses(rootPane.pane_id); + const deadInspection = await inspectProcesses(deadPane.pane_id); + assert.strictEqual( + rootInspection.inspectionFailed, + false, + `Root pane process inspection failed: ${rootInspection.error ?? "unknown error"}`, + ); + assert.strictEqual( + deadInspection.inspectionFailed, + false, + `Dead-target pane process inspection failed: ${deadInspection.error ?? "unknown error"}`, + ); + for (const pid of [...rootInspection.processIds, ...deadInspection.processIds]) { + scratchProcessIds.add(pid); + } + }); + + test("attaches, streams input, resizes, detaches, and restores shell on a dead target", async function () { + this.timeout(20_000); + assert.ok(scratch, "Scratch workspace should be created by suite setup"); + + const extension = vscode.extensions.getExtension( + "islee23520.opencode-sidebar-tui", + ); + assert.ok(extension, "Extension should be available in the test host"); + await vscode.workspace + .getConfiguration("ulw") + .update("herdr.enabled", true, vscode.ConfigurationTarget.Global); + const api = await extension.activate(); + const sourceStates: SourceState[] = []; + const sourceStateSubscription = api.onSourceState((state) => { + sourceStates.push(state); + }); + + await api.refreshExplorer(); + const workspace = scratch; + const explorer = api.getExplorerSnapshot(); + assert.ok( + explorer.spaces.some((space) => space.workspaceId === workspace.workspaceId), + `explorer spaces should include ${workspace.workspaceId}`, + ); + const treeAttached = waitForEvent( + api.onSourceState, + (state) => state.phase === "attached", + "tree click attached", + ); + const treeDetached = waitForEvent( + api.onSourceState, + (state) => state.phase === "shell", + "sourceState shell after tree attach", + ); + await vscode.commands.executeCommand("ulw.herdr.openAgent", { + kind: "agent", + agent: { + paneId: workspace.rootPaneId, + terminalId: workspace.rootTerminalId, + agent: "shell", + status: "idle", + title: "ulw-e2e", + cwd: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? workspace.tempDir, + workspaceId: workspace.workspaceId, + }, + }); + const treeAttachedState = await treeAttached; + assert.strictEqual(treeAttachedState.source, "herdr"); + await api.detachHerdr(); + await treeDetached; + + const happyAttachPhaseStart = sourceStates.length; + const attached = waitForEvent( + api.onSourceState, + (state) => state.phase === "attached", + "sourceState attached", + ); + await api.attachToHerdr({ + terminalId: scratch.rootTerminalId, + label: "ulw-e2e", + }); + assert.strictEqual((await attached).source, "herdr"); + assertPhaseOrder( + sourceStates.slice(happyAttachPhaseStart).map((state) => state.phase), + "attaching", + "attached", + ); + assert.match(api.getSurfaceSnapshot().renderedText, /ULW_E2E_READY/); + + const inputRoundTrip = waitForOutput(api.onTerminalData, "ULW_E2E_IN2"); + api.writeToTerminal("printf 'ULW_E2E_IN2\\n'\r"); + await inputRoundTrip; + assert.match(api.getSurfaceSnapshot().renderedText, /ULW_E2E_IN2/); + + const resized = waitForEvent( + api.onTerminalData, + () => true, + "a Herdr frame after resize", + ); + api.resizeTerminal(90, 30); + await resized; + const resizedSnapshot = api.getSurfaceSnapshot(); + assert.strictEqual(resizedSnapshot.sourceState.phase, "attached"); + assert.strictEqual(resizedSnapshot.sourceState.source, "herdr"); + + const detachPhaseStart = sourceStates.length; + const detached = waitForEvent( + api.onSourceState, + (state) => state.phase === "shell", + "sourceState shell after detach", + ); + await api.detachHerdr(); + await detached; + assertPhaseOrder( + sourceStates.slice(detachPhaseStart).map((state) => state.phase), + "detaching", + "shell", + ); + + const terminalExits: number[] = []; + const terminalExitSubscription = api.onTerminalExit((code) => { + terminalExits.push(code); + }); + await runHerdr(["pane", "close", scratch.deadPaneId]); + const attachError = waitForEvent( + api.onSourceState, + (state) => state.phase === "error", + "sourceState error for a dead Herdr terminal", + ); + await api.attachToHerdr({ + terminalId: scratch.deadTerminalId, + label: "dead-ulw-e2e", + }); + const errorState = await attachError; + assert.strictEqual(errorState.source, "shell"); + assert.strictEqual(api.getSurfaceSnapshot().sourceState.phase, "shell"); + terminalExitSubscription.dispose(); + sourceStateSubscription.dispose(); + assert.deepStrictEqual(terminalExits, [], "A failed Herdr attach must not exit the editor session"); + }); + + suiteTeardown(async function () { + this.timeout(20_000); + if (!scratch) { + return; + } + + const finalProcessInspection = await inspectProcesses(scratch.rootPaneId); + for (const pid of finalProcessInspection.processIds) { + scratchProcessIds.add(pid); + } + + const close = await runHerdr([ + "workspace", + "close", + scratch.workspaceId, + ]); + const listed = parseResult((await runHerdr(["workspace", "list"])).stdout); + const workspaces = (listed.workspaces ?? []) as Array<{ + workspace_id?: string; + }>; + const workspaceAbsent = !workspaces.some( + (entry) => entry.workspace_id === scratch?.workspaceId, + ); + await fs.rm(scratch.tempDir, { recursive: true, force: true }); + const tempDirRemoved = await fs.access(scratch.tempDir).then( + () => false, + () => true, + ); + const liveProcessIds = [...scratchProcessIds].filter(isProcessAlive); + + const receipt = { + workspaceId: scratch.workspaceId, + rootPaneId: scratch.rootPaneId, + deadPaneId: scratch.deadPaneId, + closeResponse: JSON.parse(close.stdout), + workspaceAbsent, + processInspection: finalProcessInspection, + checkedProcessIds: [...scratchProcessIds], + liveProcessIds, + noLeftoverChildren: + !finalProcessInspection.inspectionFailed && liveProcessIds.length === 0, + tempDir: scratch.tempDir, + tempDirRemoved, + }; + await fs.writeFile( + path.join(EVIDENCE_DIR, "cleanup.json"), + `${JSON.stringify(receipt, null, 2)}\n`, + ); + + assert.strictEqual(workspaceAbsent, true, "Scratch workspace must be absent"); + assert.strictEqual( + finalProcessInspection.inspectionFailed, + false, + `Final process inspection failed: ${finalProcessInspection.error ?? "unknown error"}`, + ); + assert.deepStrictEqual(liveProcessIds, [], "Scratch children must be gone"); + assert.strictEqual(tempDirRemoved, true, "Scratch temp directory must be removed"); + }); +}); diff --git a/src/test/mocks/vscode.ts b/src/test/mocks/vscode.ts index 9ddd689..3ca8d63 100644 --- a/src/test/mocks/vscode.ts +++ b/src/test/mocks/vscode.ts @@ -1,4 +1,4 @@ -import { vi } from "vitest"; +import { vi, type Mock } from "vitest"; export class Disposable { public constructor(private readonly callback: () => void = () => undefined) {} @@ -49,30 +49,40 @@ const configurationEmitter = new EventEmitter<{ }>(); export function setConfiguration(values: Readonly>): void { - configuration.clear(); for (const [key, value] of Object.entries(values)) { configuration.set(key, value); } } +export const ConfigurationTarget = { + Global: 1, + Workspace: 2, + WorkspaceFolder: 3, +} as const; + export const workspace = { workspaceFolders: [{ uri: Uri.file(process.cwd()) }], getConfiguration: vi.fn((section: string) => ({ get(key: string, fallback?: T): T { return (configuration.get(`${section}.${key}`) as T | undefined) ?? (fallback as T); }, + update: vi.fn(async (key: string, value: unknown) => { + configuration.set(`${section}.${key}`, value); + }), })), onDidChangeConfiguration: configurationEmitter.event, }; export function fireConfigurationChange(section: string): void { configurationEmitter.fire({ - affectsConfiguration: (candidate) => candidate === section, + affectsConfiguration: (candidate) => + section === candidate || section.startsWith(`${candidate}.`), }); } export const env = { shell: "/bin/mock-shell", + remoteName: undefined as string | undefined, clipboard: { writeText: vi.fn(async (_text: string) => undefined), readText: vi.fn(async () => ""), @@ -85,6 +95,22 @@ export const ViewColumn = { One: 1, } as const; +export const TreeItemCollapsibleState = { + None: 0, + Collapsed: 1, + Expanded: 2, +} as const; + +export class TreeItem { + public description: string | undefined; + public command: { command: string; title: string; arguments?: unknown[] } | undefined; + + public constructor( + public label: string, + public collapsibleState: number = TreeItemCollapsibleState.None, + ) {} +} + export const commands = { registerCommand: vi.fn((commandId: string, _handler: (...args: unknown[]) => unknown) => { void commandId; @@ -106,9 +132,14 @@ export interface MockWebview { export interface MockWebviewPanel { webview: MockWebview; visible: boolean; + active: boolean; readonly onDidDispose: (listener: () => unknown) => Disposable; - readonly reveal: ReturnType; - readonly dispose: ReturnType; + readonly onDidChangeViewState: ( + listener: (event: { webviewPanel: MockWebviewPanel }) => unknown, + ) => Disposable; + readonly reveal: Mock<(...args: unknown[]) => unknown>; + readonly dispose: Mock<() => void>; + readonly fireViewState: (active: boolean) => void; } function createMockWebview(): MockWebview { @@ -126,20 +157,34 @@ function createMockWebview(): MockWebview { function createMockWebviewPanel(): MockWebviewPanel { const disposeEmitter = new EventEmitter(); + const viewStateEmitter = new EventEmitter<{ webviewPanel: MockWebviewPanel }>(); const panel: MockWebviewPanel = { webview: createMockWebview(), visible: true, + active: true, onDidDispose: disposeEmitter.event, + onDidChangeViewState: viewStateEmitter.event, reveal: vi.fn(), dispose: vi.fn(() => { disposeEmitter.fire(); }), + fireViewState: (active: boolean) => { + panel.active = active; + viewStateEmitter.fire({ webviewPanel: panel }); + }, }; return panel; } export const window = { + showQuickPick: vi.fn(async (items: readonly unknown[], _options?: unknown) => { + void items; + return undefined as unknown; + }), + showWarningMessage: vi.fn(async (_message: string, ..._items: string[]) => undefined as string | undefined), + showInformationMessage: vi.fn(async (_message: string, ..._items: string[]) => undefined as string | undefined), registerWebviewViewProvider: vi.fn(() => new Disposable()), + registerTreeDataProvider: vi.fn(() => new Disposable()), createWebviewPanel: vi.fn( ( _viewType: string, @@ -161,10 +206,20 @@ export const window = { }; export function resetMocks(): void { - setConfiguration({}); + configuration.clear(); commands.registerCommand.mockClear(); commands.executeCommand.mockClear(); + window.showQuickPick.mockReset(); + window.showQuickPick.mockImplementation(async (items: readonly unknown[], _options?: unknown) => { + void items; + return undefined as unknown; + }); + window.showWarningMessage.mockReset(); + window.showWarningMessage.mockResolvedValue(undefined); + window.showInformationMessage.mockReset(); + window.showInformationMessage.mockResolvedValue(undefined); window.registerWebviewViewProvider.mockClear(); + window.registerTreeDataProvider.mockClear(); window.createWebviewPanel.mockClear(); window.createWebviewPanel.mockImplementation( ( @@ -178,6 +233,7 @@ export function resetMocks(): void { window.activeTextEditor = undefined; workspace.getConfiguration.mockClear(); env.shell = "/bin/mock-shell"; + env.remoteName = undefined; env.clipboard.writeText.mockClear(); env.clipboard.readText.mockClear(); } @@ -186,6 +242,9 @@ export default { Disposable, EventEmitter, Uri, + TreeItem, + TreeItemCollapsibleState, + ConfigurationTarget, workspace, env, window, diff --git a/src/types.ts b/src/types.ts index 330905d..f4a53d5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,9 +8,19 @@ export interface TerminalConfig { readonly scrollback: number; } +export interface HerdrScrollGesture { + readonly direction: "up" | "down"; + readonly lines: number; + readonly source: "wheel" | "page_key"; + readonly column: number; + readonly row: number; + readonly modifiers: number; +} + export type WebviewMessage = | { readonly type: "ready"; readonly cols: number; readonly rows: number } | { readonly type: "input"; readonly data: string } + | ({ readonly type: "scroll" } & HerdrScrollGesture) | { readonly type: "resize"; readonly cols: number; readonly rows: number } | { readonly type: "copy"; readonly text: string } | { readonly type: "imagePasted"; readonly data: string }; @@ -20,4 +30,12 @@ export type HostMessage = | { readonly type: "exit"; readonly code: number; readonly signal?: number } | ({ readonly type: "config" } & TerminalConfig) | { readonly type: "focus" } - | { readonly type: "clipboardImage"; readonly filePath: string }; + | { readonly type: "clipboardImage"; readonly filePath: string } + | { readonly type: "reset" } + | { + readonly type: "sourceState"; + readonly source: "shell" | "herdr"; + readonly phase: "shell" | "attaching" | "attached" | "detaching" | "error"; + readonly label?: string; + readonly message?: string; + }; diff --git a/src/webview/terminal.css b/src/webview/terminal.css index 5e55863..da54245 100644 --- a/src/webview/terminal.css +++ b/src/webview/terminal.css @@ -26,7 +26,7 @@ body, } #terminal-container .xterm-viewport { - overflow-y: auto; + overflow-y: hidden; background-color: var( --vscode-terminal-background, var(--vscode-panel-background, var(--vscode-editor-background, #1e1e1e)) diff --git a/src/webview/terminal/herdrScroll.test.ts b/src/webview/terminal/herdrScroll.test.ts new file mode 100644 index 0000000..a553652 --- /dev/null +++ b/src/webview/terminal/herdrScroll.test.ts @@ -0,0 +1,165 @@ +// @vitest-environment jsdom + +import { describe, expect, it, vi } from "vitest"; +import { + bindHerdrRemoteScroll, + buildHerdrPageScroll, + buildHerdrWheelScroll, + cellFromPointer, + herdrScrollback, + isPointerInsideTarget, + shouldInterceptHerdrScroll, + wheelStepCount, +} from "./herdrScroll"; + +function stubBounds(target: HTMLElement): void { + vi.spyOn(target, "getBoundingClientRect").mockReturnValue({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 80, + bottom: 24, + width: 80, + height: 24, + toJSON() { + return {}; + }, + }); +} + +describe("shouldInterceptHerdrScroll", () => { + it("is true only while a Herdr session is attaching or attached", () => { + expect(shouldInterceptHerdrScroll("herdr", "attached")).toBe(true); + expect(shouldInterceptHerdrScroll("herdr", "attaching")).toBe(true); + expect(shouldInterceptHerdrScroll("herdr", "detaching")).toBe(false); + expect(shouldInterceptHerdrScroll("shell", "shell")).toBe(false); + }); +}); + +describe("herdrScrollback", () => { + it("disables local xterm history while attached", () => { + expect(herdrScrollback(true, 10000)).toBe(0); + expect(herdrScrollback(false, 10000)).toBe(10000); + }); +}); + +describe("wheelStepCount", () => { + it("maps deltaY into 1-15 wheel lines", () => { + expect(wheelStepCount(0)).toBe(0); + expect(wheelStepCount(-10)).toBe(1); + expect(wheelStepCount(120)).toBe(3); + expect(wheelStepCount(1000)).toBe(15); + }); +}); + +describe("buildHerdrWheelScroll", () => { + it("builds a Herdr wheel scroll gesture", () => { + expect(buildHerdrWheelScroll(-120, { column: 4, row: 7 })).toEqual({ + direction: "up", + lines: 3, + source: "wheel", + column: 4, + row: 7, + modifiers: 0, + }); + expect(buildHerdrWheelScroll(120, { column: 8, row: 9 })).toEqual({ + direction: "down", + lines: 3, + source: "wheel", + column: 8, + row: 9, + modifiers: 0, + }); + }); +}); + +describe("buildHerdrPageScroll", () => { + it("builds page-key scroll gestures sized to the viewport", () => { + expect(buildHerdrPageScroll("PageUp", 24)).toEqual({ + direction: "up", + lines: 24, + source: "page_key", + column: 0, + row: 0, + modifiers: 0, + }); + }); +}); + +describe("cellFromPointer", () => { + it("maps pointer position onto 1-based terminal cells", () => { + const target = document.createElement("div"); + stubBounds(target); + expect(cellFromPointer({ clientX: 0, clientY: 0 }, target, 80, 24)).toEqual({ + column: 1, + row: 1, + }); + }); +}); + +describe("bindHerdrRemoteScroll", () => { + it("captures wheel on window and posts Herdr scroll gestures", () => { + let attached = false; + const sendScroll = vi.fn(); + const target = document.createElement("div"); + document.body.appendChild(target); + stubBounds(target); + const unbind = bindHerdrRemoteScroll( + target, + () => attached, + sendScroll, + () => ({ cols: 80, rows: 24 }), + ); + const wheel = new WheelEvent("wheel", { + deltaY: -120, + bubbles: true, + cancelable: true, + clientX: 0, + clientY: 0, + }); + Object.defineProperty(wheel, "target", { value: target }); + + window.dispatchEvent(wheel); + expect(sendScroll).not.toHaveBeenCalled(); + + attached = true; + window.dispatchEvent(wheel); + expect(sendScroll).toHaveBeenCalledWith({ + direction: "up", + lines: 3, + source: "wheel", + column: 1, + row: 1, + modifiers: 0, + }); + + unbind(); + target.remove(); + }); + + it("ignores wheel events outside the terminal surface", () => { + const sendScroll = vi.fn(); + const target = document.createElement("div"); + const outside = document.createElement("div"); + document.body.append(target, outside); + stubBounds(target); + const unbind = bindHerdrRemoteScroll( + target, + () => true, + sendScroll, + () => ({ cols: 80, rows: 24 }), + ); + const wheel = new WheelEvent("wheel", { + deltaY: -120, + bubbles: true, + cancelable: true, + }); + Object.defineProperty(wheel, "target", { value: outside }); + window.dispatchEvent(wheel); + expect(sendScroll).not.toHaveBeenCalled(); + unbind(); + target.remove(); + outside.remove(); + }); +}); diff --git a/src/webview/terminal/herdrScroll.ts b/src/webview/terminal/herdrScroll.ts new file mode 100644 index 0000000..b738b76 --- /dev/null +++ b/src/webview/terminal/herdrScroll.ts @@ -0,0 +1,149 @@ +import type { HerdrScrollGesture, HostMessage } from "../../types"; + +type SourceState = Extract; + +const WHEEL_OPTIONS: AddEventListenerOptions = { capture: true, passive: false }; +const KEY_OPTIONS: AddEventListenerOptions = { capture: true }; + +export interface HerdrMouseCell { + readonly column: number; + readonly row: number; +} + +export interface HerdrPointerSize { + readonly cols: number; + readonly rows: number; +} + +export function shouldInterceptHerdrScroll( + source: SourceState["source"] | undefined, + phase: SourceState["phase"] | undefined, +): boolean { + return source === "herdr" && (phase === "attaching" || phase === "attached"); +} + +export function herdrScrollback(attached: boolean, configured: number): number { + return attached ? 0 : configured; +} + +export function wheelStepCount(deltaY: number): number { + if (deltaY === 0 || !Number.isFinite(deltaY)) { + return 0; + } + return Math.max(1, Math.min(15, Math.round(Math.abs(deltaY) / 40))); +} + +export function buildHerdrWheelScroll( + deltaY: number, + cell: HerdrMouseCell, +): HerdrScrollGesture | undefined { + const lines = wheelStepCount(deltaY); + if (lines === 0) { + return undefined; + } + return { + direction: deltaY < 0 ? "up" : "down", + lines, + source: "wheel", + column: cell.column, + row: cell.row, + modifiers: 0, + }; +} + +export function buildHerdrPageScroll( + key: "PageUp" | "PageDown", + rows: number, +): HerdrScrollGesture { + return { + direction: key === "PageUp" ? "up" : "down", + lines: Math.max(1, rows), + source: "page_key", + column: 0, + row: 0, + modifiers: 0, + }; +} + +export function cellFromPointer( + event: { readonly clientX: number; readonly clientY: number }, + target: HTMLElement, + cols: number, + rows: number, +): HerdrMouseCell { + const bounds = target.getBoundingClientRect(); + const width = bounds.width || 1; + const height = bounds.height || 1; + const x = Math.min(Math.max(event.clientX - bounds.left, 0), width - 1); + const y = Math.min(Math.max(event.clientY - bounds.top, 0), height - 1); + return { + column: Math.min(cols, Math.max(1, Math.floor((x / width) * cols) + 1)), + row: Math.min(rows, Math.max(1, Math.floor((y / height) * rows) + 1)), + }; +} + +export function isPointerInsideTarget( + event: Event, + target: HTMLElement, +): boolean { + if (!(event.target instanceof Node)) { + return false; + } + return target.contains(event.target); +} + +export function bindHerdrRemoteScroll( + target: HTMLElement, + isAttached: () => boolean, + sendScroll: (gesture: HerdrScrollGesture) => void, + size: () => HerdrPointerSize, +): () => void { + const onWheel = (event: Event): void => { + if ( + !(event instanceof WheelEvent) || + !isAttached() || + !isPointerInsideTarget(event, target) || + event.ctrlKey || + event.altKey || + event.metaKey || + event.shiftKey + ) { + return; + } + const { cols, rows } = size(); + const gesture = buildHerdrWheelScroll( + event.deltaY, + cellFromPointer(event, target, cols, rows), + ); + if (!gesture) { + return; + } + event.preventDefault(); + event.stopPropagation(); + sendScroll(gesture); + }; + const onKeyDown = (event: Event): void => { + if ( + !(event instanceof KeyboardEvent) || + !isAttached() || + !isPointerInsideTarget(event, target) || + event.ctrlKey || + event.altKey || + event.metaKey + ) { + return; + } + if (event.key !== "PageUp" && event.key !== "PageDown") { + return; + } + event.preventDefault(); + event.stopPropagation(); + sendScroll(buildHerdrPageScroll(event.key, size().rows)); + }; + window.addEventListener("wheel", onWheel, WHEEL_OPTIONS); + window.addEventListener("keydown", onKeyDown, KEY_OPTIONS); + return () => { + window.removeEventListener("wheel", onWheel, WHEEL_OPTIONS); + window.removeEventListener("keydown", onKeyDown, KEY_OPTIONS); + }; +} diff --git a/src/webview/terminal/html.test.ts b/src/webview/terminal/html.test.ts index 81796dc..1f37364 100644 --- a/src/webview/terminal/html.test.ts +++ b/src/webview/terminal/html.test.ts @@ -34,6 +34,7 @@ describe("renderTerminalHtml", () => { ); expect(css).toContain("#terminal-container .xterm-viewport"); + expect(css).toContain("overflow-y: hidden"); expect(css).toContain("--vscode-terminal-background"); expect(css).toContain("--vscode-panel-background"); expect(css).not.toContain("background: #1e1e1e"); diff --git a/src/webview/terminal/index.test.ts b/src/webview/terminal/index.test.ts index e920a50..d3f8002 100644 --- a/src/webview/terminal/index.test.ts +++ b/src/webview/terminal/index.test.ts @@ -6,6 +6,7 @@ const fit = vi.fn(); const terminalWrite = vi.fn(); const terminalFocus = vi.fn(); const terminalDispose = vi.fn(); +const terminalReset = vi.fn(); const terminalGetSelection = vi.fn(() => "selected output"); const terminalRefresh = vi.fn(); const terminalOptions: Record = {}; @@ -40,8 +41,14 @@ vi.mock("@xterm/xterm", () => ({ public readonly write = terminalWrite; public readonly focus = terminalFocus; public readonly dispose = terminalDispose; + public readonly reset = terminalReset; public readonly getSelection = terminalGetSelection; public readonly refresh = terminalRefresh; + public attachCustomWheelEventHandler = vi.fn(); + public modes = { + mouseTrackingMode: "none" as const, + applicationCursorKeysMode: false, + }; public textarea: HTMLTextAreaElement | undefined; private container?: HTMLElement; public constructor(options: Record) { @@ -135,11 +142,14 @@ class TestIntersectionObserver { public disconnect(): void {} } -const { createTerminalView, DEFAULT_FONT_FAMILY } = await import("./index"); +const { createTerminalView, DEFAULT_FONT_FAMILY, isSourceStateMessage } = await import("./index"); describe("createTerminalView", () => { beforeEach(() => { vi.clearAllMocks(); + for (const key of Object.keys(terminalOptions)) { + delete terminalOptions[key]; + } dataListener = undefined; resizeListener = undefined; terminalConstructorOptions = undefined; @@ -305,4 +315,165 @@ describe("createTerminalView", () => { expect(terminalRefresh).toHaveBeenCalledTimes(1); }); + describe("reset and sourceState messages", () => { + beforeEach(() => { + terminalReset.mockClear(); + terminalWrite.mockClear(); + }); + + it("postMessage {type:'reset'} -> terminal.reset() called AND sentinel written before reset disappears", () => { + const container = document.createElement("div"); + createTerminalView(container); + + // Simulate writing a sentinel + window.dispatchEvent( + new MessageEvent("message", { data: { type: "output", data: "ULW_SENTINEL_OLD" } }), + ); + expect(terminalWrite).toHaveBeenCalledWith("ULW_SENTINEL_OLD"); + + // Dispatch reset + window.dispatchEvent( + new MessageEvent("message", { data: { type: "reset" } }), + ); + + expect(terminalReset).toHaveBeenCalled(); + + // Since it's a mock, we assert the mock order (write happened before reset) + const writeOrder = terminalWrite.mock.invocationCallOrder[0]; + const resetOrder = terminalReset.mock.invocationCallOrder[0]; + expect(resetOrder).toBeGreaterThan(writeOrder); + }); + + it("renders badge for typed phases and clears on shell phase", () => { + const container = document.createElement("div"); + createTerminalView(container); + + // attached+label -> badge visible with label text + window.dispatchEvent( + new MessageEvent("message", { data: { type: "sourceState", source: "herdr", phase: "attached", label: "probe" } }), + ); + + let badge = container.querySelector(".ulw-status-badge"); + expect(badge).not.toBeNull(); + expect(badge?.getAttribute("role")).toBe("status"); + expect(badge?.getAttribute("aria-live")).toBe("polite"); + expect(badge?.textContent).toBe("Attached: probe"); + expect(badge?.classList.contains("error")).toBe(false); + + // attaching -> badge visible + window.dispatchEvent( + new MessageEvent("message", { data: { type: "sourceState", source: "herdr", phase: "attaching" } }), + ); + + badge = container.querySelector(".ulw-status-badge"); + expect(badge?.textContent).toBe("Attaching"); + + // detaching -> badge visible + window.dispatchEvent( + new MessageEvent("message", { data: { type: "sourceState", source: "herdr", phase: "detaching" } }), + ); + + badge = container.querySelector(".ulw-status-badge"); + expect(badge?.textContent).toBe("Detaching"); + + // error+message -> message inline, error class + window.dispatchEvent( + new MessageEvent("message", { data: { type: "sourceState", source: "herdr", phase: "error", message: "boom" } }), + ); + + badge = container.querySelector(".ulw-status-badge"); + expect(badge?.textContent).toBe("Error: boom"); + expect(badge?.classList.contains("error")).toBe(true); + + // shell -> badge cleared + window.dispatchEvent( + new MessageEvent("message", { data: { type: "sourceState", source: "shell", phase: "shell" } }), + ); + + expect(container.querySelector(".ulw-status-badge")).toBeNull(); + }); + + it("forwards wheel as Herdr scroll gestures while attached", () => { + const container = document.createElement("div"); + createTerminalView(container); + const dispatchWheel = (): WheelEvent => { + const event = new WheelEvent("wheel", { + deltaY: -120, + bubbles: true, + cancelable: true, + clientX: 0, + clientY: 0, + }); + Object.defineProperty(event, "target", { value: container }); + window.dispatchEvent(event); + return event; + }; + + expect(dispatchWheel().defaultPrevented).toBe(false); + expect(postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "scroll" }), + ); + + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "sourceState", + source: "herdr", + phase: "attached", + label: "probe", + }, + }), + ); + postMessage.mockClear(); + + expect(terminalOptions.scrollback).toBe(0); + expect(dispatchWheel().defaultPrevented).toBe(true); + expect(postMessage).toHaveBeenCalledWith({ + type: "scroll", + direction: "up", + lines: 3, + source: "wheel", + column: 1, + row: 1, + modifiers: 0, + }); + + window.dispatchEvent( + new MessageEvent("message", { + data: { type: "sourceState", source: "shell", phase: "shell" }, + }), + ); + postMessage.mockClear(); + expect(terminalOptions.scrollback).toBe(10000); + expect(dispatchWheel().defaultPrevented).toBe(false); + expect(postMessage).not.toHaveBeenCalled(); + }); + + it("rejects malformed external payload (cast through unknown guard) without throw, badge unchanged", () => { + const container = document.createElement("div"); + createTerminalView(container); + + // set an initial valid state + window.dispatchEvent( + new MessageEvent("message", { data: { type: "sourceState", source: "herdr", phase: "attaching" } }), + ); + + const badge = container.querySelector(".ulw-status-badge"); + expect(badge?.textContent).toBe("Attaching"); + + // exercise the guard directly + expect(isSourceStateMessage({ type: "sourceState", source: "herdr", phase: "invalid_phase_name" } as unknown)).toBe(false); + expect(isSourceStateMessage({ type: "sourceState", source: "herdr", phase: "attaching" } as unknown)).toBe(true); + + // send a malformed payload (invalid phase) + window.dispatchEvent( + new MessageEvent("message", { data: { type: "sourceState", source: "herdr", phase: "invalid_phase_name" } as unknown }), + ); + + // The badge should not have changed or crashed + const badgeAfter = container.querySelector(".ulw-status-badge"); + expect(badgeAfter).toBe(badge); + expect(badgeAfter?.textContent).toBe("Attaching"); + }); + }); }); diff --git a/src/webview/terminal/index.ts b/src/webview/terminal/index.ts index b63b974..c74509e 100644 --- a/src/webview/terminal/index.ts +++ b/src/webview/terminal/index.ts @@ -3,7 +3,14 @@ import { WebglAddon } from "@xterm/addon-webgl"; import { Terminal } from "@xterm/xterm"; import type { HostMessage } from "../../types"; import { postMessage } from "../shared/vscode-api"; +import { + bindHerdrRemoteScroll, + herdrScrollback, + shouldInterceptHerdrScroll, +} from "./herdrScroll"; +import { createStatusBadge } from "./statusBadge"; import { readTerminalTheme, watchTerminalTheme } from "./theme"; +import "./terminal.css"; export interface TerminalView { readonly terminal: Terminal; @@ -18,6 +25,26 @@ const MAX_IMAGE_SIZE = 5 * 1024 * 1024; type RendererPreference = "webgl" | "dom"; +export function isSourceStateMessage( + msg: unknown, +): msg is Extract { + if (!msg || typeof msg !== "object") { + return false; + } + const candidate = msg as Record; + const sourceOk = candidate.source === "shell" || candidate.source === "herdr"; + const phaseOk = + candidate.phase === "shell" || + candidate.phase === "attaching" || + candidate.phase === "attached" || + candidate.phase === "detaching" || + candidate.phase === "error"; + const labelOk = candidate.label === undefined || typeof candidate.label === "string"; + const messageOk = + candidate.message === undefined || typeof candidate.message === "string"; + return candidate.type === "sourceState" && sourceOk && phaseOk && labelOk && messageOk; +} + function readRendererPreference(): RendererPreference { return (globalThis as { __ulwRenderer?: unknown }).__ulwRenderer === "dom" ? "dom" @@ -73,6 +100,26 @@ export function createTerminalView(container: HTMLElement): TerminalView { }); } + let herdrAttached = false; + let configuredScrollback = 10000; + let detachCustomWheelHandler: (() => void) | undefined; + const applyHerdrBufferMode = (): void => { + terminal.options.scrollback = herdrScrollback(herdrAttached, configuredScrollback); + detachCustomWheelHandler?.(); + detachCustomWheelHandler = undefined; + if (herdrAttached) { + terminal.attachCustomWheelEventHandler(() => false); + detachCustomWheelHandler = () => { + terminal.attachCustomWheelEventHandler(() => true); + }; + } + }; + const unbindHerdrScroll = bindHerdrRemoteScroll( + container, + () => herdrAttached, + (gesture) => postMessage({ type: "scroll", ...gesture }), + () => ({ cols: terminal.cols, rows: terminal.rows }), + ); const inputDisposable = terminal.onData((data) => { postMessage({ type: "input", data }); }); @@ -146,6 +193,7 @@ export function createTerminalView(container: HTMLElement): TerminalView { }; container.addEventListener("paste", handlePasteEvent); + const statusBadge = createStatusBadge(container); const messageHandler = (event: MessageEvent) => { const message = event.data; switch (message.type) { @@ -162,7 +210,8 @@ export function createTerminalView(container: HTMLElement): TerminalView { terminal.options.fontFamily = message.fontFamily; terminal.options.cursorBlink = message.cursorBlink; terminal.options.cursorStyle = message.cursorStyle; - terminal.options.scrollback = message.scrollback; + configuredScrollback = message.scrollback; + applyHerdrBufferMode(); fitAndRepaintUnlessImeComposing(); break; case "focus": @@ -171,6 +220,20 @@ export function createTerminalView(container: HTMLElement): TerminalView { case "clipboardImage": terminal.paste(message.filePath); break; + case "reset": + terminal.reset(); + break; + case "sourceState": + if (isSourceStateMessage(message)) { + herdrAttached = shouldInterceptHerdrScroll(message.source, message.phase); + applyHerdrBufferMode(); + statusBadge.update(message); + } + break; + default: { + const _exhaustiveCheck: never = message; + break; + } } }; window.addEventListener("message", messageHandler); @@ -185,6 +248,7 @@ export function createTerminalView(container: HTMLElement): TerminalView { terminal, dispose() { window.removeEventListener("message", messageHandler); + unbindHerdrScroll(); container.removeEventListener("mouseup", copySelection); container.removeEventListener("mousedown", focusTerminal); container.removeEventListener("paste", handlePasteEvent); @@ -194,6 +258,7 @@ export function createTerminalView(container: HTMLElement): TerminalView { resizeObserver.disconnect(); visibilityObserver.disconnect(); disposeThemeWatcher(); + detachCustomWheelHandler?.(); inputDisposable.dispose(); resizeDisposable.dispose(); terminal.dispose(); diff --git a/src/webview/terminal/statusBadge.ts b/src/webview/terminal/statusBadge.ts new file mode 100644 index 0000000..65401b8 --- /dev/null +++ b/src/webview/terminal/statusBadge.ts @@ -0,0 +1,37 @@ +import type { HostMessage } from "../../types"; + +type SourceStateMessage = Extract; + +export function createStatusBadge(container: HTMLElement): { + update(message: SourceStateMessage): void; +} { + let badgeElement: HTMLDivElement | undefined; + return { + update(message) { + if (message.phase === "shell") { + badgeElement?.remove(); + badgeElement = undefined; + return; + } + if (!badgeElement) { + badgeElement = document.createElement("div"); + badgeElement.className = "ulw-status-badge"; + badgeElement.setAttribute("role", "status"); + badgeElement.setAttribute("aria-live", "polite"); + container.appendChild(badgeElement); + } + if (message.phase === "error") { + badgeElement.classList.add("error"); + badgeElement.textContent = message.message + ? `Error: ${message.message}` + : "Error attaching"; + return; + } + badgeElement.classList.remove("error"); + const phaseText = message.phase.charAt(0).toUpperCase() + message.phase.slice(1); + badgeElement.textContent = message.label + ? `${phaseText}: ${message.label}` + : phaseText; + }, + }; +} diff --git a/src/webview/terminal/terminal.css b/src/webview/terminal/terminal.css new file mode 100644 index 0000000..af22962 --- /dev/null +++ b/src/webview/terminal/terminal.css @@ -0,0 +1,22 @@ +.ulw-status-badge { + position: absolute; + top: 8px; + right: 16px; + z-index: 100; + padding: 4px 8px; + border-radius: 4px; + font-family: inherit; + font-size: 12px; + color: var(--vscode-terminal-foreground, var(--vscode-editor-foreground, #cccccc)); + background: var(--vscode-terminal-inactiveSelectionBackground, var(--vscode-badge-background, #333333)); + border: 1px solid var(--vscode-terminal-selectionBackground, #444444); + pointer-events: none; + opacity: 0.95; + box-shadow: var(--vscode-widget-shadow, 0 2px 4px rgba(0, 0, 0, 0.2)); +} + +.ulw-status-badge.error { + color: var(--vscode-terminal-ansiBrightWhite, #ffffff); + background: var(--vscode-terminal-ansiRed, #cd3131); + border-color: var(--vscode-terminal-ansiBrightRed, #f14c4c); +}