feat: in-browser terminal for the workspace pane + rename boot→workspace, claude→nexus #17

Merged
lz merged 7 commits from feat/workspace-window-terminal into main 2026-05-16 23:43:23 +02:00
Owner

Summary

  • Add a "term" button on every running workspace card that opens an in-browser xterm.js terminal attached to the workspace (formerly "boot") tmux pane — useful for inspecting the environment with or without the workspace agent running.
  • Rename the bootstrap tmux window from bootworkspace and the tmux session from claudenexus across the entrypoint, session service, and all AGENTS.md references.
  • Refactor the terminal WebSocket upgrade path into a shared DRY core (upgrade.ts); add a new workspace-upgrade.ts handler at GET /api/workers/:id/terminal.
  • Extend SessionTerminal.svelte with a mode prop ('session' | 'workspace') so the same component covers both terminal targets.
  • Rename workspace-shell toggle to "+ workspace agent" / "× workspace agent" with yellow/red colours; anchor it to the far-right of the action row via margin-left: auto.

Bug fixes during smoke testing

  • SSR crash (self is not defined): xterm.js evaluated at SSR time. Fixed by lazy-loading inside onMount.
  • Workspace-shell probe always false: pane_current_command returned sh (claude is a child of the /bin/sh -c wrapper). Fixed by scanning /proc/<pid>/cwd for a process with CWD /workspace.
  • respawn-window relaunched claude on close: tmux respawn-window -k without an explicit command replayed the window's original launch. Fixed by passing -- /bin/bash explicitly.
  • Button colour bounced after toggle (flicker): optimistic healthMap flip set workspaceShellRunning before claude actually settled. Fixed with a server-side waitForWorkspaceShell poll loop and a client-side reprobeHealth call after the action instead of an optimistic flip.
  • Leaked view-sessions: stream.destroy() doesn't reliably terminate the docker exec on the daemon side with Tty: true. Leaked grouped sessions stayed (attached), blocking destroy-unattached on and hijacking the operator's docker exec ... tmux a with prefix=None / status=off. Fixed by firing an explicit tmux kill-session -t \<viewId\> from a fresh short-lived exec in pty.end().

Test plan

  • Rebuild the worker image and start a workspace
  • Click term on a running workspace card → xterm terminal opens on the workspace pane
  • Click + workspace agent → button turns red ("× workspace agent") without bounce
  • Click × workspace agent → button turns yellow ("+ workspace agent") without bounce
  • Open a session terminal and close it; run tmux ls inside the container — no stale view- sessions
  • Run pnpm test inside nexus/ — all tests pass
  • Host-side docker exec -it <container> tmux a still works with Ctrl-b intact after the in-browser terminal is closed

This PR closes #7

## Summary - Add a **"term" button** on every running workspace card that opens an in-browser xterm.js terminal attached to the workspace (formerly "boot") tmux pane — useful for inspecting the environment with or without the workspace agent running. - Rename the bootstrap tmux window from `boot` → `workspace` and the tmux session from `claude` → `nexus` across the entrypoint, session service, and all AGENTS.md references. - Refactor the terminal WebSocket upgrade path into a shared DRY core (`upgrade.ts`); add a new `workspace-upgrade.ts` handler at `GET /api/workers/:id/terminal`. - Extend `SessionTerminal.svelte` with a `mode` prop (`'session' | 'workspace'`) so the same component covers both terminal targets. - Rename workspace-shell toggle to **"+ workspace agent" / "× workspace agent"** with yellow/red colours; anchor it to the far-right of the action row via `margin-left: auto`. ## Bug fixes during smoke testing - **SSR crash** (`self is not defined`): xterm.js evaluated at SSR time. Fixed by lazy-loading inside `onMount`. - **Workspace-shell probe always false**: `pane_current_command` returned `sh` (claude is a child of the `/bin/sh -c` wrapper). Fixed by scanning `/proc/<pid>/cwd` for a process with CWD `/workspace`. - **`respawn-window` relaunched claude on close**: `tmux respawn-window -k` without an explicit command replayed the window's original launch. Fixed by passing `-- /bin/bash` explicitly. - **Button colour bounced after toggle** (flicker): optimistic healthMap flip set `workspaceShellRunning` before claude actually settled. Fixed with a server-side `waitForWorkspaceShell` poll loop and a client-side `reprobeHealth` call after the action instead of an optimistic flip. - **Leaked view-sessions**: `stream.destroy()` doesn't reliably terminate the docker exec on the daemon side with `Tty: true`. Leaked grouped sessions stayed `(attached)`, blocking `destroy-unattached on` and hijacking the operator's `docker exec ... tmux a` with `prefix=None` / `status=off`. Fixed by firing an explicit `tmux kill-session -t \<viewId\>` from a fresh short-lived exec in `pty.end()`. ## Test plan - [ ] Rebuild the worker image and start a workspace - [ ] Click **term** on a running workspace card → xterm terminal opens on the workspace pane - [ ] Click **+ workspace agent** → button turns red ("× workspace agent") without bounce - [ ] Click **× workspace agent** → button turns yellow ("+ workspace agent") without bounce - [ ] Open a session terminal and close it; run `tmux ls` inside the container — no stale `view-` sessions - [ ] Run `pnpm test` inside `nexus/` — all tests pass - [ ] Host-side `docker exec -it <container> tmux a` still works with Ctrl-b intact after the in-browser terminal is closed --- This PR closes #7
lz added 7 commits 2026-05-16 23:20:12 +02:00
The bootstrap tmux window was named after an implementation detail of the
entrypoint rather than its purpose. 'workspace' better describes what it
hosts: the persistent pane the operator uses for workspace-level shell
and agent work. Rename end-to-end: entrypoint, BOOT_WINDOW constant →
WORKSPACE_WINDOW, sessions service, reconciler guard, test fixture, and
the tooltip/JSDoc that surface the name to the operator.
Adds an upgrade handler that attaches the operator's browser to the
workspace tmux window via a grouped view-session — the same PTY-over-WS
mechanism used by session terminals. Changes:
- Refactor handleTerminalUpgradeCore to accept a pluggable resolveTarget
  strategy; handleSessionTerminalUpgrade becomes the thin session caller.
