Includes review fixes: explicit ?full=1 trigger wired from initial page load, capture maxBuffer sized from config with -S line bound, capture returned alone (no byte-buffer duplication), early byte-cap before normalization.
- help-modal extractor bounds at the next HTML comment (cron modal's 'Run At'
text false-positived the stale-shortcut regex)
- remote-shell run test expects the wired /api/quick-start path (#145) — POST
/api/sessions has no caseName in its schema
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Includes review fixes: Session Manager aligned to the merged /api/sessions/unified contract with error states, Ctrl+K no longer leaks 0x0B into the PTY, shortcut registry finished (dispatch/persistence/rendering), shortcutOverrides preserved across settings saves, help modal kept reachable.
# Conflicts:
# README.md
# src/web/public/index.html
# src/web/public/session-ui.js
- saveAppSettings() rebuilds settings from the DOM; carry over shortcutOverrides
like showTokenCount/showCost so rebinding survives unrelated saves
- shortcut overlay footer links to the full help modal (its only opener was the
legacy Ctrl+? route this PR replaced)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- UI: add the missing data-tab="case-remote" tab button; dispatch it through
submitCaseModal()/switchCaseModalTab() to linkRemoteCase() (was dead code).
- Restore: restoreMuxSessions() now passes remote (muxSession.remote ??
savedState.remote) into the Session constructor, so remote metadata round-trips
on restart instead of reattaching from a local cwd / respawning LOCAL / being
erased from state.json. Recovery tests added.
- Run flows: runClaude()/runShell() route remote cases through /api/quick-start
(POST /api/sessions stat-validates workingDir locally); run*() skip the
/api/*/status pre-check and omit inert config/env for remote cases.
- Quick-start: resolve the remote case BEFORE the local CLI availability gates and
skip isCodex/Gemini/OpenCodeAvailable() when remote; REJECT
envOverrides/effort/codex/gemini/openCode config for remote (they don't cross
ssh) instead of silently dropping them.
- Injection: reject $, backtick, $( in remotePath + identityFile at the schema
layer (they survive shellescape into the bash -c launch double-quote layer).
Regression tests for $(...) and backtick payloads added.
- Remote socket/name: launch on a DEDICATED -L codeman-remote socket under a
codeman-ssh-<id> name that fails a remote Codeman's SAFE_MUX_NAME_PATTERN, so a
remote instance can't adopt the session; scope tmux set-options per-session
(never -g) so they don't mutate other sessions.
- Kill: best-effort ssh 'tmux -L codeman-remote kill-session' on remote session
kill (fire-and-forget, never blocks/throws the local kill) so the remote agent
isn't orphaned forever.
- Probe: wire checkRemoteTmuxAvailable() into POST /api/quick-start (structured
OPERATION_FAILED) and as courtesy validation in remote-link; add a default
-o ConnectTimeout=10 to buildSshConnectionArgs (overridable via extraSshOptions).
- Command default: remote claude default is now
'exec claude --dangerously-skip-permissions' (per-host override stays the escape
hatch), mirroring local non-interactive semantics.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Reject multi-line prompts end-to-end: schema refines on promptText/
launchCommand, runtime check in resolvePrompt (prompt-file content;
trailing newlines tolerated), matching cron-ui form validation — delivery
is single-line only, so multi-line was silently corrupted (typed mode
fused lines, paste mode submitted partials)
- Close the workingDir confinement bypass (arbitrary server-side file read,
e.g. workingDir=/proc + /proc/self/environ): realpath-resolve workingDir
before the containment check, reject '/' and blocked/pseudo-fs trees
(/proc, /sys, /dev + the attachment-guard blocklist) at fire time AND at
job create/update (workingDir must exist and be a directory)
- Session lifecycle: new per-job autoClosePreviousSession (default true,
recurring schedules only; ignored for 'once') — the previous run's
still-open session is closed via the normal cleanupSession path when the
next run fires; UI switch added; 50-session cap math documented in
docs/cron-guide.md §8
- skip_if_same_agent_running: count only live sessions (exclude
stopped/error dead tabs), exclude sessions created by this job's own runs
(fixes the fire-once-then-skip-forever self-deadlock), and a skipped
'once' job stays armed and retries next tick instead of being consumed;
liveness filter mirrored in cron-ui _countActiveAgents
- Wire launchCommand (was accepted+documented but dead): shell mode sends
it via writeViaMux as the first input line after startShell readiness
(single-line, schema-enforced); form field shown for shell agent type
- Record delivery failures: a false writeViaMux result now fails the run
instead of recording a false 'prompt_sent'
- Cap saved jobs at MAX_CRON_JOBS (100) to bound state.json growth
- Surface field-specific schema messages (drop parseBody custom
errorMessage on cron create/update)
- Tests: workingDir create/update validation, /proc bypass regression,
single-line enforcement (schema+runtime+trailing-newline tolerance),
live/own-session skip filtering, once-skip re-arm, auto-close on/off/once,
job-count cap
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Session Manager (COD-121/192): align _loadSessionManagerList() with the
merged #139 endpoint — map UnifiedSessionItem fields (lastActivityAt
epoch-ms → lastModified, optional sizeBytes/firstPrompt/name) to the
history-record shape _buildHistoryItem renders; surface non-2xx /
error-envelope responses as a visible message instead of a silent
"No sessions found"; route clicks by liveness (live row → selectSession,
history row → resumeHistorySession by conversation UUID) via a new
onActivate option so a live session is never duplicate-resumed
- Ctrl+K double-dispatch: gate the palette chord in
attachCustomKeyEventHandler (return false on keydown) so xterm never
writes 0x0b kill-line into the PTY while the palette opens; gate is
registry-aware so a rebound/disabled palette shortcut restores normal
terminal Ctrl+K
- Shortcut registry (COD-157) finished per maintainer decision: document
keydown now dispatches through getShortcutRegistry() +
matchesShortcutEvent() (legacy SHORTCUTS table removed), honoring
per-shortcut disable and rebinds incl. the palette chord; overrides
persist via saveAppSettingsToStorage() (correct device key + cache
coherence, was orphaned 'codeman:settings'); Shortcuts tab renders on
open via switchSettingsTab hook; capture uses a persistent listener that
ignores bare modifier keydowns (combos now capturable) and requires a
Ctrl/Cmd/Alt chord; settings rows use delegated listeners instead of
inline onclick (JS-string injection sink) and overrides can no longer
clobber id/label/action; added the missing row + overlay CSS
- matchesShortcutEvent: reject undeclared extra modifiers (Ctrl+Shift+K
no longer hijacked from Firefox devtools) while keeping Ctrl/Cmd
interchangeable; match physical code OR produced key for layout parity
- Registry/dispatch gaps: added restore-terminal-size entry, documented
Ctrl+Shift+R again in the help modal (test flipped to assert presence),
Ctrl+?/Alt+? now really open the registry-driven shortcut overlay, and
Escape closes it
- Palette new-session pick routes through selectQuickStartCase() so the
searchable combobox, dir display, and lastUsedCase stay in sync
- Removed fork cherry-pick debris: dead _onSessionListMaybeChanged(),
orphaned .session-row-menu CSS, nonexistent closeMobileHeaderUtilities
calls
- Tests: functional vm-harness coverage for the unified-list field
mapping + error state + liveness routing, palette chord shift/disable/
rebind handling, override persistence round-trip, capture flow, tab
render hook, and source guards for the PTY gate + registry dispatch
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Replace the 'missing ?tail means reload' overload with an explicit ?full=1
query param: the frontend's first buffer load after a page load (selectSession)
now requests full=1, tab switches keep ?tail=, and the legacy no-param callers
(response-viewer fallback, clearTerminal refresh) keep the cheap visible-frame
path — the COD-47 feature was previously unreachable from a real reload.
- When the full-history capture succeeds, return it ALONE instead of prepending
the byte buffer + \x1b[H\x1b[2J: the capture is the rendered superset of the
byte history, and ED2 clears only the viewport so the concat replayed the whole
conversation twice in xterm scrollback. The history+clear+frame concat stays
for the visible-frame/tab-switch path.
- Pass an explicit execSync maxBuffer for the full-history capture (configured
terminalBufferMaxBytes + slack) — the 1MB Node default ENOBUFS-killed exactly
the multi-MB captures the feature exists for; log ENOBUFS concisely instead of
dumping the truncated stdout.
- Bound the capture itself via -S -<N> derived from the configured tmux
history limit (was unbounded -S -), and add -J so lines hard-wrapped at the
capture-time pane width reflow in the browser xterm.
- Cap the concatenated buffer to terminalBufferMaxBytes EARLY (before the
regex normalization passes) so multi-MB captures don't stall the event loop
normalizing bytes that get sliced away.
- Tests: route tests updated for ?full=1 semantics (capture-alone response,
config-forwarded capture bounds, byte-history fallback, no-param requests
stay on the visible-frame path); source-scan tests cover the bounded -J -S -<N>
flags and explicit maxBuffer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Breaker reset is now explicit-only: POST /api/sessions/:id/interactive no
longer unconditionally resets the PTY-exit breaker (that endpoint IS the
frontend's automatic re-attach path, so the breaker could never trip on the
COD-115 crash loop and any tab click silently re-armed it). The route accepts
a schema-validated optional body flag {clearBreaker:true}
(InteractiveStartSchema) and resets only when it is sent.
- Frontend restart control: app.js selectSession keeps the bare auto-attach
(no body, never clears); when the selected session has respawnBlocked it asks
for explicit user confirmation and only then re-POSTs with clearBreaker:true.
respawnBlocked is surfaced via SessionState/toState() (runtime-only, not
restored on boot so recovery can re-attach).
- Trip observability: WebServer.setupSessionListeners() is now idempotent
(skips while refs are attached) and the re-attach routes (/interactive,
/interactive-respawn, /shell) re-run it, restoring the wiring that the exit
handler detaches on every PTY exit — without this the 5th-exit trip had
guaranteed zero listeners (no SSE, no push, no persist, no run-summary).
- Push notification: added SessionRespawnBreakerTripped to PUSH_EVENT_MAP
('Session crash loop stopped', urgency critical) with an exit-count body
branch; previously sendPushNotifications silently no-oped.
- Minor: buildMuxAttachEnv() truecolor param is now actually passed
(codex/gemini, mirrors buildEnvExports); buildClaudeEnv() uses delete for
COLORTERM/CLAUDECODE (same node-pty "KEY=undefined" quirk as COD-115).
- Tests: route tests assert auto-reattach does NOT reset, clearBreaker resets,
invalid flag rejected, and listener re-wiring on /interactive + /shell;
real-wiring lifecycle tests (createSessionListeners/attach/detach) prove the
exit-detach gap and that re-setup keeps the 5th-exit trip observable;
PUSH_EVENT_MAP regression guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- 92vh on iOS Safari measures the large viewport; with browser chrome visible the
panel top (header + close button) clipped off-screen. 88vh fallback + 92dvh
matches the repo's established dvh idiom.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- saveAppSettings no longer sends webglRendererEnabled on the settings PUT:
the key is absent from the .strict() SettingsUpdateSchema, so every save
400'd with INVALID_INPUT, silently killing all server-side settings
persistence. Stripped in the per-device destructure alongside
localEchoEnabled/skin/etc.
- shouldSkipWebGL now treats a stored true like the untouched default w.r.t.
the sticky marker: the checkbox defaults checked on desktop, so any
unrelated save stored true and every page load then cleared the
'codeman-webgl-disabled' marker, permanently defeating the GPU-stall
auto-fallback. Only ?webgl=force clears the marker at init.
- The marker is instead retired on a real OFF->ON toggle flip detected at
save time (mirrors the _prevGestureEnabled pattern in settings-ui.js).
- webglRendererEnabled added to the displayKeys per-device set in
loadAppSettingsFromServer (renderer choice is device/GPU-specific; syncing
would leak mobile's hidden-checkbox false onto desktop).
- Tests: stored true + sticky marker -> still skips WebGL; OFF->ON save
clears the marker and keeps the key off the wire; default-checked save
leaves the marker alone; ?webgl=force / ?nowebgl behavior unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- BLOCKER (privacy): the CJK diagnostic trace logged typed CONTENT — _esc(e.key)
per keystroke, up to 24 chars of textarea value on focus/blur/compstart/
compend/input, and the flushed text — mirrored into _crashDiag, which
persists to localStorage and beacons to POST /api/crash-diag. Traces are now
content-free: key CLASS via _kdesc (any single code point → 'printable',
named keys pass through), value lengths + phantom presence via _vdesc
(len=N[+ph]), and 'flush send len=N'. _esc removed.
- MAJOR: the onData self-heal refocused the CJK field whenever gated data
arrived with focus elsewhere — but onData also fires for xterm's
SELF-GENERATED query replies (DA/DSR/CPR/OSC during Ink redraws), so it
stole focus from rename/search/settings inputs while output streamed. Now
requires document.activeElement === this.terminal.textarea (genuine typed
input) and bails when shouldSuppressTerminalQueryResponse(data) matches.
- MAJOR: the pointerdown blur→setTimeout(focus,0) wedged-IME recovery ran on
ALL platforms; on iOS tapping the focused empty field is normal and the
async refocus is outside the user-gesture stack. The listener is now only
registered when /Android/i.test(navigator.userAgent).
- tests: trace-privacy test (no typed character or textarea value ever appears
in the trace; lengths/key classes still recorded), iOS harness asserts the
pointerdown recovery never cycles, self-heal source guard asserts both new
conditions; vm harness gained a ua option (navigator injected, Android UA
default so the existing wedged-IME test still exercises the recovery).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Duplicate rows: transcript-history rows are keyed by the Claude
conversation UUID (.jsonl filename stem), which diverges from the Codeman
session id for resumed (claudeSessionId = resumeSessionId != id) and
/clear-respawned sessions, so one conversation surfaced as both a live row
and a history-only row. mergeUnifiedSessions now builds an alias map
(claudeSessionId -> Codeman id) from the live + persisted views and
resolves history/lifecycle keys through it; the route feeds
SessionState.resumeSessionId as the persisted alias.
- Inverted precedence: SessionLifecycleLog.query() returns entries
NEWEST-first, but the merge loop unconditionally overwrote name/mode so
the OLDEST entry in the window won (stale rename/mode). First-seen now
wins, mirroring the existing lastActivityAt guard.
- Tests: resumed session yields ONE row (service unit + route end-to-end
with a real transcript fixture); renamed-then-deleted session surfaces
the NEWEST name/mode. All 4 new tests fail against the pre-fix code.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- _shouldForwardWheelToApp: claude sessions forward wheel to the TUI only
when the banner-parsed cliVersion is known AND >= 2.1.187 (older/unknown
Claude Code captures wheel as select-menu navigation → keep local
scrollLines); new dependency-free _cliVersionAtLeast semver-ish compare
- gemini excluded from wheel forwarding entirely (TUI wheel behavior
unverified); codex keeps forwarding (verified); taps/clicks still
forwarded for all strip modes
- link double-fire: registerFilePathLinkProvider links now track hover
state via ILink hover/leave callbacks (_linkHovered) and
_handleDesktopTerminalClick bails while a link is hovered, so a link
click no longer also sends a synthetic SGR press/release to the TUI
- help modal: document Shift+Wheel (scroll local history when mouse
passthrough is active)
- tests: version gate (2.1.186/unknown/garbage no forward, 2.1.187+
forwards), codex/gemini split, link-hover click suppression
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- UNBOUNDED-MEMORY: DEFAULT_TERMINAL_BUFFER_TRIM_BYTES from CODEMAN_TRIM_TERMINAL_TO
had no relation to DEFAULT_TERMINAL_BUFFER_MAX_BYTES — setting only
CODEMAN_MAX_TERMINAL_BUFFER=2097152 left the 24MB trim default in force, making
BufferAccumulator.trim() (slice(-trimSize)) a no-op: unbounded growth past the cap
plus a full string re-join on every append (O(n²)). Trim default is now clamped to
75% of the resolved max (the 24MB/32MB default ratio, preserved as hysteresis);
regression test re-evaluates the module under the env via vi.resetModules.
- OVERCLAIM: reverted DEFAULT_TERMINAL_SCROLLBACK_LINES 100k -> 50k — it has zero
consumers; browser xterm scrollback is the separate hardcoded DEFAULT_SCROLLBACK
(50k) in constants.js and deliberately stays 50k (mobile-memory hazard). The tmux
history-limit raise (50k -> 100k) and PTY 32MB/24MB raise remain (those are wired).
Module docstring now claims only what is wired; fixed the stale tmux-manager.ts
comment saying the tmux limit "matches the xterm-side default in constants.js".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A shell terminal could render output diagonally (each line shifted one
column right) after a full page reload or a cursor-query-failure replay.
Root cause: capturePaneBuffer's full-history path (capture-pane -p -e -S -)
and its cursor-query-failure fallback returned raw scrollback, which tmux
joins with a BARE \n. The browser xterm uses convertEol:false (correct for
the live PTY stream, which carries real \r\n), so each bare \n dropped a
row without returning the cursor to column 0 -> staircase. The visible /
tab-switch path (formatPaneSnapshot) was immune because it repaints each
row with an absolute cursor CSI.
Fix: new pure helper normalizeScrollbackEol() (\r?\n -> \r\n, idempotent
on CRLF, leaves lone \r overwrites untouched, adds/removes no rows) applied
at both raw-return seams. The absolute-positioned snapshot path is unchanged.
Tests: test/tmux-scrollback-eol.test.ts pins the invariant (no LF without a
preceding CR) + CRLF idempotency + lone-CR preservation. 136/136 across
tmux-scrollback-eol + tmux-capture-full-history + tmux-manager +
routes/session-routes; build, tsc, prettier, frontend-syntax clean.
A full page reload (GET /api/sessions/:id/terminal with no ?tail=) now captures
the ENTIRE tmux scrollback via capture-pane -p -e -S -, so users get back history
that scrolled off Codeman's byte buffer. Tab switches (?tail=N) keep the fast
visible-frame capture.
- tmux-manager capturePaneBuffer/captureActivePaneBuffer take { fullHistory }:
full-history returns raw linear scrollback (skips the single-screen
formatPaneSnapshot repaint, which would clip multi-screen history).
- /terminal selects full-history on full reload, visible on tail; caps the
payload at the configured terminalBufferMaxBytes (keeps most-recent bytes,
line-aligned) and returns source/fullSize/truncated metadata.
Verified: tsc 0, tmux-capture-full-history 5/5, session-routes 68/68.
Caveat: lines tmux already evicted past its history-limit can't be recovered.
Defense-in-depth after COD-115. If the interactive PTY exits non-zero
repeatedly within a short window, recovery/reconnect paths recreate it
indefinitely (COD-115 saw 114 'exited with code: 1' events + orphans).
- New pure InteractivePtyExitBreaker (session-pty-exit-breaker.ts):
injectable time, sliding window, clean-exit resets counter, stays
tripped until reset(). Defaults: threshold 5, window 10s.
- Session records each interactive PTY exit in the breaker; on trip it
flips _status to 'error', sets _respawnBlocked, emits
respawnBreakerTripped. startInteractive() refuses to respawn while
blocked, so all recovery/reconnect callers stop looping uniformly.
- Explicit user restart (POST /api/sessions/:id/interactive) calls
resetRespawnBreaker() so intentional restarts are never blocked.
- New SSE event session:respawnBreakerTripped wired in sse-events.ts +
constants.js (registries in sync) + session-listener-wiring.ts;
minimal diagnostic toast in app.js.
- Tests: test/respawn-pty-breaker.test.ts (pure trip/reset/window +
MockSession session-level trip/reset).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When the web server is launched from inside a tmux pane it inherits TMUX/
TMUX_PANE. tmux's nesting guard then makes every new attach-bridge PTY
(`tmux attach-session`, used by codex/opencode/gemini and mux-wrapped claude)
exit code 1; the respawn controller recreates the dead bridge → infinite loop.
The existing guard in buildMuxAttachEnv() used `TMUX: undefined` on a
{...process.env} spread, which leaves the KEY present with value undefined —
node-pty serializes it as the literal string "TMUX=undefined", still tripping
the guard. (The working create path in tmux-manager.ts uses `delete`.)
Fix:
- Primary: delete process.env.TMUX / TMUX_PANE at web bootstrap (src/index.ts)
so every downstream {...process.env} spread is clean regardless of launch
context. `delete`, not `= undefined`.
- buildMuxAttachEnv(): build a copy and `delete` TMUX/TMUX_PANE/CLAUDECODE
(and COLORTERM when not truecolor) instead of `: undefined` — same node-pty
quirk affected all of them.
- Test: assert the keys are genuinely ABSENT (`'TMUX' in env === false`), not
merely undefined — the prior test only checked `toBeUndefined()`, which is
why the bug slipped through. Red→green confirmed.
Verified on isolated beta launched from inside tmux (inherited the poisonous
TMUX=codeman,980,7): created a codex session + triggered interactive attach —
the bridge `tmux -L codeman-beta attach-session` spawned with NO TMUX in its
env, attached successfully, zero "exited with code: 1", server healthy.
Circuit-breaker for repeated non-zero bridge exits (AC bullet 4, optional)
split to a follow-up. Deploy-pending (substrate): never auto-deployed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pins a "Browse all sessions…" item at the bottom of the command palette
list (after "New session"). Activating it closes the palette and opens
the Session Manager modal, bridging the gap between the fast in-memory
switcher and the full server-side history browser.
The item gets a distinct visual treatment (≡ icon, muted title/icon
color, 4px top gap) so it reads as a secondary action separate from the
primary session rows.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- src/remote-hosts.ts: add missing execAsync = promisify(exec) that was
implied by intermediate commits not in the cherry-pick set
- src/web/routes/session-routes.ts: add getDataDir import and
readRemoteCases/readRemoteHosts/toSessionRemote for remote case support
in quick-start; narrow casePath string|null via resolvedCasePath cast
- test/routes/session-routes.test.ts: add vi.hoisted remoteStore mock for
remote-hosts.js; fix 'creates session from remote case' test to use
/api/quick-start (remote cases are not supported on /api/sessions)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
buildSshConnectionArgs interpolated jumpHost raw while its siblings
(identityFile/socksProxy/extraSshOptions) were shellescaped. The token array is
joined and run via execAsync (/bin/sh -c), so a jumpHost like "x; touch /tmp/pwned"
executed. The Zod denylist only blocked backtick/newline/$( and let ;|& and spaces
through.
- shellescape jumpHost in buildSshConnectionArgs (primary fix)
- replace jumpHost denylist with a structural allowlist: [user@]host[:port],
comma-separated multi-hop, bracketed IPv6; no shell metachar can appear
- update/extend tests: escaped -J assertion + injection-safety case
Verified: remote-ssh-options (11) + case-routes (33) pass, tsc --noEmit clean,
regex accepts valid forms / rejects 8 injection payloads.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Remote case form could only reach port-22, default-identity, directly
SSH-able hosts. Add an escape-hatch set of SSH connection options so Codeman
can reach a host like aa-desktop (custom port 2222, ed25519 identity, cloudflared
SOCKS5 ProxyCommand) the way ssh-aa-desktop does — without shelling out to that
wrapper.
- Model (types/session.ts): new optional RemoteSshOptions (identityFile,
socksProxy, jumpHost, extraSshOptions) on RemoteHost AND SessionRemote; all
absent = today's behavior. toSessionRemote() carries them case->session.
- Shared buildSshConnectionArgs(remote) in remote-hosts.ts: pure, exported,
ordered ssh connection tokens (-o BatchMode=yes, -p, -i <abs identity with
~/$HOME expanded + shellescaped>, -J, -o ProxyCommand=nc -X 5 -x <socks>
%h %p emitted as ONE shellescaped token so %h %p reach ssh literally, then
each extraSshOptions -o). Both buildRemoteLaunchCommand (tmux-manager.ts) and
buildRemoteTmuxCheckCommand now use it, so the prereq probe and the real
launch connect identically. checkRemoteTmuxAvailable widened to accept the
options (callers already pass the full host).
- Validation (schemas.ts): identityFile (no newline/NUL), socksProxy
(host:port), jumpHost (no shell metachars), extraSshOptions (KEY=VALUE,
reject newline/NUL/backtick/$() — defense-in-depth on operator-entered config.
- UI (index.html + session-ui.js): SSH Port field + collapsible "Advanced SSH"
section (identity, SOCKS proxy, jump host, extra -o options one per line);
wired into the remote-host create payload.
Empty-options remotes emit byte-identical ssh to before (pinned by test).
Tests: test/remote-ssh-options.test.ts (buildSshConnectionArgs +
buildRemoteLaunchCommand + buildRemoteTmuxCheckCommand for the aa-desktop set,
escaping/%h %p/identity-~ expansion, byte-identical back-compat); case-routes
schema tests (advanced options round-trip; malformed extraSshOptions/socksProxy
rejected). tsc/eslint/frontend-syntax/prettier/build clean.
Acceptance (real remote, no wrapper): the emitted command connected to
aa-desktop through the cloudflared SOCKS proxy and created a durable remote
tmux session (verified independently via ssh-aa-desktop: CONNECTED_NO_WRAPPER,
STILL_ALIVE_AFTER_DETACH); checkRemoteTmuxAvailable over the proxy returned
{ok:true, tmuxPath:/usr/local/bin/tmux}; test session cleaned up.
Desktop click-to-position-cursor died under the server's mouse-DECSET strip
(same root cause as the mobile touchend tap regression): xterm's native mouse
encoder only emits SGR while mouseTrackingMode is ON, but the server strips the
enabling DECSETs from claude/codex/gemini output. Hand-encode the report for
plain left-clicks (_handleDesktopTerminalClick), skipping every click that
already means something else (synthetic/compat, modified, double/triple,
drag-selection, off-grid, xterm encoder live).
Also widen forwarding to the wheel: Claude Code 2.1.187+ scrolls its own
transcript on SGR wheel reports and no longer captures wheel as select-menu
navigation (verified against 2.1.202), so forward the wheel to the TUI for
strip-mode sessions at the buffer bottom (40ms-coalesced to avoid a tmux
send-keys storm). Shift+wheel and any scrolled-up viewport stay on xterm's
local scrollback. Guard synthetic taps/clicks on viewport-at-bottom so a
scrolled-up report can't hit-test the wrong row.
Tests: 12 cases in test/terminal-touch-tap.test.ts. Verified E2E via Playwright
against the live instance (wheel up/down forward, Shift+wheel local, click).
v1.1.7 (3172bef, arrived via the master merge) strips mouse-tracking DECSET
sequences from claude/codex/gemini output so the wheel keeps scrolling
scrollback. Side effect: the browser xterm's mouseTrackingMode is permanently
'none' for those sessions, and the mobile touchend tap branch gates its
synthetic click on exactly that mode — so tap-to-position-cursor silently died.
Fix: when tracking reads 'none' but the session mode is one the server strips
(claude/codex/gemini — the PTY-side TUI still has tracking ON), encode the SGR
press+release report directly from the touch point and send it to the PTY,
bypassing xterm's mouse encoder. No DOM click is dispatched, so xterm's local
selection cannot trigger either.
Tests: 3 new cases in test/terminal-touch-tap.test.ts (SGR encoding, grid
clamping, shell-mode exclusion); verified E2E via Playwright iPhone emulation
against both a stripped-stream instance and the production bundle.
Three independent root causes of intermittent Chinese character loss
(English was unaffected because it bypasses the composition path):
1. input-cjk.js state machine: stuck _composing when compositionend never
fires (WeChat/Sogou IMEs) silently swallowed all input; the deferred
compositionend flush could reset the textarea mid-next-composition
(cancels the live IME composition on iOS); the 100ms keydown-echo
window discarded ANY input regardless of content.
2. Focus stealing: session-select / SSE-reconnect paths call
terminal.focus() (15+ call sites), landing focus on xterm's hidden
textarea; with the CJK onData gate active, everything typed there was
swallowed. Fix: focus router in initTerminal routes ALL
terminal.focus() calls to the CJK field while it is visible, plus a
self-healing onData gate that reclaims focus when it swallows input.
3. Android InputConnection wedge (9-key IMEs + Chromium): the keyboard
composes in its own UI but delivers zero DOM events. Fix: skip
redundant textarea value/selection writes (they race IME session
setup), and re-tapping the focused empty field forces a blur→focus
cycle that restarts the input session.
Diagnostics: input-cjk.js now traces every IME event/flush decision into
the crash-diag breadcrumbs; /api/crash-diag stores beacons per page-load
id (iOS PWA reloads no longer wipe the trail, concurrent clients no
longer clobber each other) and flushes on visibilitychange.
Tests: test/input-cjk.test.ts (vm-sandbox, 9 cases incl. regression
guards for all three root causes).
The mobile media query only overrode .response-viewer-body with a flat
font-size: 12px / padding: 12px, leaving the desktop response-viewer
typography system (--rv-content-max, .rv-text pre, heading scale) with no
mobile tuning. Bump body text to 14.5px/1.65, give code blocks phone-sized
padding and 11.5px code, scale headings (h1 1.35em / h2 1.2em / h3 1.08em),
let content span full width, and cap the panel at 92vh.
Layers cleanly on top of the existing response-viewer selectors in
styles.css; desktop rendering is unchanged.