feat: workspace aliases, tab groups of split panes, native path picker (v2.1.0)
- /api/workspaces and `agenthub workspace(s)` / `--workspace` as aliases of /api/lanes and `agenthub lanes` / `--lane`; old names print one stderr note. REPL `help lanes|workspace|workspaces` resolves through the aliases. - A workspace holds up to ten sessions (slots 0-9); every console tab is a group of 1-4 split panes (PaneGroups, agenthub.workspace.paneLayout); parseGroups keeps the stored active tab when an earlier one is dropped. - Browse button beside path fields: POST /api/lanes/pick-path opens the OS picker on the server's desktop — loopback only and no forwarding header (EREMOTE), a display failure is ENODIALOG not a Cancel, the dialog is killed when the request closes, a folder field falls back to the in-app browser on any failure. - Changes rail counts chip, image viewer (/git/raw), status bar session counts and per-day cost (`date=` on /api/pricing/cost). - /agenthub-open and /agenthub-doctor run from the INSTALLED build (supersededBy), doctor offers fixes behind two confirmations; plugin-open treats a locked bootstrap as non-fatal. - New-terminal shell waits for the first attach (issue #55). - repo-onboarding / debug-live-issue skills removed. - OpenAPI: pick-path, lane/date on /pricing/cost, tool on /run/history, /api/workspaces mount.
This commit is contained in:
+54
-13
@@ -108,6 +108,13 @@ https://dashboard.example.com
|
||||
|
||||
### Workspaces
|
||||
|
||||
Every `/api/lanes…` route below also answers under `/api/workspaces…` — the
|
||||
same router mounted twice (`server/index.js`), so `GET /api/workspaces/:id` is
|
||||
`GET /api/lanes/:id`. Both are permanent: `/api/workspaces` matches the UI's
|
||||
word, but exists only on servers newer than v2.0.1, so a script that must also
|
||||
reach an older install keeps `/api/lanes`. Responses keep their `lane`/`lanes`
|
||||
keys either way, and the paths below are written with `/api/lanes`.
|
||||
|
||||
Workspace mutations (`POST /api/lanes/ensure`, `POST /api/lanes/worktree`,
|
||||
`PATCH /api/lanes/:id`, confirmed
|
||||
actions, the runtime routes `POST /api/lanes/:id/up`, `/down` and
|
||||
@@ -170,6 +177,32 @@ Two concurrent calls for the same path always yield **one** workspace: the
|
||||
`lanes.cwd` UNIQUE constraint decides the winner, and the loser re-reads and
|
||||
returns the winning workspace rather than reporting a conflict.
|
||||
|
||||
#### Pick a path with the OS's own dialog
|
||||
|
||||
```http
|
||||
POST /api/lanes/pick-path
|
||||
Content-Type: application/json
|
||||
|
||||
{ "kind": "dir", "start": "/home/me/projects" }
|
||||
```
|
||||
|
||||
Opens the operating system's folder (`kind: "dir"`, the default) or file
|
||||
(`kind: "file"`) picker **on the server's desktop** and waits for the user —
|
||||
zenity, else kdialog, on Linux; `osascript` on macOS; PowerShell's WinForms
|
||||
dialogs on Windows (`server/lib/native-dialog.js`; macOS and Windows untested).
|
||||
`start` opens it there (its folder when it names a file; home when missing).
|
||||
Answers `{ "path": "<absolute>" }` or `{ "cancelled": true }`. Same-origin
|
||||
guarded, and only a socket from loopback carrying no `X-Forwarded-For` /
|
||||
`Forwarded` header may ask (a reverse proxy connects from loopback too):
|
||||
`403 EREMOTE` otherwise, `501 ENODIALOG` with no graphical session, no picker
|
||||
program, or one that could not reach the display, `409 EBUSY` while one is
|
||||
already open, `500 EDIALOG` when the program failed. The dialog is closed when
|
||||
the request goes away (a closed tab, an SSH tunnel's browser leaving). A folder
|
||||
**Browse** button in the UI (`client/src/components/PathPickButton.tsx`) falls
|
||||
back on ANY of those to the in-app folder browser
|
||||
`GET /api/lanes/browse?path=<abs>`; a file field falls back to typing the path
|
||||
on `EREMOTE`/`ENODIALOG` and shows the error otherwise.
|
||||
|
||||
#### Update a workspace
|
||||
|
||||
```http
|
||||
@@ -193,7 +226,7 @@ to showing the workspace's `cwd`. Renaming is display-only: a workspace's branch
|
||||
slugified from its title once, at creation.
|
||||
|
||||
`pipeline` is patchable, which is how a workspace moves between templates after
|
||||
creation (`agenthub lanes pipeline <template-id>`). An id no template defines is
|
||||
creation (`agenthub workspace pipeline <template-id>`). An id no template defines is
|
||||
rejected with `400 EBADPIPELINE` rather than stored (`default` is always
|
||||
accepted): `getPipeline` falls back to the default template when READING, so an unvalidated write would be
|
||||
accepted and then silently render the wrong map forever. Same 400 treatment as
|
||||
@@ -281,8 +314,8 @@ When the session's tool is Codex, three fields control Codex-specific behavior:
|
||||
- **`router: "<name>"`** — the AI Router profile THIS session runs on (omitted = native, or for a resume the router its session last ran on); see § AI Router profiles. `400 EBADROUTER` for an unknown name, `400 EUNSUPPORTED` on a tool other than Claude/Codex.
|
||||
- **`worktree: true`** (Claude workspaces, no `resumeSessionId`) — a NEW session in a git worktree of its own, Claude Code's way: the pane runs `claude --worktree=ah-<id>-<base36 time>` (the `=` form, since `-w [name]` would take the prompt as the name; the name is made by the server), so Claude itself creates `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs the user's `WorktreeCreate` hook and cleans up on exit. `/.claude/worktrees/` is added to the repo root's `info/exclude`; the run is recorded with `worktree: 1` (and the run handle carries `worktree: true`). `400 EUNSUPPORTED` on any other tool. A `resumeSessionId` (or fork) of a session that ran in such a worktree starts the pane IN that worktree; one whose worktree is gone is `400 ENOWORKTREE`.
|
||||
|
||||
- **`slot`** — which Workspace tab's session to start, `0`–`3` or `"auto"` (default `0`, else `400 EBADSLOT`). `"auto"` lets the server pick the first slot with no live session, under the workspace lock (a client's own pick may come from a stale list); when all four are busy it answers `409 ERUNLIVE` "every tab of this workspace is busy", while a specific busy slot answers `409 ERUNLIVE` "that tab already has a live session". Slot 0 is the workspace's own `agenthub-lane-<id>` and is what `run_id` records; slots 1–3 are extra sessions on the same workspace, `agenthub-lane-<id>-<slot>`, and never touch `run_id`/`status`. The response adds `run_id`: the session this start opened. `stop`, `reset` and `remove` kill every slot's session, not only `run_id`.
|
||||
- **`shell`** — `true` opens a plain shell in that slot instead of an AI tool (the Workspace's **New terminal** tab): tmux's default shell in the workspace's folder, recorded as tool `shell`. Every other field is ignored, nothing comes from the request but the slot, and the workspace's `run_id`/`status` stay as they are (no agent is at work). A slot with a live session answers `409 ERUNLIVE`.
|
||||
- **`slot`** — which Workspace session to start, `0`–`9` (ten per workspace) or `"auto"` (default `0`, else `400 EBADSLOT`). `"auto"` lets the server pick the first slot with no live session, under the workspace lock (a client's own pick may come from a stale list); when all ten are busy it answers `409 ERUNLIVE` "every tab of this workspace is busy", while a specific busy slot answers `409 ERUNLIVE` "that tab already has a live session". Slot 0 is the workspace's own `agenthub-lane-<id>` and is what `run_id` records; slots 1–9 are extra sessions on the same workspace, `agenthub-lane-<id>-<slot>`, and never touch `run_id`/`status`. The response adds `run_id`: the session this start opened. `stop`, `reset` and `remove` kill every slot's session, not only `run_id`.
|
||||
- **`shell`** — `true` opens a plain shell in that slot instead of an AI tool (the Workspace's **New terminal** tab): the login shell tmux would run (`$SHELL -l`) in the workspace's folder, started only once a client is attached, so at the browser's size (`#{session_attached}` polled, at most 2 s — a prompt printed at tmux's 80 columns and then resized left the cursor on top of it, issue #55), recorded as tool `shell`. Every other field is ignored, nothing comes from the request but the slot, and the workspace's `run_id`/`status` stay as they are (no agent is at work). A slot with a live session answers `409 ERUNLIVE`.
|
||||
|
||||
Error `503 ETMUXMISSING` is returned when the host has no `tmux` binary. Every run the dashboard starts is a tmux session, so this is a missing dependency rather than a bad request — the message carries the install command, and the same code comes back from `POST /api/run`. Nothing else in the dashboard is affected.
|
||||
|
||||
@@ -526,14 +559,15 @@ that file.
|
||||
GET /api/lanes/:id/git/log?limit=30[&file=<path>]
|
||||
GET /api/lanes/:id/git/file?path=<path>[&rev=work|HEAD|<sha>]
|
||||
PUT /api/lanes/:id/git/file {path, content, baseSha}
|
||||
GET /api/lanes/:id/git/raw?path=<image>[&rev=work|HEAD|<sha>]
|
||||
GET /api/lanes/:id/git/ls?q=<substring>
|
||||
GET /api/lanes/:id/git/tree?path=<dir>
|
||||
GET /api/lanes/:id/git/tree-all
|
||||
```
|
||||
|
||||
The reads behind the Changes panel's **History**, whole-file view, quick-open
|
||||
and file explorer. All four are read-only and unguarded, and all four answer
|
||||
`{available:false}` with HTTP **200** for a cwd git cannot read.
|
||||
and file explorer. The GETs are read-only and unguarded, and the JSON ones
|
||||
answer `{available:false}` with HTTP **200** for a cwd git cannot read.
|
||||
|
||||
- **`/git/log`** → `{available, commits:[{sha, short, subject, author, date,
|
||||
refs}]}`, newest first, `limit` clamped to 200. `file=` narrows it to the
|
||||
@@ -547,6 +581,13 @@ and file explorer. All four are read-only and unguarded, and all four answer
|
||||
overwrite. `403 ESECRET` for `settings.local.json`; `400 EBADPATH`,
|
||||
`ENOFILE`, `EBADCONTENT`, `ETOOBIG`, `EBINARY`. Answers the file re-read,
|
||||
fresh `sha` included. CRLF endings and the final newline are preserved.
|
||||
- **`/git/raw`** → the bytes of one IMAGE file (png, jpg, jpeg, gif, webp,
|
||||
avif, bmp, ico, svg — by extension, which also sets the Content-Type), for
|
||||
the viewer's `<img>`; the Changes panel shows an image file this way instead
|
||||
of text or a binary diff. Same path/rev/secret guards as `/git/file`, 20 MB
|
||||
cap; `400 ENOTIMAGE` for any other extension, `ETOOBIG`, `ENOFILE`,
|
||||
`EBADPATH`, `EBADREV`, `403 ESECRET`. Served with a `sandbox` CSP and
|
||||
`nosniff`, so an SVG opened from this URL runs no script.
|
||||
- **`/git/file`** → `{available, path, rev, binary, truncated, lines, sha?}`
|
||||
(`sha` only for a whole, textual `work` file: what a save sends back).
|
||||
`rev=work` (default) is the file on disk — the one branch that reads the
|
||||
@@ -994,7 +1035,7 @@ Delete proof at one of three granularities: request body `{ "group": "phase-name
|
||||
POST /api/lanes/:id/proof-link
|
||||
```
|
||||
|
||||
Converge the clone-root `proof/` directory (if it exists at `<lane.cwd>/proof`) onto the canonical `.playwright-mcp/proof`. This is an explicit operation, never automatic — exposed only as `agenthub lanes proof-link`. Idempotent.
|
||||
Converge the clone-root `proof/` directory (if it exists at `<lane.cwd>/proof`) onto the canonical `.playwright-mcp/proof`. This is an explicit operation, never automatic — exposed only as `agenthub workspace proof-link`. Idempotent.
|
||||
|
||||
**Response:**
|
||||
|
||||
@@ -1110,7 +1151,7 @@ every number. `status` filters by session lifecycle, same convention as
|
||||
```json
|
||||
{
|
||||
"skills": [
|
||||
{ "name": "agenthub:repo-onboarding", "plugin": "agenthub", "count": 12, "sessions": 5 },
|
||||
{ "name": "agenthub:update-project-docs", "plugin": "agenthub", "count": 12, "sessions": 5 },
|
||||
{ "name": "daily-report", "plugin": null, "count": 3, "sessions": 3 }
|
||||
],
|
||||
"plugins": [
|
||||
@@ -1941,10 +1982,10 @@ curl -X DELETE http://localhost:4820/api/pricing/gpt-5.1-codex
|
||||
#### Total Cost
|
||||
|
||||
```http
|
||||
GET /api/pricing/cost?tz_offset=<minutes>&sources=<csv>&lane=<id>
|
||||
GET /api/pricing/cost?tz_offset=<minutes>&sources=<csv>&lane=<id>&date=<YYYY-MM-DD>
|
||||
```
|
||||
|
||||
The total cost with a per-model `breakdown`, plus `daily_costs: [{date, cost}]` by local date (`tz_offset` is the browser's `getTimezoneOffset()`; the status bar's **Today** is the entry for today). A day is the day the tokens were SPENT: each session's total is split over the local days of its `token_usage_hourly` rows (one per UTC hour, from each Claude transcript message's timestamp), and whatever the total holds beyond those rows — a session recorded before that table and whose transcript is gone, sub-agent files, Codex and Antigravity sessions — is booked to the session's start date. The days always add up to the total. `lane=<id>` narrows it to the sessions that workspace owns (`sessions.lane_id`, or held by one of its `dashboard_runs` and not stamped yet — the rule of `GET /api/sessions?lane=`; a non-numeric id is `400 INVALID_INPUT`); the status bar shows it as **Workspace $…** on the Workspace page, beside **Session $…** from `GET /api/pricing/cost/:sessionId` for the replayed session or the focused pane's.
|
||||
The total cost with a per-model `breakdown`, plus `daily_costs: [{date, cost}]` by local date (`tz_offset` is the browser's `getTimezoneOffset()`). `date=<YYYY-MM-DD>` prices only that local day — `total_cost`, `breakdown` and `daily_costs` all cover just it (any other shape is `400 INVALID_INPUT`); the status bar's **Today** asks for the local date and shows the day's token total beside it, listing tokens and cost per model on hover (as it does for **Workspace** and **Session**). A day is the day the tokens were SPENT: each session's total is split over the local days of its `token_usage_hourly` rows (one per UTC hour, from each Claude transcript message's timestamp), and whatever the total holds beyond those rows — a session recorded before that table and whose transcript is gone, sub-agent files, Codex and Antigravity sessions — is booked to the session's start date. The days always add up to the total. `lane=<id>` narrows it to the sessions that workspace owns (`sessions.lane_id`, or held by one of its `dashboard_runs` and not stamped yet — the rule of `GET /api/sessions?lane=`; a non-numeric id is `400 INVALID_INPUT`); the status bar shows it as **Workspace $…** on the Workspace page, beside **Session $…** from `GET /api/pricing/cost/:sessionId` for the replayed session or the focused pane's.
|
||||
|
||||
```bash
|
||||
curl "http://localhost:4820/api/pricing/cost?tz_offset=-420"
|
||||
@@ -2276,7 +2317,7 @@ GET /api/run List all live runs (computed fresh from tm
|
||||
GET /api/run/tmux { available: bool } — whether tmux is on PATH
|
||||
GET /api/run/binary?tool= { found, path } for the `claude`, `codex` or `agy` binary
|
||||
GET /api/run/cwds Suggested cwds (dashboard, home, recent)
|
||||
GET /api/run/history?limit=&laneId= Persisted run history; laneId narrows to one lane's runs
|
||||
GET /api/run/history?limit=&laneId=&tool= Persisted run history; laneId narrows to one lane's runs, tool (with laneId) to one tool's — `shell` = Terminal tabs
|
||||
GET /api/run/files?cwd=&q= Fuzzy file search inside cwd for the @-file autocomplete
|
||||
POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <tmpdir>/agenthub-drops-<uid>/ with a random name, pruned after a day; the terminal pastes the path
|
||||
(skips node_modules, .git, dist, build, .next, .cache, coverage, vendor)
|
||||
@@ -2295,7 +2336,7 @@ DELETE /api/run/:id Kill (SIGTERM → SIGKILL after 5 s)
|
||||
|
||||
`permissionMode` (`auto` default, or `acceptEdits` / `default` / `plan` / `bypassPermissions`) is the mode the pane starts in — `auto` is the CLI's own classifier mode: it asks only for what it reads as risky. The pane's argv also carries `--allow-dangerously-skip-permissions`, which only makes **bypass permissions** selectable in the pane's `shift+tab` cycle — it does not enable it.
|
||||
|
||||
**PTY streaming:** Frames from the tmux pane are streamed to the client over `/ws-pty/:runId` as binary WebSocket frames (not JSON). The Workspace page's TerminalView component feeds these frames to xterm.js for live rendering. Simultaneously, `agenthub lanes shell` can attach the same session via a real local terminal, staying in sync with the browser view.
|
||||
**PTY streaming:** Frames from the tmux pane are streamed to the client over `/ws-pty/:runId` as binary WebSocket frames (not JSON). The Workspace page's TerminalView component feeds these frames to xterm.js for live rendering. Simultaneously, `agenthub workspace shell` can attach the same session via a real local terminal, staying in sync with the browser view.
|
||||
|
||||
Every history row carries `lane_id` (the workspace whose run it belongs to) and `tool` (the CLI that was invoked: `"claude"` or `"codex"`). A run's `id` is its tmux pane name, which each Start in that pane reuses, so the run it replaces is kept as its own row with `id` = `<pane name>@<its started_at>` (status `abandoned` if it never recorded an end). A row still marked `running` is flipped to `abandoned` once its tmux session is gone — checked at server start and on the 10 s run-heal beat (`DASHBOARD_RUN_HEAL_MS`); a pane that survived a server restart stays `running`. `GET /api/run/history?laneId=<n>` returns only that workspace's runs. Spawned Claude processes fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, and the Dashboard's Analytics tab (Workflow sub-tab included) automatically. Codex runs do not emit these hooks; they are recorded only in run history and `dashboard_runs`.
|
||||
|
||||
@@ -2368,7 +2409,7 @@ The mascot (Sagi) can wear a pet: right-click it for **Hide pet**, **Size** (48
|
||||
|
||||
A **Codex pet** is the `.codex-pet.zip` format Codex Desktop uses (the bundles shared on codex-pets.net and in `mileson/codex-pets`): `pet.json` (`id`, `displayName`, `description`, `spritesheetPath`) and `spritesheet.webp`, a 1536×1872 atlas of 8 columns × 9 rows of 192×208 frames — idle, run right, run left, waving, jumping, failed, waiting, running, review, with 6/8/8/4/5/8/6/6/6 frames. The mascot plays the row of its mood (an agent working → running, waiting for you → waiting, an error → failed, a finished turn → jumping, idle/asleep → idle). Only the two files are read, at the archive root or one folder deep; an entry's size is checked from the zip's directory before it is inflated; the sheet must be a WebP of exactly that size (`400 EBADPET` otherwise). An uploaded image is shown still and bobs; SVG is refused because an SVG served from the dashboard's origin runs script.
|
||||
|
||||
**Featured pets are downloaded, never bundled.** The four offered (Goku, Bac Ho, Border Collie, 11) are other people's uploads to codex-pets.net with no licence to redistribute them, so AgentHub ships none of their art: `POST /api/pets/featured/:id` takes only an id from the fixed list in `server/lib/pets.js` (`404 ENOPET` for any other, with no request made) and fetches `https://codex-pets.net/api/pets/<id>` and `/spritesheet` — no URL from the request, no redirects followed. A pet id is always made by the server (`p-<12 hex>` for an upload, `cp-<featured id>`), every write is same-origin (`403 EBADORIGIN`), and at most 50 pets are kept (`409 ETOOMANY`). Other errors: `413 ETOOBIG`, `502 EFETCH` (codex-pets.net unreachable or refusing).
|
||||
**Featured pets are downloaded, never bundled.** The three offered (Goku, Border Collie, 11) are other people's uploads to codex-pets.net with no licence to redistribute them, so AgentHub ships none of their art: `POST /api/pets/featured/:id` takes only an id from the fixed list in `server/lib/pets.js` (`404 ENOPET` for any other, with no request made) and fetches `https://codex-pets.net/api/pets/<id>` and `/spritesheet` — no URL from the request, no redirects followed. A pet id is always made by the server (`p-<12 hex>` for an upload, `cp-<featured id>`), every write is same-origin (`403 EBADORIGIN`), and at most 50 pets are kept (`409 ETOOMANY`). Other errors: `413 ETOOBIG`, `502 EFETCH` (codex-pets.net unreachable or refusing).
|
||||
|
||||
---
|
||||
|
||||
@@ -2550,7 +2591,7 @@ Sent when a notification is created.
|
||||
|
||||
#### /ws-pty/:runId — PTY frames
|
||||
|
||||
A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on workspace load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub lanes shell`, other tools), all receiving the same frames live-synced. **The attach URL carries the client's size** (`/ws-pty/:runId?cols=<n>&rows=<n>`, integers in `[2, 1000]`, else 80×24): tmux resizes the window to whichever client attached most recently, so spawning the attach at a default size and correcting it from the first `resize` message a beat later made the pane's program redraw its whole UI at 80 columns — and everything painted at that width stayed in the scrollback, surfacing as torn left-margin fragments the moment the user scrolled back (issue #33). Resize events after that: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and re-asserts it whenever the terminal regains focus, because `window-size latest` lets a second attach client (another tab, `agenthub lanes shell`) resize the tmux window out from under this one. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended — the Workspace takes that frame as the signal to restore the run setup form, since there is nothing left to attach to. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again.
|
||||
A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on workspace load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub workspace shell`, other tools), all receiving the same frames live-synced. **The attach URL carries the client's size** (`/ws-pty/:runId?cols=<n>&rows=<n>`, integers in `[2, 1000]`, else 80×24): tmux resizes the window to whichever client attached most recently, so spawning the attach at a default size and correcting it from the first `resize` message a beat later made the pane's program redraw its whole UI at 80 columns — and everything painted at that width stayed in the scrollback, surfacing as torn left-margin fragments the moment the user scrolled back (issue #33). Resize events after that: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and re-asserts it whenever the terminal regains focus, because `window-size latest` lets a second attach client (another tab, `agenthub workspace shell`) resize the tmux window out from under this one. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended — the Workspace takes that frame as the signal to restore the run setup form, since there is nothing left to attach to. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again.
|
||||
|
||||
#### cc_config_changed
|
||||
|
||||
|
||||
Reference in New Issue
Block a user