- handleWorkspaceTerminalUpgrade resolves the 'workspace' window on any
  running worker (no isWorkspaceShellRunning gate — the pane always hosts
  something useful).
- Route GET /api/workers/:id/terminal → workspace upgrade handler.
- SessionTerminal gains a mode prop ('session' | 'workspace') to route the WS URL.
- Workspace card shows a Terminal button when the worker is running.
xterm's top-level module references the browser global 'self', so importing
it statically in a component reachable from +page.svelte crashes the SSR
pass with 'ReferenceError: self is not defined'. Switch to dynamic imports
inside onMount and 'import type' for the type stubs (erased at runtime).
The previous probe used tmux's pane_current_command, which stays 'sh' (then
'bash') for the workspace launch wrapper — claude never appeared as the
current command. Switch to a /proc CWD scan matching /workspace, the same
pattern checkSessionHealth uses. Also fix closeWorkspaceSession to pass
'-- /bin/bash' explicitly so respawn-window doesn't replay the claude launch
command. Drop the isWorkspaceShellRunning gate from the workspace terminal
handler — the window is always present on a running worker.
The session name was tied to the binary it usually hosts, which is
ambiguous now that the workspace window can run bash, claude, or anything
else. 'nexus' matches the project brand and avoids confusion with the
claude binary. Rename end-to-end: entrypoint, all tmux target strings in
sessions/service.ts, workers/service.ts and exec-pty.ts, test fixtures,
and operator docs (AGENTS.md, README.md, worker/README.md).
Destroying the hijacked dockerode stream does not reliably tear down the
docker exec on the daemon side. The lingering attach-session process keeps
the grouped view-session marked '(attached)', blocking destroy-unattached
and causing stale view-sessions to accumulate. Fix: fire-and-forget a
separate exec running 'tmux kill-session -t <viewId>' in pty.end().
feat(ui): workspace agent button with settled-state probe
All checks were successful
ci / nexus (pull_request) Successful in 2m32s
ci / images (./nexus, agent-nexus) (pull_request) Successful in 3m43s
ci / images (./worker, nexus-worker) (pull_request) Successful in 1m50s
79266b67b0
Rename the 'Workspace shell' toggle to '+ workspace agent' / 'Stop agent'
and move it to the far-right of the action row with margin-left:auto.
Style: yellow primary for start, red danger for stop.

Drop the optimistic healthMap flip: the API returned before claude was
fully up/down, causing the button to bounce colors on the next poll.
Instead, openWorkspaceSession/closeWorkspaceSession now poll the /proc
probe (3s deadline, 100ms cadence) before returning, and the client
re-probes health once after the action to get the settled state.
lz merged commit c57a5df994 into main 2026-05-16 23:43:23 +02:00
lz deleted branch feat/workspace-window-terminal 2026-05-16 23:43:23 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lz/agent-nexus!17
No description provided.