closeWebSocket() nulls both WebSocketServer references but cannot unregister
the `server.on("upgrade")` listeners that initWebSocket/initPtyWebSocket
installed on the shared http.Server. An upgrade landing in that window threw
TypeError: Cannot read properties of null (reading 'handleUpgrade')
from the listener — unhandled, so it killed the process mid-SIGTERM instead of
letting it exit gracefully, and no server came back up. Observed in
runtime/server.log right after a restart, with the dashboard then showing
"Mất kết nối" and nothing else.
The previous commit's TerminalView reconnect makes this near-certain rather
than rare: every open run console re-attaches to /ws-pty every 1.5s, so a
shutdown almost always has an upgrade in flight. Both listeners now destroy
the socket when their server is gone; clients retry, which is the correct
answer during a shutdown.
Regression test uses a real http.Server and raw upgrade requests — without the
guard the process dies and the test run hangs rather than reporting a failure.
xterm emits ESC[Z for Shift+Tab but, unlike plain Tab, never marks the
keyboard event cancelled, so the browser ran its default action and moved
focus out of the terminal. The pane saw only the first press, which made
`shift+tab to cycle` (claude's permission mode) look dead on /run while
working fine in a real terminal.
A custom key handler now calls preventDefault() on that one combo and still
returns true, so xterm's own handling — including the ESC[Z it sends — is
untouched; only the focus-moving default is suppressed. Verified against a
scratch tmux session: writing ESC[Z into the attach pty cycles claude's
status line from `bypass permissions on` to `auto mode on`.
Trade-off: Shift+Tab no longer tab-reverses out of the terminal; click
elsewhere to move focus.
Three separate reasons the Workspace terminal looked unscrollable:
- xterm falls back, on an alt-screen buffer whose program has not enabled
mouse tracking, to translating each wheel notch into a cursor-key press.
Inside a `claude` pane that reads as arrow up/down, so scrolling walked the
prompt history. A custom wheel handler returning false suppresses only that
emulation branch; real mouse reports come from a separate listener xterm
registers when the program does request wheel events.
- TerminalView never reconnected, so a server restart left the pane frozen on
its last painted frame — still live-looking, but swallowing every keystroke
and mouse report. It now re-attaches 1.5s after an unexpected close and
stops only on the server's `exit` frame; tmux keeps the session, so the
re-attach repaints in full.
- tmux repaints the whole pane, so xterm's own scrollback is always empty and
its viewport scrollbar renders as a groove with no thumb that cannot move.
Hidden, since scrolling there goes through mouse reports, not the DOM.
Sessions created before the mouse-on fix landed (or before the server
restarted to pick it up) never got the set-option call, so their pane
still had no drag/wheel scroll. Moving it into pty-attach's attach()
makes it idempotent per-connection instead of once-at-birth.
Detail toggle was hidden and info forced-visible whenever layout was
single-pane; user needed the ability to hide it too. Scrollbar track
color matched the page background (surface-0), making it invisible —
moved it to surface-2 so the track reads against any surface.
Full rename across files, identifiers, CSS classes, and user-facing strings
(en/vi locales, ARCHITECTURE.md). Replaces the hand-drawn animated cat
avatar with the pig-superhero mascot artwork, split into parts to keep
per-eye cursor tracking and blink animation working; whole-mascot mood
transforms (breathe/bob/shake/tilt) and new zzz/bang/sparkle overlays
carry the rest of the mood expression since the traced art has no shared
palette to key off of.
Claude Code spawns plugin MCP servers straight from the read-only
git-content plugin cache, which never gets an npm install for mcp/. The
old tsc-multi-file build imported bare specifiers that only resolved
via mcp/node_modules, so every fresh plugin install/update produced a
-32000 reconnect failure. Bundle mcp/src into a single dependency-free
build/index.js instead; the one CJS-heavy import (express, only used
by --transport=http) is now dynamic so the stdio path never evaluates it.
The lane strip was a fixed-width vertical column beside the detail panel.
Flipped it to a horizontally scrolling row on top so the detail/console
area gets the full page width below it.
Session panes had a scrollbar track but no way to drag it — wheel/drag
events passed straight to the running program instead of entering tmux's
copy-mode scrollback. xterm.js already forwards mouse-tracking escapes
once tmux advertises them, so turning mouse mode on per-session is enough.
Docs and the command's own instructions previously implied /ccam-update
fetches new code; it only reinstalled deps and restarted the server
against whatever version was already resolved. It now also runs
`claude plugin marketplace update` + `claude plugin update ccam@<mp> -y`
first, and says plainly that /reload-plugins is still a manual step
(no CLI/script equivalent exists to apply it automatically).
The lane-switch effect only synced `handle` from whatever activeRuns
snapshot was already loaded, so navigating away and back to /run (a
fresh mount with activeRuns still null) left a running lane's console
stuck on the setup form until the user switched lanes and back.
Adds POST /api/updates/apply (pull, rebuild, restart) plus an "Update now"
button and hourly auto-check in the UI, for checkouts that are safely
fast-forwardable. Reintroduces self-restart (previously removed in edc25ca
for cross-environment reliability concerns) per explicit user request.
Without a lane, the picker had no cwd filter and listed every session
across every repo. Fall back to the free-typed cwd field so resume
suggestions stay scoped to the working directory in view.
Picking a session in the setup form's resume picker only staged the
selection: the user still had to type a prompt and press Run before the
lane's tmux session was started with `--resume`. A resume carries its own
transcript, so there was nothing to type.
The picker now fires the start directly with the picked session (passed
explicitly, since the parent's state has not landed on that tick), sends the
session's own cwd — which is what the locked cwd field already displays —
and the Run button no longer requires a prompt while a resume is selected.
Fresh runs still require one.
The cwd was synced only when laneId changed, but a pane can render before
GET /api/lanes has answered — split view restores its pane lanes from
localStorage, and layout 1 paints before the list loads. With no lane
resolved the field fell back to the home suggestion, and since laneId never
changed afterwards it stayed there. RunSetup submits that string verbatim to
POST /api/lanes/:id/start, so the run was started in the wrong folder.
Track the cwd in its own effect keyed on the resolved lane path rather than
on laneId, so it re-syncs as soon as the lane is known. The home default now
applies only while no lane is selected at all.
LaneConsolePane kept handle/cwd/prompt/runHistory in local state that was
never reset when the laneId prop changed, so selecting another lane swapped
the header and detail panel while the terminal stayed attached to the
previous lane's tmux session.
Reset the pane on lane switch and re-attach immediately to the new lane's
live run from GET /api/run when it has one, falling back to that lane's
setup form when it does not. `lanes`/`activeRuns` are read through a ref so
the page's 5s poll cannot wipe a half-typed prompt.
spawnRun adopted any existing `ccam-lane-<id>` tmux session without looking
at it, so a Resume issued while the session sat at a bare shell prompt (left
by `ccam lanes shell`, or by a `claude` that had already exited) dropped the
whole argv: no `--resume` ran, no initial prompt was typed, and the API still
answered 200. The lane's DB `run_id` is not set in that case, so the ERUNLIVE
guard in the start route never saw it either.
Reuse the pane instead of erroring: when the session exists and
`#{pane_current_command}` is a shell, type the argv into that pane and record
the run. A pane running a program (a live `claude`, an editor, a build) is
still adopted untouched, so attaching shows what is running rather than typing
over it. `sendCommand` POSIX single-quotes every argument and uses
`send-keys -l`, the only place in tmux.js that composes a command line.
The Workspace active-runs list only knew about runs this dashboard spawned,
so two `claude` sessions started by hand in terminal tabs showed up nowhere —
the list read "no active runs" while two agents were working.
Poll GET /api/sessions?status=active alongside the run list and merge those
sessions in as live rows, deduped against dashboard runs by session_id and
filtered to local sources with a cwd (a remote-source or cwd-less session
cannot be resumed on this machine).
External rows get no Attach action: the dashboard owns no tmux session for
them, so there is no PTY to bridge. They offer Resume, which reuses the
existing ensure-lane + start-with-resumeSessionId path to spawn a new
tmux-backed `claude --resume` in that folder — a second process on the same
transcript, not a view of the original terminal.
Four cases in lane-lifecycle.test.js call /start without stubbing PATH,
so they spawn the real system `claude` binary in a real tmux session
to simulate a stuck/live run. Each then mocks tmux's own exec calls to
fake has-session/kill-session for the app's checks, but never touches
the real spawned process — the mock only fools the app, not the OS.
Two of these leaked past every prior test run undetected (ccam-lane-22,
ccam-lane-24), surfacing in the dashboard's live "Dashboard runs" list
with no DB record and a garbage started_at, and reappearing in a
Workspace split pane pointed at a deleted temp directory.
Stub a lightweight fake `claude` on PATH (same pattern already used
correctly elsewhere in this file) instead of spawning the real CLI, and
explicitly kill the real tmux session in each test's teardown since the
app-level mock never reaches the OS process.
The lane-detail section is a flex-1 row item next to the lane list, but
lacked min-w-0. A flex item's intrinsic min-width defaults to its
content size, so PipelineMap's 16-node row (which relies on flex-1
min-w-0 truncate per node to shrink) pushed the whole panel wider than
its allotted space instead of compressing, spilling nodes off-screen.
Undeclared lanes default stage to the DB sentinel "idle", which never
matches a pipeline node — skip the mismatch warning in that case
instead of showing a false lane-action-failed banner. Also cap the
expandable lane-info block so it can't squeeze the console/split-view
out of the fixed-height detail panel.
The detail panel (LaneCard/PipelineMap/proof gallery/console) could
grow tall enough to visually crowd out the lane-strip carousel above
it once a run was attached in split view. Move the lane list into its
own scrolling vertical column beside the detail panel instead of
stacking it above, so neither can cover the other; also collapse the
info block by default in 2/4-pane split view (toggle to expand) and
move the pane-count control into the detail header.
- Extract ConsoleArea helper to eliminate ~60 lines of duplicated layout
toggle + grid rendering shared between currentLane and !currentLane branches
- Fix persistence test to validate the component's actual write path instead
of manually re-seeding localStorage (proves writeSplitViewState is called)
- Remove orphaned grid/pane code left by incomplete merge
Use proper guards before indexing getAllByTestId results. Store the array
in a variable first and assert the element is defined before using it, to
satisfy noUncheckedIndexedAccess strictness.
Added comprehensive tests for the split-terminal-view feature:
- Default 1-pane layout (no multi-pane UI)
- Switching to 2-pane and 4-pane layouts
- Persisting layout and lane selections to localStorage
- Fallback behavior when persisted lane IDs no longer exist
Fixed test expectation: when a persisted lane ID no longer exists and falls back
to unselected (null), both panes render as empty (pane-empty), not just one.
Updated Run snapshot to reflect the new layout toggle buttons.
Fixes regression from Tasks 1-2:
1. Run.defaultCwd.test.tsx was failing because LaneConsolePane wasn't
rendering when no lane was selected (in layout-1 with zero lanes).
2. Added fallback rendering of LaneConsolePane in Workspace when
!currentLane, so RunSetup stays reachable on fresh install.
3. Fixed early-return condition in LaneConsolePane to only show
dropdown-only placeholder when showLaneSelector=true && laneId=null
(split-view mode), not in layout-1 mode.
4. Added useEffect to sync cwd state with defaultCwd when it changes
asynchronously from parent.
5. Updated LaneConsolePane tests to reflect correct behavior.
6. Regenerated snapshot to match layout-1 output.
The wsConnected prop was destructured but unused. This restores the
live/offline status pill next to the page title, matching the original
Header function design and consuming the prop properly.
/ccam-open built only when client/dist was missing, so a stale bundle
silently outlived code fixes (seen 2026-08-13: fix b0bfc66 wasn't served
until a --force rebuild). Pass --force unconditionally.
Adopted lanes have source_repo = null; lane-mcp.js passed it raw to
readSourceMcpServers and threw ENOMCPCONFIG. Match the existing fallback
pattern in lane-env.js/lane-profile.js.
Selecting a lane card only updated selectedLaneId, leaving the run-setup
cwd field on whatever it was before — now selecting a lane also syncs
cwd to that lane's own working directory.
Server streams PTY output as binary WS frames, but the browser's default
binaryType ("blob") handed onmessage a Blob that never matched the
`typeof === "string"` check — every keystroke response was silently
dropped and the terminal stayed blank despite the backend streaming
correctly (verified via a raw ws client against the live tmux session).
xterm.js's onData hands the browser a plain string, and WebSocket.send(string)
always emits a TEXT frame — pty-attach.js only forwarded BINARY frames to
pty.write(), so every keystroke was silently dropped as an unparseable JSON
control message. Now any non-control text frame reaches the pty.
Deeper root cause of the terminal never accepting input at all: the /ws
WebSocketServer used the {server, path} shorthand, whose own internal
upgrade listener calls handleUpgrade() for every upgrade on the shared
http.Server and aborts with 400 on a path mismatch — killing /ws-pty/*
upgrades before the PTY server's own listener ever ran. Switched /ws to
noServer + a manual path-checked dispatch, matching /ws-pty's pattern.
node-pty ships prebuilt binaries for darwin/win32 only — on Linux it
needs a native build via its install script. The plugin's
--ignore-scripts install (scripts/plugin-bootstrap.js, deliberately
skipped to avoid requiring a build toolchain) silently left node-pty
unusable: server/lib/pty-attach.js's require() threw
"Cannot find module './prebuilds/linux-x64//pty.node'" the moment a
terminal was attached, leaving TerminalView permanently blank with no
visible error.
@lydell/node-pty is an API-compatible fork that ships each platform's
binary as a regular optionalDependency instead of a postinstall build
step, so a plain --ignore-scripts install resolves a working native
binding on Linux with no compiler needed. Verified by installing with
the exact `npm install --omit=dev --ignore-scripts` invocation the
plugin bootstrap uses and confirming require() succeeds.
Adds README.vi.md as the Vietnamese translation of README.md, and
documents the pipeline-template picker/CLI switch (ccam lanes pipeline)
added in the previous feature.
Fix two issues identified in code review:
1. Critical: onViewFromHistory was a silent no-op. Now navigates to the
SessionDetail page using the same route pattern as the external link in
RunHistory, allowing users to view a finished run's transcript.
2. Important: Removed dead slashCommands={[]} prop from RunSetup invocation.
Made slashCommands optional in RunSetupProps to maintain type safety while
reflecting that the discovery logic was removed.
All tests pass (396 client, 1152 server).
`npm run test:client` (Vitest/esbuild) doesn't type-check, so several
tasks' incomplete cleanup of the old RunConsole-era code in
Workspace.tsx (SlashCommand/BUILTIN_SLASH_COMMANDS references, old
RunStatus values, a stray `mode` field, a wrong `prompt` vs
`initialPrompt` key) went unnoticed through every review until `tsc
--noEmit` was run directly. Also fixes a stale `RunStatusPayload.exitCode`
read in Tabby's brain.ts and dangling RunStreamPayload/RunInputAckPayload
references left in types.ts.
- Fix POST /api/run param: change 'prompt' to 'initialPrompt' (Finding 1)
- Fix POST /api/run response shape: show actual fields from publicRun() (Finding 2)
- Delete stale 'Running and releasing lanes' section with old POST /api/lanes/:id/start (Finding 3)
- Delete stale POST /api/lanes/:id/message section — endpoint now returns 400 EUNSUPPORTED (Finding 4)
All changes verified against actual code:
- server/routes/run.js:285 uses body.initialPrompt
- server/lib/pty-run.js:144-156 publicRun() returns {id, laneId, status, cwd, model, ...}
- server/routes/lanes.js:1134 message action returns 400 error
- pty-run.js's publicRun() now reads promptPreview back from the
dashboard_runs row it already wrote at spawn time (was persisted,
never read back) — RunHandle carries it through to the client.
- Workspace.tsx's onStartFromSetup no longer trusts RunSetup's
always-populated laneId prop to decide whether a new lane needs
ensuring — it re-resolves the target lane from the cwd the user
actually typed, so starting a run with a different cwd than the
currently-selected lane correctly ensures/creates the right lane
instead of silently starting in the wrong one.
Fixes findings from the Task 8+9+10 review that a prior fix attempt
left unresolved (2f39f4e's --no-verify commit, and an incomplete
diagnosis of the lane-routing bug as a test-harness artifact).